Logout from next-auth with keycloak provider not works - reactjs

I have a nextjs application with next-auth to manage the authentication.
Here my configuration
....
export default NextAuth({
// Configure one or more authentication providers
providers: [
KeycloakProvider({
id: 'my-keycloack-2',
name: 'my-keycloack-2',
clientId: process.env.NEXTAUTH_CLIENT_ID,
clientSecret: process.env.NEXTAUTH_CLIENT_SECRET,
issuer: process.env.NEXTAUTH_CLIENT_ISSUER,
profile: (profile) => ({
...profile,
id: profile.sub
})
})
],
....
Authentication works as expected, but when i try to logout using the next-auth signOut function it doesn't works. Next-auth session is destroyed but keycloak mantain his session.

After some research i found a reddit conversation https://www.reddit.com/r/nextjs/comments/redv1r/nextauth_signout_does_not_end_keycloak_session/ that describe the same problem.
Here my solution.
I write a custom function to logout
const logout = async (): Promise<void> => {
const {
data: { path }
} = await axios.get('/api/auth/logout');
await signOut({ redirect: false });
window.location.href = path;
};
And i define an api path to obtain the path to destroy the session on keycloak /api/auth/logout
export default (req, res) => {
const path = `${process.env.NEXTAUTH_CLIENT_ISSUER}/protocol/openid-connect/logout?
redirect_uri=${encodeURIComponent(process.env.NEXTAUTH_URL)}`;
res.status(200).json({ path });
};
UPDATE
In the latest versions of keycloak (at time of this post update is 19.*.* -> https://github.com/keycloak/keycloak-documentation/blob/main/securing_apps/topics/oidc/java/logout.adoc) the redirect uri becomes a bit more complex
export default (req, res) => {
const session = await getSession({ req });
let path = `${process.env.NEXTAUTH_CLIENT_ISSUER}/protocol/openid-connect/logout?
post_logout_redirect_uri=${encodeURIComponent(process.env.NEXTAUTH_URL)}`;
if(session?.id_token) {
path = path + `&id_token_hint=${session.id_token}`
} else {
path = path + `&client_id=${process.env.NEXTAUTH_CLIENT_ID}`
}
res.status(200).json({ path });
};
Note that you need to include either the client_id or id_token_hint parameter in case that post_logout_redirect_uri is included.

So, I had a slightly different approach building upon this thread here.
I didn't really like all the redirects happening in my application, nor did I like adding a new endpoint to my application just for dealing with the "post-logout handshake"
Instead, I added the id_token directly into the initial JWT token generated, then attached a method called doFinalSignoutHandshake to the events.signOut which automatically performs a GET request to the keycloak service endpoint and terminates the session on behalf of the user.
This technique allows me to maintain all of the current flows in the application and still use the standard signOut method exposed by next-auth without any special customizations on the front-end.
This is written in typescript, so I extended the JWT definition to include the new values (shouldn't be necessary in vanilla JS
// exists under /types/next-auth.d.ts in your project
// Typescript will merge the definitions in most
// editors
declare module "next-auth/jwt" {
interface JWT {
provider: string;
id_token: string;
}
}
Following is my implementation of /pages/api/[...nextauth.ts]
import axios, { AxiosError } from "axios";
import NextAuth from "next-auth";
import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak";
// I defined this outside of the initial setup so
// that I wouldn't need to keep copying the
// process.env.KEYCLOAK_* values everywhere
const keycloak = KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
issuer: process.env.KEYCLOAK_ISSUER,
});
// this performs the final handshake for the keycloak
// provider, the way it's written could also potentially
// perform the action for other providers as well
async function doFinalSignoutHandshake(jwt: JWT) {
const { provider, id_token } = jwt;
if (provider == keycloak.id) {
try {
// Add the id_token_hint to the query string
const params = new URLSearchParams();
params.append('id_token_hint', id_token);
const { status, statusText } = await axios.get(`${keycloak.options.issuer}/protocol/openid-connect/logout?${params.toString()}`);
// The response body should contain a confirmation that the user has been logged out
console.log("Completed post-logout handshake", status, statusText);
}
catch (e: any) {
console.error("Unable to perform post-logout handshake", (e as AxiosError)?.code || e)
}
}
}
export default NextAuth({
secret: process.env.NEXTAUTH_SECRET,
providers: [
keycloak
],
callbacks: {
jwt: async ({ token, user, account, profile, isNewUser }) => {
if (account) {
// copy the expiry from the original keycloak token
// overrides the settings in NextAuth.session
token.exp = account.expires_at;
token.id_token = account.id_token;
}
return token;
}
},
events: {
signOut: ({ session, token }) => doFinalSignoutHandshake(token)
}
});

signOut only clears session cookies without destroying user's session on the provider.
Year 2023 Solution:
hit GET /logout endpoint of the provider to destroy user's session
do signOut() to clear session cookies, only if step 1 was successful
Implementation:
Assumption: you are storing user's idToken in the session object returned by useSession/getSession/getServerSession
create an idempotent endpoint (PUT) on server side to make this GET call to the provider
create file: pages/api/auth/signoutprovider.js
import { authOptions } from "./[...nextauth]";
import { getServerSession } from "next-auth";
export default async function signOutProvider(req, res) {
if (req.method === "PUT") {
const session = await getServerSession(req, res, authOptions);
if (session?.idToken) {
try {
// destroy user's session on the provider
await axios.get("<your-issuer>/protocol/openid-connect/logout", { params: id_token_hint: session.idToken });
res.status(200).json(null);
}
catch (error) {
res.status(500).json(null);
}
} else {
// if user is not signed in, give 200
res.status(200).json(null);
}
}
}
wrap signOut by a function, use this function to sign a user out throughout your app
import { signOut } from "next-auth/react";
export async function theRealSignOut(args) {
try {
await axios.put("/api/auth/signoutprovider", null);
// signOut only if PUT was successful
return await signOut(args);
} catch (error) {
// <show some notification to user asking to retry signout>
throw error;
}
}
Note: theRealSignOut can be used on client side only as it is using signOut internally.
Keycloak docs logout

Related

How to bypass NextAuth when given an external access token

This is not a common use case and even less a good practice, but for the purposes of my project, if an access token is passed as a parameter in the url (e.g.http://localhost:3000?accessToken=myAccessToken), I need to use it in my API calls and "disable" authentication with next auth.
The authentication process is just a fallback in case an accessToken is not passed.
My current implementation is:
storing the accessToken in a cookie in _app.tsx, before the auth
kicks and redirects to the login page :
_app.tsx
...
// Retrieving the callbackURL query params.
const { callbackUrl } = router.query;
// Retrieving the accessToken from the callbackURL.
const params = new URL(callbackUrl as string, 'https://example').searchParams;
const accessToken = params.get('accessToken');
// Storing it in a cookie.
if (storeNumber) {
document.cookie = `storeNumber=${storeNumber}`;
}
...
in my _middleware.ts file, trying to get this cookie, and authorize
the login if the token is present.
_middleware.ts :
export default withAuth({
pages: {
signIn: '/auth/signin',
},
callbacks: {
authorized: ({ req, token }) => {
const accessToken = getCookie('accessToken'); // => null
return !!accessToken;
},
},
});
I'm not even sure I can access the cookie from the _middleware.ts file, or if it's the right way to do this.
Any help would really be appreciated. Thank you guys.
If anyone wants the solution (doubt it), I managed to retrieve the cookie in the middleware like this :
callbacks: {
authorized: ({ req }) => {
const cookie = req.headers.get('cookie');
const accessToken = cookie.split('accessToken=')[1].split(';')[0];
console.log(accessToken);
// Do your logic
return !!accessToken
},
},

Protected Route by checking JWT saved in user's cookie

I just finished implementing Google social authentication in my NextJS + DjangoRest project following this blog post. I am trying to figure out how to make protected routes that will redirect users if they’re not logged in.
This is how I did it so far:
when user logs in, it saves the jwt_token in the cookie as httponly
uses axios with “withCredentials: true” to access the API endpoint which returns current user data(i.e. email)
saves the user data as a useContext(). When protected page loads, check if UserContext is empty or not and redirects to login page if it is empty.
The obvious problem is the UserContext is reset whenever user refreshes the page, even when the JWT token is still present in the cookies. And I have a feeling this isn’t the right way to implement this.
So how would I implement a similar feature in a non-hacky way? I cannot read jwt-token from cookies in the frontend as it is httponly. Is there a safe way to read user’s JWT token from cookies to test for authentication?
So if I am reading your question right then you can use getServerSide props on your page to detect if the user is authenticated with your api.
function Page({ isAuth }) {
return (
<>
<div>My secure page</div>
//if you return data from your token check api then you could do something like this
<div>Welcome back {isAuth.name}</div>
</>
)
}
export default Page
export async function getServerSideProps(context) {
const isAuth = await tokenChecker(context.cookies.jwt) // In your token checker function you can just return data or false.
if (!isAuth) { //if tokenChecker returns false then redirect the user to where you want them to go
return {
redirect: {
destination: `/login`,
}
};
}
//else return the page
return {
props: {
isAuth,
},
}
}
If this is not what you mean let me know and i can edit my answer.
I modified #Matt's answer slightly and typescript-friendly to solve my problem. It simply checks the user's cookies if they have a jwt_token value inside.
import cookies from 'cookies'
export const getServerSideProps = async ({
req,
}: {
req: { headers: { cookie: any } };
}) => {
function parseCookies(req: { headers: { cookie: any } }) {
var parsedCookie = cookie.parse(
req ? req.headers.cookie || '' : document.cookie
);
return parsedCookie.jwt_token;
}
const isAuth = parseCookies(req);
if (typeof isAuth === undefined) {
return {
redirect: {
destination: `/auth/sign_in`,
},
};
}
return {
props: {
isAuth,
},
};
};

How to get current provider of session in Next-auth

In Next-auth, We can get session related info like user: {name, email .. etc}
something as follows:
import { useSession } from "next-auth/client"
export default function Component() {
const [session, loading] = useSession()
if (session) {
return <p>Signed in as {session.user.email}</p>
}
return Sign in
}
I want to get also the current provider name in my component to be used. For you your information, next-auth supports login by many providers as Facebook, Twitter .. etc
For example, if the user logged in via Twitter Api, I want to get this piece of info and print it in his profile page.
Sources:
https://next-auth.js.org/v3/getting-started/client#usesession
Callbacks can be used for passing additional data to session object.
Provider details are provided the first time user signs in.
You can use jwt callback to store data in jwt cookie.
In [...nextauth].js :
const callbacks = {}
callbacks.jwt = async function jwt(token, user , account) {
if (user) {
token = { id: user.id , provider:account.provider , ...moreData}
}
return token
}
const options = {
providers,
callbacks
}
Note: You also need to use session callback for passing the token data to useSession hook.
callbacks.session = async function session(session, token) {
session.user = {
provider : token.provider,
id: dbUser.id,
profile: dbUser.profile,
}
return session
}
export default (req, res) => NextAuth(req, res, options)
This way provider will be stored in the token for subsequent requests.
Learn more about callbacks here : Callbacks

Login to chrome extension via website with AWS amplify

I'm building a chrome extension and am trying to implement user login and sign-up. I originally had the sign-up and login functionality in the popup portion of my chrome extension but, after examining some of the more popular chrome extensions like Grammarly and Honey, I realized that they use their websites to login and sign up users. I decided to do the same for various reasons.
I'm using React js for both my website and the popup. I am using AWS-Amplify to handle login, sign-up, and user sessions. When I open the popup I have it check for a user session using await Auth.currentSession(); after having logged in on my site with await Auth.signIn(email, password);. However, that doesn't work. I've read the Amplify docs but couldn't find an answer. I have functionality in my popup that requires access to AWS services.
How can I use AWS-Amplify to login to my chrome extension via my website?
I did end up figuring this out. I'm not sure if this is the "right way" to do this but it works. After logging in using amplify on my react web app I can grab the session and send it to the chrome extension. However, only JSONifible objects can be sent through the extension messaging api. So all the functions that come with the session are lost. However, you can rebuild the session from the information that can be sent through the messaging api. You rebuild the session, create a new CognitoUser object, and then attach the session to the user. Once that is done the session will be stored and amplify will be able to use it.
On the web side.
//Get the current session from aws amplify
const session = await Auth.currentSession();
const extensionId = 'your_extension_id';
chrome.runtime.sendMessage(extensionID, session,
function(response) {
// console.log(response);
});
On the extension side in background.js
// This is all needed to reconstruct the session
import {
CognitoIdToken,
CognitoAccessToken,
CognitoRefreshToken,
CognitoUserSession,
CognitoUser,
CognitoUserPool
} from "amazon-cognito-identity-js";
import {Auth} from "aws-amplify";
//Listen for incoming external messages.
chrome.runtime.onMessageExternal.addListener(
async function (request, sender, sendResponse) {
if (request.session) {
authenticateUser(request.session);;
} else {
console.log(request);
}
sendResponse("OK")
});
//Re-build the session and authenticate the user
export const authenticateUser = async (session) => {
let idToken = new CognitoIdToken({
IdToken: session.idToken.jwtToken
});
let accessToken = new CognitoAccessToken({
AccessToken: session.accessToken.jwtToken
});
let refreshToken = new CognitoRefreshToken({
RefreshToken: session.refreshToken.token
});
let clockDrift = session.clockDrift;
const sessionData = {
IdToken: idToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
ClockDrift: clockDrift
}
// Create the session
let userSession = new CognitoUserSession(sessionData);
const userData = {
Username: userSession.getIdToken().payload['cognito:username'],
Pool: new CognitoUserPool({UserPoolId: YOUR_USER_POOL_ID, ClientId: YOUR_APP_CLIENT_ID})
}
// Make a new cognito user
const cognitoUser = new CognitoUser(userData);
// Attach the session to the user
cognitoUser.setSignInUserSession(userSession);
// Check to make sure it works
cognitoUser.getSession(function(err, session) {
if(session){
//Do whatever you want here
} else {
console.error("Error", err);
}
})
// The session is now stored and the amplify library can access it to do
// whatever it needs to.
}
There is a configuration option in AWS Amplify where you can override the default token storage (which is localStorage in the browser)
class MyStorage {
// the promise returned from sync function
static syncPromise = null;
// set item with the key
static setItem(key: string, value: string): string;
// get item with the key
static getItem(key: string): string;
// remove item with the key
static removeItem(key: string): void;
// clear out the storage
static clear(): void;
// If the storage operations are async(i.e AsyncStorage)
// Then you need to sync those items into the memory in this method
static sync(): Promise<void> {
if (!MyStorage.syncPromise) {
MyStorage.syncPromise = new Promise((res, rej) => {});
}
return MyStorage.syncPromise;
}
}
// tell Auth to use your storage object
Auth.configure({
// REQUIRED - Amazon Cognito Region
region: process.env.REACT_APP_AWS_REGION,
// OPTIONAL - Amazon Cognito User Pool ID
userPoolId: process.env.REACT_APP_COGNITO_USER_POOL_ID,
// OPTIONAL - Amazon Cognito Web Client ID (26-char alphanumeric string)
userPoolWebClientId: process.env.REACT_APP_COGNITO_USER_POOL_CLIENT_ID,
// OPTIONAL - customized storage object
storage: MyStorage
});
More information here
What you could do is something like this, using chrome.store API
export class TokensStorage {
static setItem(key, value) {
chrome.storage.local.set({[key]: JSON.stringify(value)}, () => {
console.log('token stored');
});
}
// get item with the key
static getItem(key) {
return new Promise((resolve, reject) => {
chrome.storage.local.get([key], (result) => {
resolve(result)
});
})
}
// remove item with the key
static removeItem(key) {
chrome.storage.local.remove(key, () => {
console.log("item removed");
})
}
// clear out the storage
static clear() {
chrome.storage.local.clear(() => {
console.log("storage cleared");
})
}
}
Here is the solution I came up with while solving exactly the same problem. I hope this will also work for you: https://github.com/vocably/pontis
How to use it
npm install --save #vocably/pontis
// website-app/index.js
import { Auth } from '#aws-amplify/auth';
import { AppAuthStorage } from '#vocably/pontis';
const extensionId = 'baocigmmhhdemijfjnjdidbkfgpgogmb';
Auth.configure({
// The following line sets up the custom storage
// which exchages auth tokens with the extension
storage: new AppAuthStorage(extensionId),
// and the rest of Auth params:
region: 'eu-central-1',
userPoolId: 'eu-central-1_uSErPooL',
//etc...
});
// extension/service-worker.js
import { registerExtensionStorage } from '#vocably/pontis';
import { Auth } from '#aws-amplify/auth';
// The only function param is responsible for
// the storage type. It could be 'sync' or 'local'
const storage = registerExtensionStorage('sync');
Auth.configure({
// The following line sets up the custom extension
// storage which exchanges Auth tokens with the app
storage: storage,
// and the rest of Auth params:
region: 'eu-central-1',
userPoolId: 'eu-central-1_uSErPooL',
//etc...
});
That's it.

How to implement login authentication using react-redux?

After a bit of research, JWT is commonly used for login authentication because of its compact nature and easiness to parse. I have settled on using JWT. However, my question is on how to embed this in my redux paradigm. Assuming we have a sign up form, when a user fills in his or her credentials and clicks a submit button, this will invoke an action to create an action to create a JWT. Now, this action goes to the back-end of my application and the back-end of my application calls the JWT API? So this action is an asynchronous/rpc call? Also, how does routing happen exactly? I have used react-router before, but using a boilerplate. I am building this web app from scratch and so I am a bit confused on where to deal with the routing and where do I pass this token exactly that I obtain from the server the first time? Is the token used every time a user does a request? How does the client know about this token every time it does the request so that it would keep a user authenticated?
When a user submits his credentials (email/password) your backend authenticates that for the first time and only this time does the backend use these credentials. On authentication your backend will create a JWT with some of the user information, usually just the user ID. There are plenty of JWT Libraries and even jwt-decode for javascript to do this. The backend will respond with this JWT where the front-end will save it (ie, localStorage.setItem('authToken', jwt)) for every subsequent request.
The user will send a request with the JWT in the request header under the Authorization key. Something like:
function buildHeaders() {
const token = localStorage.getItem('authToken')
return {
"Accept": "application/json",
"Content-Type": "application/json"
"Authorization": `${token}`
}
}
Your backend will now decode and authenticate the JWT. If it's a valid JWT the request continues, if not it's rejected.
Now with React-Router you can protect authenticated routes with the onEnter function. The function you provide does any necessary checks (check localStorage for JWT and if a current user). Typically I've done this:
const _ensureAuthenticated = (nextState, replace) => {
const { dispatch } = store
const { session } = store.getState()
const { currentUser } = session
const token = localStorage.getItem("phoenixAuthToken")
if (!currentUser && token) { // if no user but token exist, still verify
dispatch(Actions.currentUser())
} else if (!token) { // if no token at all redirect to sign-in
replace({
pathname: "/sign-in",
state: { nextPathname: nextState.location.pathname}
})
}
}
You can use this function in any route like so:
<Route path="/secret-path" onEnter={_ensureAuthenticated} />
Check out jwt.io for more information on JWT's and the react-router auth-flow example for more information on authentication with react-router.
I personally use Redux saga for async API calls, and I'll show You the flow I've been using for JWT authorization:
Dispatch LOG_IN action with username and password
In your saga You dispatch LOGGING_IN_PROGRESS action to show e.x. spinner
Make API call
Retrieved token save e.x. in localstorage
Dispatch LOG_IN_SUCCESS or LOG_IN_FAILED to inform application what response did You get
Now, I always used a separate function to handle all my requests, which looks like this:
import request from 'axios';
import {get} from './persist'; // function to get something from localstorage
export const GET = 'GET';
export const POST = 'POST';
export const PUT = 'PUT';
export const DELETE = 'DELETE';
const service = (requestType, url, data = {}, config = {}) => {
request.defaults.headers.common.Authorization = get('token') ? `Token ${get('token')}` : '';
switch (requestType) {
case GET: {
return request.get(url, data, config);
}
case POST: {
return request.post(url, data, config);
}
case PUT: {
return request.put(url, data, config);
}
case DELETE: {
return request.delete(url, data, config);
}
default: {
throw new TypeError('No valid request type provided');
}
}
};
export default service;
Thanks to this service, I can easily set request data for every API call from my app (can be setting locale also).
The most interesting part of it should be this line:
request.defaults.headers.common.Authorization = get('token') ? `Token ${get('token')}` : '';`
It sets JWT token on every request or leave the field blank.
If the Token is outdated or is invalid, Your backend API should return a response with 401 status code on any API call. Then, in the saga catch block, you can handle this error any way You want.
I recently had to implement registration and login with React & Redux as well.
Below are a few of the main snippets that implement the login functionality and setting of the http auth header.
This is my login async action creator function:
function login(username, password) {
return dispatch => {
dispatch(request({ username }));
userService.login(username, password)
.then(
user => {
dispatch(success(user));
history.push('/');
},
error => {
dispatch(failure(error));
dispatch(alertActions.error(error));
}
);
};
function request(user) { return { type: userConstants.LOGIN_REQUEST, user } }
function success(user) { return { type: userConstants.LOGIN_SUCCESS, user } }
function failure(error) { return { type: userConstants.LOGIN_FAILURE, error } }
}
This is the login function of the user service that handles the api call:
function login(username, password) {
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
};
return fetch('/users/authenticate', requestOptions)
.then(response => {
if (!response.ok) {
return Promise.reject(response.statusText);
}
return response.json();
})
.then(user => {
// login successful if there's a jwt token in the response
if (user && user.token) {
// store user details and jwt token in local storage to keep user logged in between page refreshes
localStorage.setItem('user', JSON.stringify(user));
}
return user;
});
}
And this is a helper function used to set the Authorization header for http requests:
export function authHeader() {
// return authorization header with jwt token
let user = JSON.parse(localStorage.getItem('user'));
if (user && user.token) {
return { 'Authorization': 'Bearer ' + user.token };
} else {
return {};
}
}
For the full example and working demo you can go to this blog post

Categories

Resources