-
-
Save degitgitagitya/db5c4385fc549f317eac64d8e5702f74 to your computer and use it in GitHub Desktop.
# KEYCLOAK BASE URL | |
KEYCLOAK_BASE_URL= | |
# KEYCLOAK CLIENT SECRET | |
KEYCLOAK_CLIENT_SECRET= | |
# KEYCLOAK CLIENT ID | |
KEYCLOAK_CLIENT_ID= | |
# BASE URL FOR NEXT AUTH | |
NEXTAUTH_URL= | |
# JWT SECRET KEY | |
JWT_SECRET= | |
# NEXT AUTH SECRET KEY | |
SECRET= | |
# JWT SIGNING PRIVATE KEY | |
JWT_SIGNING_PRIVATE_KEY= |
import NextAuth from 'next-auth'; | |
import KeycloakProvider from 'next-auth/providers/keycloak' | |
import type { JWT } from 'next-auth/jwt'; | |
/** | |
* Takes a token, and returns a new token with updated | |
* `accessToken` and `accessTokenExpires`. If an error occurs, | |
* returns the old token and an error property | |
*/ | |
/** | |
* @param {JWT} token | |
*/ | |
const refreshAccessToken = async (token: JWT) => { | |
try { | |
if (Date.now() > token.refreshTokenExpired) throw Error; | |
const details = { | |
client_id: process.env.KEYCLOAK_CLIENT_ID, | |
client_secret: process.env.KEYCLOAK_CLIENT_SECRET, | |
grant_type: ['refresh_token'], | |
refresh_token: token.refreshToken, | |
}; | |
const formBody: string[] = []; | |
Object.entries(details).forEach(([key, value]: [string, any]) => { | |
const encodedKey = encodeURIComponent(key); | |
const encodedValue = encodeURIComponent(value); | |
formBody.push(encodedKey + '=' + encodedValue); | |
}); | |
const formData = formBody.join('&'); | |
const url = `${process.env.KEYCLOAK_BASE_URL}/token`; | |
const response = await fetch(url, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', | |
}, | |
body: formData, | |
}); | |
const refreshedTokens = await response.json(); | |
if (!response.ok) throw refreshedTokens; | |
return { | |
...token, | |
accessToken: refreshedTokens.access_token, | |
accessTokenExpired: Date.now() + (refreshedTokens.expires_in - 15) * 1000, | |
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, | |
refreshTokenExpired: | |
Date.now() + (refreshedTokens.refresh_expires_in - 15) * 1000, | |
}; | |
} catch (error) { | |
return { | |
...token, | |
error: 'RefreshAccessTokenError', | |
}; | |
} | |
}; | |
// If you have the latest version of next-auth | |
// Please use this next auth provider instead of my custom provider https://next-auth.js.org/providers/keycloak | |
// const keycloakProvider = KeycloakProvider({ | |
// clientId: process.env.KEYCLOAK_CLIENT_ID, | |
// clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, | |
// issuer: process.env.KEYCLOAK_ISSUER, | |
// authorization: { | |
// params: { | |
// grant_type: 'authorization_code', | |
// scope: | |
// 'openid tts-saas-user-attribute email speech-api profile console-prosa payment-service', | |
// response_type: 'code' | |
// } | |
// }, | |
// httpOptions: { | |
// timeout: 30000 | |
// } | |
// }) | |
export default NextAuth({ | |
// providers: [keycloakProvider], | |
providers: [ | |
{ | |
id: 'keycloak', | |
name: 'Keycloak', | |
type: 'oauth', | |
version: '2.0', // Double check your keycloak version | |
params: { grant_type: 'authorization_code' }, | |
scope: 'openid email profile console-prosa basic-user-attribute', | |
accessTokenUrl: `${process.env.KEYCLOAK_BASE_URL}/token`, | |
requestTokenUrl: `${process.env.KEYCLOAK_BASE_URL}/auth`, | |
authorizationUrl: `${process.env.KEYCLOAK_BASE_URL}/auth`, | |
clientId: process.env.KEYCLOAK_CLIENT_ID, | |
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, | |
profileUrl: `${process.env.KEYCLOAK_BASE_URL}/userinfo`, | |
profile: (profile) => { | |
return { | |
...profile, | |
id: profile.sub, | |
}; | |
}, | |
authorizationParams: { | |
response_type: 'code', | |
}, | |
}, | |
], | |
session: { | |
jwt: true, | |
}, | |
jwt: { | |
secret: process.env.JWT_SECRET, | |
signingKey: process.env.JWT_SIGNING_PRIVATE_KEY, | |
}, | |
secret: process.env.SECRET, | |
callbacks: { | |
/** | |
* @param {object} user User object | |
* @param {object} account Provider account | |
* @param {object} profile Provider profile | |
* @return {boolean|string} Return `true` to allow sign in | |
* Return `false` to deny access | |
* Return `string` to redirect to (eg.: "/unauthorized") | |
*/ | |
async signIn(user, account) { | |
if (account && user) { | |
return true; | |
} else { | |
// TODO : Add unauthorized page | |
return '/unauthorized'; | |
} | |
}, | |
/** | |
* @param {string} url URL provided as callback URL by the client | |
* @param {string} baseUrl Default base URL of site (can be used as fallback) | |
* @return {string} URL the client will be redirect to | |
*/ | |
async redirect(url, baseUrl) { | |
return url.startsWith(baseUrl) ? url : baseUrl; | |
}, | |
/** | |
* @param {object} session Session object | |
* @param {object} token User object (if using database sessions) | |
* JSON Web Token (if not using database sessions) | |
* @return {object} Session that will be returned to the client | |
*/ | |
async session(session, token: JWT) { | |
if (token) { | |
session.user = token.user; | |
session.error = token.error; | |
session.accessToken = token.accessToken; | |
} | |
return session; | |
}, | |
/** | |
* @param {object} token Decrypted JSON Web Token | |
* @param {object} user User object (only available on sign in) | |
* @param {object} account Provider account (only available on sign in) | |
* @param {object} profile Provider profile (only available on sign in) | |
* @param {boolean} isNewUser True if new user (only available on sign in) | |
* @return {object} JSON Web Token that will be saved | |
*/ | |
async jwt(token, user, account) { | |
// Initial sign in | |
if (account && user) { | |
// Add access_token, refresh_token and expirations to the token right after signin | |
token.accessToken = account.accessToken; | |
token.refreshToken = account.refreshToken; | |
token.accessTokenExpired = | |
Date.now() + (account.expires_in - 15) * 1000; | |
token.refreshTokenExpired = | |
Date.now() + (account.refresh_expires_in - 15) * 1000; | |
token.user = user; | |
return token; | |
} | |
// Return previous token if the access token has not expired yet | |
if (Date.now() < token.accessTokenExpired) return token; | |
// Access token has expired, try to update it | |
return refreshAccessToken(token); | |
}, | |
}, | |
}); |
// Client example | |
import { signIn, useSession } from "next-auth/client"; | |
import { useEffect } from "react"; | |
const HomePage() { | |
const [session] = useSession(); | |
useEffect(() => { | |
if (session?.error === "RefreshAccessTokenError") { | |
signIn('keycloak', { | |
callbackUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/application`, | |
}); // Force sign in to hopefully resolve error | |
} | |
}, [session]); | |
return (...) | |
} |
import type { User } from 'next-auth'; | |
declare module 'next-auth' { | |
/** | |
* Returned by `useSession`, `getSession` and received as a prop on the `Provider` React Context | |
*/ | |
interface Session { | |
user: { | |
sub: string; | |
email_verified: boolean; | |
name: string; | |
preferred_username: string; | |
given_name: string; | |
family_name: string; | |
email: string; | |
id: string; | |
org_name?: string; | |
telephone?: string; | |
}; | |
error: string; | |
} | |
/** | |
* The shape of the user object returned in the OAuth providers' `profile` callback, | |
* or the second parameter of the `session` callback, when using a database. | |
*/ | |
interface User { | |
sub: string; | |
email_verified: boolean; | |
name: string; | |
telephone: string; | |
preferred_username: string; | |
org_name: string; | |
given_name: string; | |
family_name: string; | |
email: string; | |
id: string; | |
} | |
/** | |
* Usually contains information about the provider being used | |
* and also extends `TokenSet`, which is different tokens returned by OAuth Providers. | |
*/ | |
interface Account { | |
provider: string; | |
type: string; | |
id: string; | |
accessToken: string; | |
accessTokenExpires?: any; | |
refreshToken: string; | |
idToken: string; | |
access_token: string; | |
expires_in: number; | |
refresh_expires_in: number; | |
refresh_token: string; | |
token_type: string; | |
id_token: string; | |
'not-before-policy': number; | |
session_state: string; | |
scope: string; | |
} | |
/** The OAuth profile returned from your provider */ | |
interface Profile { | |
sub: string; | |
email_verified: boolean; | |
name: string; | |
telephone: string; | |
preferred_username: string; | |
org_name: string; | |
given_name: string; | |
family_name: string; | |
email: string; | |
} | |
} | |
declare module 'next-auth/jwt' { | |
/** Returned by the `jwt` callback and `getToken`, when using JWT sessions */ | |
interface JWT { | |
name: string; | |
email: string; | |
sub: string; | |
name: string; | |
email: string; | |
sub: string; | |
accessToken: string; | |
refreshToken: string; | |
accessTokenExpired: number; | |
refreshTokenExpired: number; | |
user: User; | |
error: string; | |
} | |
} |
Thank you for this helper code!
But, if user leave the web app (change browser tab), go back to the web app while the access token is expired, then
refreshAccessToken()
function is called multiple times concurrently if multiple components calluseSession()
. I do not have any problem at moment, but maybe it should be a good idea to prevent any concurrent call torefreshAccessToken()
? This avoid having Keycloak being flooded by multiple refresh token requests? What do you think?
Hi, you're welcome.
It's generally fine, since Keycloak is designed to handle thousands (and even millions) of user requests, depending on the Keycloak server's capacity.
Optimizing it can be a bit tricky. Since most Next.js servers are deployed on serverless platforms, it's hard to sync each request because they might be running on different edge or serverless functions.
However, you could apply a technique called deduplication (you can research this). What I'm thinking is using Redis to dedupe the requests, so only one refresh token process is performed.
Alternatively, you could move the logic for refreshing the token to the client (browser) by creating a Next.js API endpoint to handle the refresh. This way, you can have more control over when the refresh token method is called. However, you should still keep the client ID and secret on the server.
Thank you for your fast answer.
I'm fine with the "Keycloak is designed to handle thousands (and even millions) of user requests" argument.
But, on the web app side, any change to fall in a race condition? I am not sure about that but I think of something like "refreshAccessToken
is called concurrently with he same refresh token, the first call to Keycloak works but the second one failed because the same refresh token is used multiple times in a short period"?
Edit: what I use currently is a lock (using a promise) to be sure that refreshAccessToken is called sequentially, but maybe it's not useful ...
Thank you for this helper code!
But, if user leave the web app (change browser tab), go back to the web app while the access token is expired, thenrefreshAccessToken()
function is called multiple times concurrently if multiple components calluseSession()
. I do not have any problem at moment, but maybe it should be a good idea to prevent any concurrent call torefreshAccessToken()
? This avoid having Keycloak being flooded by multiple refresh token requests? What do you think?Hi, you're welcome.
It's generally fine, since Keycloak is designed to handle thousands (and even millions) of user requests, depending on the Keycloak server's capacity.
Optimizing it can be a bit tricky. Since most Next.js servers are deployed on serverless platforms, it's hard to sync each request because they might be running on different edge or serverless functions.
However, you could apply a technique called deduplication (you can research this). What I'm thinking is using Redis to dedupe the requests, so only one refresh token process is performed.
Alternatively, you could move the logic for refreshing the token to the client (browser) by creating a Next.js API endpoint to handle the refresh. This way, you can have more control over when the refresh token method is called. However, you should still keep the client ID and secret on the server.
Thank you for your fast answer.
I'm fine with the "Keycloak is designed to handle thousands (and even millions) of user requests" argument. But, on the web app side, any change to fall in a race condition? I am not sure about that but I think of something like "
refreshAccessToken
is called concurrently with he same refresh token, the first call to Keycloak works but the second one failed because the same refresh token is used multiple times in a short period"?Edit: what I use currently is a lock (using a promise) to be sure that refreshAccessToken is called sequentially, but maybe it's not useful ...
Ahh, okay. The old access token will not become expired on token refresh, since you have to revoke the access token to make it invalid.
Sorry, I misunderstood your question.
Ok so everything should be good!
Thank you!
Ok so everything should be good! Thank you!
You're welcome :)
Hello, another question:
On some tutorial of Keycloak-js one can see the usage of a setInterval()
with the kc.updateToken()
method in order to have the access token automatically refreshed even if we not do any "action" on the web app. This way (I guess), even after a long period of inactivity we can expect to have a valid access token.
On the other side (if I understand correctly), with the code given here, we do not automatically update the access token thanks to the refresh token, even, we only try to update an expired access token when we need this access token, meaning that after a long period of inanity we are in a case where both the access token and the refresh token are expired so we need to login from scratch.
Is the setInterval
trick a good approach? Should we adapt a similar trick with next-auth. Or, is it the right way to do with the current behavior?
Thank you
Hello, another question:
On some tutorial of Keycloak-js one can see the usage of a
setInterval()
with thekc.updateToken()
method in order to have the access token automatically refreshed even if we not do any "action" on the web app. This way (I guess), even after a long period of inactivity we can expect to have a valid access token.On the other side (if I understand correctly), with the code given here, we do not automatically update the access token thanks to the refresh token, even, we only try to update an expired access token when we need this access token, meaning that after a long period of inanity we are in a case where both the access token and the refresh token are expired so we need to login from scratch.
Is the
setInterval
trick a good approach? Should we adapt a similar trick with next-auth. Or, is it the right way to do with the current behavior? Thank you
Hi,
It depends on your authentication policies. The best practice is to set the access token expiration time to about 5 to 15 minutes and the refresh token expiration time to one day or even one month. This way, the user experience will be smooth but still secure (similar to Google, where we rarely have to log in again). The short lifespan of the access token is a security measure in case it's intercepted, while the long-lived refresh token ensures a seamless user experience by allowing the application to get new access tokens without requiring a full re-login.
Thank you for this helper code!
But, if user leave the web app (change browser tab), go back to the web app while the access token is expired, then
refreshAccessToken()
function is called multiple times concurrently if multiple components calluseSession()
.I do not have any problem at moment, but maybe it should be a good idea to prevent any concurrent call to
refreshAccessToken()
? This avoid having Keycloak being flooded by multiple refresh token requests?What do you think?
Edit: Related discussion: nextauthjs/next-auth#3940