nextAuth SignOut and revoking app sessions
-
I'm trying to use FusionAuth with nextAuth, and when I call SignOut() from my app, the nextAuth token is destroyed, and then I have it redirect to my api/logout route where I call FusionAuth's oauth2/logout url which destroys the FusionAuth SSO session; however, I can't destroy the application sessions that I still see for that user via the admin console. I have a logout url configured for the tenant which calls my api/endSession route, but I was hoping to get the userId from the nextAuth token here to destroy all refreshTokens; however, at this point, the nextAuth token is gone.
What is the correct way to sign out of all sessions when using nextAuth?
-
@laurahernandez What is the Logout behavior set to for your Application?
-
@mark-robustelli I have it set to "All applications".
I got this working, but please let me know if there is a better way.
Basically the flow is: hit the sign out button, this navigates to api/auth/global-logout which reads the refreshTokenId that had been stored in the nextAuth token (because of the jwt callback defined in authOptions), and uses that to revoke that session for that user. Then the fusionAuth OAuth logout URL (oauth2/logout) is called which revokes the FusionAuth SSO session and then that calls the tenant Logout URL which is the /logout page. Then the logout page calls nextAuth's signOut() to destroy the nextAuth cookies on the client and redirect to /login.
This GitHub conversation helped me get this done: https://github.com/nextauthjs/next-auth/discussions/3938#discussioncomment-2165150
Reading the whole dialog before that post really helped me understand what was going on.
Then this FusionAuth example shows how to revoke refresh tokens: https://github.com/FusionAuth/fusionauth-example-node-centralized-sessions/blob/main/changebank/src/index.ts
(the premise of the setup: https://github.com/FusionAuth/fusionauth-example-node-centralized-sessions)
And of course the background information as to why we have to do all this: https://fusionauth.io/docs/lifecycle/authenticate-users/logout-session-managementI'm still working on it, but it does work already, and the code looks like this:
For the nextAuth setup:api/lib/authOptions.ts
import FusionAuthProvider from 'next-auth/providers/fusionauth'; const fusionAuthIssuer = process.env.FUSIONAUTH_ISSUER; export const fusionAuthClientId = process.env.FUSIONAUTH_CLIENT_ID; const fusionAuthClientSecret = process.env.FUSIONAUTH_CLIENT_SECRET; export const fusionAuthUrl = process.env.FUSIONAUTH_URL; export const fusionAuthTenantId = process.env.FUSIONAUTH_TENANT_ID; export const fusionAuthRefreshTokenScopedApiKey = process.env.FUSION_AUTH_REFRESHTOKEN_API_KEY; const missingError = 'missing in environment variables.'; if (!fusionAuthIssuer) { throw Error('FUSIONAUTH_ISSUER' + missingError); } if (!fusionAuthClientId) { throw Error('FUSIONAUTH_CLIENT_ID' + missingError); } if (!fusionAuthClientSecret) { throw Error('FUSIONAUTH_CLIENT_SECRET' + missingError); } if (!fusionAuthUrl) { throw Error('FUSIONAUTH_URL' + missingError); } if (!fusionAuthTenantId) { throw Error('FUSIONAUTH_TENANT_ID' + missingError); } if (!fusionAuthRefreshTokenScopedApiKey) { throw Error('FUSION_AUTH_REFRESHTOKEN_API_KEY' + missingError); } export const authOptions = { providers: [ FusionAuthProvider({ issuer: fusionAuthIssuer, clientId: fusionAuthClientId, clientSecret: fusionAuthClientSecret, wellKnown: `${fusionAuthUrl}/.well-known/openid-configuration/${fusionAuthTenantId}`, tenantId: fusionAuthTenantId, // Only required if you're using multi-tenancy authorization: { params: { scope: 'openid offline_access email profile', }, }, }), ], callbacks: { async session({ session, token }: any) { session.user = token.user; return session; }, async jwt({ token, account, user }: any) { // console.log({ token, account, user }); if (user) { token.user = user; } // Persist the OAuth access_token to the token right after signin to use in token revocation if (account) { // token.accessToken = account.access_token; token.refreshTokenId = account.refresh_token_id; } return token; }, }, pages: { signIn: '/login', // Custom login page }, debug: true, };
And at api\auth[...nextauth]\route.ts
import NextAuth from 'next-auth'; import { authOptions } from '../lib/authOptions'; const handler = NextAuth(authOptions); export { handler as GET, handler as POST };
Then in your FusionAuth setup:
Configure the tenant "logout URL" (under OAuth tab) to http://localhost:3000/logout (I did it at the tenant level, but it can also be specified per application), and define that page in your Next app:/logout.ts:
'use client'; import { signOut } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; export default function LogoutPage() { const router = useRouter(); useEffect(() => { async function signout() { await signOut({ redirect: false }); router.push('/login'); // ... } signout(); }, []); return ( <> <div>Please wait while you are being logged out</div> </> ); }
The sign out button on your app:
{session && ( <button onClick={async () => { window.location.href = '/api/auth/global-logout'; }} type='button' > Sign Out </button> )}
Your api/auth/global-logout route
import { NextRequest, NextResponse } from 'next/server'; import jwt from 'next-auth/jwt'; import FusionAuthClient from '@fusionauth/typescript-client'; import { fusionAuthClientId, fusionAuthRefreshTokenScopedApiKey, fusionAuthTenantId, fusionAuthUrl, } from '../lib/authOptions'; export async function GET(req: NextRequest) { try { const token = await jwt.getToken({ req, secret: process.env.NEXTAUTH_SECRET, }); console.debug('token', token); if (!token) { console.warn('No JWT token found when calling /global-logout endpoint'); return NextResponse.redirect(process.env.NEXTAUTH_URL!); } console.log('token?.user', token?.user); if (!token.idToken) console.warn( "Without an id_token the user won't be redirected back from the IdP after logout.", ); const fusionAuthLogoutUrl = getLogoutRedirectUrl(req, token.idToken); //revoke this token ID. Should I revoke more? const refreshTokenId = token?.refreshTokenId; if (refreshTokenId && typeof refreshTokenId === 'string') { if (fusionAuthRefreshTokenScopedApiKey && fusionAuthUrl) { console.debug('revoking refresh token with ID: ', refreshTokenId); const client = new FusionAuthClient( fusionAuthRefreshTokenScopedApiKey, fusionAuthUrl, fusionAuthTenantId, ); const revokeResponse = await client.revokeRefreshTokenById( refreshTokenId, ); console.debug(revokeResponse); } } // revoke ALL active user sessions regardless of device, application, etc // const user = token?.user; // if (user && typeof user === 'object' && 'id' in user) { // console.log('got user info: ', user); // const userId = (user as { id: string; email: string }).id; // if (userId && fusionAuthRefreshTokenScopedApiKey && fusionAuthUrl) { // console.log('revoking all user refreshTokens (sessions)'); // const client = new FusionAuthClient( // fusionAuthRefreshTokenScopedApiKey, // fusionAuthUrl, // fusionAuthTenantId, // ); // console.log('revoking '); // const revokingResponse = await client.revokeRefreshTokensByUserId( // userId, // ); // console.log(revokingResponse.wasSuccessful()); // console.log(revokingResponse.exception); // } // } // to initiate the process of ending FusionAuth's SSO session and follow the configured logout URLs for the application. See https://github.com/FusionAuth/fusionauth-example-node-centralized-sessions/blob/main/changebank/src/index.ts return NextResponse.redirect(fusionAuthLogoutUrl); } catch (error) { console.error(error); console.error(JSON.stringify(error)); return NextResponse.redirect(process.env.NEXTAUTH_URL!); } } function getLogoutRedirectUrl(req: NextRequest, idToken: any): string { const { searchParams } = new URL(req.url); const postLogoutRedirectUri = searchParams.get('post_logout_redirect_uri'); const params = new URLSearchParams(); if (postLogoutRedirectUri) { params.set('post_logout_redirect_uri', postLogoutRedirectUri); } if (fusionAuthTenantId) { params.set('tenantId', fusionAuthTenantId); } if (fusionAuthClientId) { params.set('client_id', fusionAuthClientId); } else if (idToken) { params.set('id_token_hint', idToken); } return `${fusionAuthUrl}/oauth2/logout?${params.toString()}`; }
There is also commented out code that shows how to revoke all sessions like in the FusionAuth example, but I guess that would revoke sessions that are from another device as well which I'd rather not do.
-
@laurahernandez This appears to be the right approach. I am a bit confused on whether you got it working or not in terms of no longer seeing the sessions in the AdminUI after your flow. Please let me know.
-
@mark-robustelli Yes, it's all working. After the logout flow executes, the sessions that were being left behind on FusionAuth are now being revoked properly. Thanks.