-
Notifications
You must be signed in to change notification settings - Fork 3
feat(auth): implement back-channel logout and session invalidation logic #79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,49 @@ | ||||
| import { NextRequest, NextResponse } from 'next/server'; | ||||
|
|
||||
| declare global { | ||||
| // eslint-disable-next-line no-var | ||||
| var invalidatedSessions: Set<string> | undefined; | ||||
| } | ||||
|
|
||||
| /** | ||||
| * Back-channel logout endpoint to handle logout requests from Keycloak | ||||
| */ | ||||
| export async function POST(request: NextRequest) { | ||||
| try { | ||||
| const formData = await request.formData(); | ||||
| const logoutToken = formData.get('logout_token'); | ||||
|
|
||||
| if (!logoutToken || typeof logoutToken !== 'string') { | ||||
| return NextResponse.json({ error: 'Missing logout_token' }, { status: 400 }); | ||||
| } | ||||
|
|
||||
| // Decode the JWT to get the session ID (sid) without verification | ||||
| const [, payload] = logoutToken.split('.'); | ||||
| const decodedPayload = JSON.parse(Buffer.from(payload, 'base64').toString()); | ||||
|
Comment on lines
+20
to
+22
|
||||
|
|
||||
| const sid = decodedPayload.sid; | ||||
| const sub = decodedPayload.sub; // User ID | ||||
|
|
||||
| if (!sid && !sub) { | ||||
| return NextResponse.json({ error: 'Invalid logout_token' }, { status: 400 }); | ||||
| } | ||||
|
|
||||
| // Store the invalidated session/user in a cache | ||||
| if (typeof globalThis.invalidatedSessions === 'undefined') { | ||||
| globalThis.invalidatedSessions = new Set(); | ||||
| } | ||||
| if (sid) { | ||||
| globalThis.invalidatedSessions.add(sid); | ||||
| } | ||||
| if (sub) { | ||||
| globalThis.invalidatedSessions.add(sub); | ||||
| } | ||||
Nudelsuppe42 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
|
|
||||
| console.log('Back-channel logout received'); | ||||
|
|
||||
|
Comment on lines
+42
to
+43
|
||||
| console.log('Back-channel logout received'); |
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -2,18 +2,18 @@ | |||
|
|
||||
| import type { Session } from 'next-auth'; | ||||
| import { SessionProvider, signOut } from 'next-auth/react'; | ||||
| import { usePathname } from 'next/navigation'; | ||||
| import { useEffect } from 'react'; | ||||
|
|
||||
| export default function AuthProvider({ session, children }: { session: Session | null; children: React.ReactNode }) { | ||||
| const pathname = usePathname(); | ||||
|
|
||||
| useEffect(() => { | ||||
| if (session?.error === 'ForceLogout' || (!session && !pathname?.startsWith('/auth'))) { | ||||
| signOut(); | ||||
| // Force logout if session has error or no user data when it should | ||||
| if (session?.error === 'ForceLogout' || (session && !session.user)) { | ||||
| console.log('AuthProvider: Forcing logout due to invalid session'); | ||||
|
||||
| console.log('AuthProvider: Forcing logout due to invalid session'); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,24 @@ | ||
| export { default } from 'next-auth/middleware'; | ||
| import { withAuth } from 'next-auth/middleware'; | ||
|
|
||
| export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|auth).*)'] }; | ||
| export default withAuth({ | ||
| callbacks: { | ||
| authorized: ({ token }) => { | ||
| // Block access if token has error or doesn't exist | ||
| if ( | ||
| token?.error === 'RefreshAccessTokenError' || | ||
| token?.error === 'TokenInvalidated' || | ||
| token?.error === 'ForceLogout' | ||
| ) { | ||
| return false; | ||
| } | ||
| return !!token; | ||
| }, | ||
| }, | ||
| pages: { | ||
| signIn: '/auth/signin', | ||
| }, | ||
| }); | ||
|
|
||
| export const config = { | ||
| matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|auth).*)'], | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,7 @@ import { AuthOptions, Session, getServerSession } from 'next-auth'; | |||||||
|
|
||||||||
| import { JWT } from 'next-auth/jwt'; | ||||||||
| import KeycloakProvider from 'next-auth/providers/keycloak'; | ||||||||
| import { isSessionInvalidated, markSessionAsChecked } from './invalidatedSessions'; | ||||||||
|
|
||||||||
| /** | ||||||||
| * Refreshes access token to continue the session after token expiration | ||||||||
|
|
@@ -55,7 +56,7 @@ const refreshAccessToken = async (token: JWT) => { | |||||||
| refreshTokenExpired: nextRefreshExpiry, | ||||||||
| }; | ||||||||
| } catch (error) { | ||||||||
| console.error('Failed to refresh access token', error); | ||||||||
| console.log('Failed to refresh access token', error); | ||||||||
|
||||||||
| console.log('Failed to refresh access token', error); | |
| console.error('Failed to refresh access token', error); |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The authorization scope has been explicitly set to 'openid email profile', but for back-channel logout to work properly with session_state tracking, the scope may need to include additional parameters. Verify that Keycloak is configured to include the session_state (sid) in the logout token with these scopes.
According to OpenID Connect Back-Channel Logout specifications, some identity providers require specific configuration or scopes to enable back-channel logout support.
| scope: 'openid email profile', | |
| // Include offline_access to better support session management and back-channel logout. | |
| scope: 'openid email profile offline_access', |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential race condition: Between checking if a session is invalidated (line 125) and marking it as checked (line 126), another request could add the same session back to the invalidated set. While this is unlikely in practice, it could lead to inconsistent behavior.
Consider using a single atomic operation if possible, or document this behavior. Alternatively, the session ID/user ID could be removed within the isSessionInvalidated check itself to avoid the separate call to markSessionAsChecked.
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Debug console.log statements should be removed before merging to production. These logs will add noise to production logs and may expose sensitive token information.
Consider removing or converting these to debug-level logs that can be conditionally enabled based on environment.
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential issue: The return type is cast to Session but the returned object only contains 'expires' and 'error', missing required Session fields like 'user'. While TypeScript will accept this due to the cast, it may cause runtime issues in components expecting a properly typed Session.
Consider checking if this partial Session object is properly handled by all consuming code, or define a more specific type that extends Session to include the error property without misleading type information.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| /** | ||
| * Global store for invalidated sessions | ||
| * This is set by the backchannel-logout endpoint when Keycloak notifies | ||
| */ | ||
|
|
||
| declare global { | ||
| // eslint-disable-next-line no-var | ||
| var invalidatedSessions: Set<string> | undefined; | ||
| } | ||
|
|
||
| export function isSessionInvalidated(sessionId: string | undefined, userId: string | undefined): boolean { | ||
| if (!globalThis.invalidatedSessions) { | ||
| return false; | ||
| } | ||
|
|
||
| if (sessionId && globalThis.invalidatedSessions.has(sessionId)) { | ||
| return true; | ||
| } | ||
|
|
||
| if (userId && globalThis.invalidatedSessions.has(userId)) { | ||
| return true; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| export function markSessionAsChecked(sessionId: string | undefined, userId: string | undefined): void { | ||
| // Remove from invalidated sessions after checking to prevent memory buildup | ||
| if (sessionId) { | ||
| globalThis.invalidatedSessions?.delete(sessionId); | ||
| } | ||
| if (userId) { | ||
| globalThis.invalidatedSessions?.delete(userId); | ||
| } | ||
|
Comment on lines
+27
to
+34
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical security vulnerability: The backchannel-logout endpoint has no authentication or authorization checks. Any external party can send requests to this endpoint to invalidate arbitrary sessions.
This endpoint should be protected with authentication (e.g., verify the request comes from Keycloak using IP allowlists, shared secrets, or by verifying the JWT signature). According to OpenID Connect Back-Channel Logout specs, the logout token should be validated to ensure it comes from a trusted identity provider.