I think I had some success so far. I was able to hit the right endpoint, found here after all. Below is the code, however I still have to implement the token refresh logic. I will update the code when I'm done. At this point I have another question though:
In order to enhance NextAuth token with required Hasura custom claims, I need to set a new iat
and exp
value. These values are passed to NextAuth by FusionAuth in the account.accessToken
property on the jwt
callback event. Is there a way and is it safe to decode and grab all values from account.accessToken
? It looks like it contains exactly all props I need, together with exact iat
and exp
.
// [...nextauth].js api route
import {cleanEnv, host, str} from 'envalid'
import jwt from 'jsonwebtoken'
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
const env = cleanEnv(process.env, {
FUSIONAUTH_DOMAIN: host(),
FUSIONAUTH_CLIENT_ID: str(),
FUSIONAUTH_SECRET: str(),
FUSIONAUTH_TENANT_ID: str(),
SECRET: str(),
})
const FUSIONAUTH_REFRESH_TOKEN_URL = `${FUSIONAUTH_DOMAIN}/oauth2/token?`
async function refreshAccessToken(token) {
try {
const url =
FUSIONAUTH_REFRESH_TOKEN_URL +
new URLSearchParams({
client_id: env.FUSIONAUTH_CLIENT_ID,
client_secret: env.FUSIONAUTH_SECRET,
tenant_id: env.FUSIONAUTH_TENANT_ID,
grant_type: "refresh_token",
refresh_token: token.refreshToken,
});
const response = await fetch(url, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
method: "POST",
});
const refreshedTokens = await response.json();
if (!response.ok) {
throw refreshedTokens;
}
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, // Fall back to old refresh token
};
} catch (error) {
console.log(error);
return {
...token,
error: "RefreshAccessTokenError",
};
}
}
export default NextAuth({
providers: [
Providers.FusionAuth({
id: 'fusionauth',
name: 'FusionAuth',
domain: env.FUSIONAUTH_DOMAIN,
tenantId: env.FUSIONAUTH_TENANT_ID,
clientId: env.FUSIONAUTH_CLIENT_ID,
clientSecret: env.FUSIONAUTH_SECRET,
scope: 'offline_access',
}),
],
secret: env.SECRET,
session: {jwt: true},
jwt: {
secret: env.SECRET,
async encode({secret, token}) {
const jwtClaims = {
...token,
iat: Date.now() / 1000,
exp: Math.floor(Date.now() / 1000) + 60 * 30,
'https://hasura.io/jwt/claims': {
'x-hasura-allowed-roles': token.roles,
'x-hasura-default-role': 'user',
'x-hasura-role': 'user',
'x-hasura-user-id': token.sub,
},
}
return jwt.sign(jwtClaims, secret, {algorithm: 'RS512'})
},
async decode({secret, token}) {
return jwt.verify(token, secret, {algorithms: ['RS512']})
},
},
pages: {},
callbacks: {
async jwt(token, user, account, profile) {
if (user && profile) {
token.id = user.id
token.roles = profile.roles
}
return token
},
async session(session, token) {
if (token) {
const encodedToken = jwt.sign(token, env.SECRET, {algorithm: 'RS512'})
session.id = token.id
session.token = encodedToken
session.error = token.error
}
return session
},
},
events: {},
})