From 02d59ccc1b2de31c319dac67a0744667bde811e0 Mon Sep 17 00:00:00 2001 From: Nudesuppe42 Date: Wed, 28 Jan 2026 00:45:03 +0100 Subject: [PATCH 1/2] feat(auth): implement back-channel logout and session invalidation logic --- .../api/auth/backchannel-logout/route.ts | 49 +++++++++++++++++++ .../src/app/(sideNavbar)/auth/signin/page.tsx | 5 +- .../dashboard/src/components/AuthProvider.tsx | 12 ++--- .../layout/header/HeaderProfile.tsx | 2 +- .../dashboard/src/components/layout/index.tsx | 4 +- apps/dashboard/src/middleware.ts | 27 +++++++++- apps/dashboard/src/util/auth.ts | 35 +++++++++++-- .../dashboard/src/util/invalidatedSessions.ts | 35 +++++++++++++ .../typings/next-auth/next-auth.d.ts | 1 + 9 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 apps/dashboard/src/app/(sideNavbar)/api/auth/backchannel-logout/route.ts create mode 100644 apps/dashboard/src/util/invalidatedSessions.ts diff --git a/apps/dashboard/src/app/(sideNavbar)/api/auth/backchannel-logout/route.ts b/apps/dashboard/src/app/(sideNavbar)/api/auth/backchannel-logout/route.ts new file mode 100644 index 00000000..763e8395 --- /dev/null +++ b/apps/dashboard/src/app/(sideNavbar)/api/auth/backchannel-logout/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; + +declare global { + // eslint-disable-next-line no-var + var invalidatedSessions: Set | 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()); + + 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); + } + + console.log('Back-channel logout received'); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + console.error('Back-channel logout error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/apps/dashboard/src/app/(sideNavbar)/auth/signin/page.tsx b/apps/dashboard/src/app/(sideNavbar)/auth/signin/page.tsx index 03861855..2b855801 100644 --- a/apps/dashboard/src/app/(sideNavbar)/auth/signin/page.tsx +++ b/apps/dashboard/src/app/(sideNavbar)/auth/signin/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { Button } from '@mantine/core'; import { signIn, useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; @@ -19,5 +20,7 @@ export default function SigninPage() { } }, [status, router]); - return

Redirecting...

; + return ( + + ); // TODO: Add button to trigger sign-in manually } diff --git a/apps/dashboard/src/components/AuthProvider.tsx b/apps/dashboard/src/components/AuthProvider.tsx index efe86f23..ce065027 100644 --- a/apps/dashboard/src/components/AuthProvider.tsx +++ b/apps/dashboard/src/components/AuthProvider.tsx @@ -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'); + signOut({ callbackUrl: '/auth/signin', redirect: true }); } }, [session]); - if (session?.error === 'ForceLogout') { + + if (session?.error === 'ForceLogout' || (session && !session.user)) { return null; } diff --git a/apps/dashboard/src/components/layout/header/HeaderProfile.tsx b/apps/dashboard/src/components/layout/header/HeaderProfile.tsx index 2f9db19c..32024f8d 100644 --- a/apps/dashboard/src/components/layout/header/HeaderProfile.tsx +++ b/apps/dashboard/src/components/layout/header/HeaderProfile.tsx @@ -36,7 +36,7 @@ const HeaderProfile = () => { } return null; } - if (!session.data) return null; + if (!session.data || !session.data.user || !session.data.user.username) return null; return ( diff --git a/apps/dashboard/src/components/layout/index.tsx b/apps/dashboard/src/components/layout/index.tsx index d772a1df..a0484c1b 100644 --- a/apps/dashboard/src/components/layout/index.tsx +++ b/apps/dashboard/src/components/layout/index.tsx @@ -27,11 +27,11 @@ export default async function AppLayout({ children, hideNavbar, customNavbar, .. p="md" {...props} > - {!hideNavbar && } + {!hideNavbar && } {customNavbar} -
+
{children} diff --git a/apps/dashboard/src/middleware.ts b/apps/dashboard/src/middleware.ts index 429326b2..4cc8b417 100644 --- a/apps/dashboard/src/middleware.ts +++ b/apps/dashboard/src/middleware.ts @@ -1,3 +1,26 @@ -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 }) => { + console.log('Middleware authorized check, token:', token); + // Block access if token has error or doesn't exist + if ( + token?.error === 'RefreshAccessTokenError' || + token?.error === 'TokenInvalidated' || + token?.error === 'ForceLogout' + ) { + console.log('Access denied due to token error:', token?.error); + return false; + } + return !!token; + }, + }, + pages: { + signIn: '/auth/signin', + }, +}); + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|auth).*)'], +}; diff --git a/apps/dashboard/src/util/auth.ts b/apps/dashboard/src/util/auth.ts index a51606e8..d3f6e2d3 100644 --- a/apps/dashboard/src/util/auth.ts +++ b/apps/dashboard/src/util/auth.ts @@ -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); return { ...token, error: 'RefreshAccessTokenError', @@ -73,6 +74,11 @@ export const authOptions: AuthOptions = { clientId: process.env.NEXT_PUBLIC_KEYCLOAK_ID || '', clientSecret: process.env.KEYCLOAK_SECRET || '', issuer: process.env.NEXT_PUBLIC_KEYCLOAK_URL || '', + authorization: { + params: { + scope: 'openid email profile', + }, + }, profile: (profile) => { return { ...profile, @@ -103,9 +109,24 @@ export const authOptions: AuthOptions = { token.refreshToken = account.refresh_token; token.accessTokenExpired = Date.now() + (accessExpiresIn - 15) * 1000; token.refreshTokenExpired = refreshExpiresIn ? Date.now() + (refreshExpiresIn - 15) * 1000 : undefined; + token.sessionId = account.session_state; token.user = user; return token; } + + // If token already has an error (from previous check), return it immediately + if (token.error) { + return token; + } + + // Check if this session was invalidated via back-channel logout + const sessionId = token.sessionId as string | undefined; + const userId = token.sub; + if (isSessionInvalidated(sessionId, userId)) { + markSessionAsChecked(sessionId, userId); + return { ...token, error: 'TokenInvalidated' }; + } + // Return previous token if the access token has not expired yet if (Date.now() < token.accessTokenExpired || token.accessTokenExpired == null) return token; @@ -116,10 +137,14 @@ export const authOptions: AuthOptions = { }, session: async ({ session, token }: { session: Session; token: JWT }) => { if (token) { - // If refresh token failed, end the session by returning null - if (token.error === 'RefreshAccessTokenError') { - console.error('Refresh token expired or invalid - ending session'); - return { ...session, error: 'ForceLogout' }; + // If refresh token failed or token was invalidated, end the session + if (token.error === 'RefreshAccessTokenError' || token.error === 'TokenInvalidated') { + // Return a minimal session with only the error flag and no user data + console.log('Ending session due to token error:', token.error); + return { + expires: session.expires, + error: 'ForceLogout', + } as Session; } // @ts-expect-error shut up typescript diff --git a/apps/dashboard/src/util/invalidatedSessions.ts b/apps/dashboard/src/util/invalidatedSessions.ts new file mode 100644 index 00000000..6a3ea42c --- /dev/null +++ b/apps/dashboard/src/util/invalidatedSessions.ts @@ -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 | 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); + } +} diff --git a/apps/dashboard/typings/next-auth/next-auth.d.ts b/apps/dashboard/typings/next-auth/next-auth.d.ts index ac869233..29f30faf 100644 --- a/apps/dashboard/typings/next-auth/next-auth.d.ts +++ b/apps/dashboard/typings/next-auth/next-auth.d.ts @@ -106,6 +106,7 @@ declare module 'next-auth/jwt' { refreshToken: string; accessTokenExpired: number; refreshTokenExpired: number | undefined; + sessionId?: string; user: User; error: string; } From 23971bef829e0f373fda9a3494456850e6f87c97 Mon Sep 17 00:00:00 2001 From: Nudesuppe42 <67996941+Nudelsuppe42@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:49:28 +0100 Subject: [PATCH 2/2] fix(dash/auth): Remove console log Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/dashboard/src/middleware.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/dashboard/src/middleware.ts b/apps/dashboard/src/middleware.ts index 4cc8b417..fe4b21a6 100644 --- a/apps/dashboard/src/middleware.ts +++ b/apps/dashboard/src/middleware.ts @@ -3,14 +3,12 @@ import { withAuth } from 'next-auth/middleware'; export default withAuth({ callbacks: { authorized: ({ token }) => { - console.log('Middleware authorized check, token:', token); // Block access if token has error or doesn't exist if ( token?.error === 'RefreshAccessTokenError' || token?.error === 'TokenInvalidated' || token?.error === 'ForceLogout' ) { - console.log('Access denied due to token error:', token?.error); return false; } return !!token;