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..0d0c09d5 --- /dev/null +++ b/apps/dashboard/src/app/(sideNavbar)/api/auth/backchannel-logout/route.ts @@ -0,0 +1,258 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createPublicKey, createVerify } from 'node:crypto'; +import '../../../../util/invalidatedSessions'; + +type JsonObject = Record; + +type JwtHeader = { + alg?: string; + kid?: string; + typ?: string; +}; + +type Jwk = { + kid?: string; + kty?: string; + n?: string; + e?: string; + alg?: string; + use?: string; +}; + +type JwksResponse = { + keys?: Jwk[]; +}; + +type LogoutTokenPayload = JsonObject & { + sid?: string; + sub?: string; + nonce?: string; + iss?: string; + aud?: string | string[]; + azp?: string; + iat?: number; + exp?: number; + nbf?: number; + jti?: string; + events?: { + 'http://schemas.openid.net/event/backchannel-logout'?: Record; + }; +}; + +const JWKS_CACHE_TTL_MS = 10 * 60 * 1000; + +let cachedJwks: JwksResponse | null = null; +let cachedJwksIssuer: string | null = null; +let cachedJwksAt = 0; + +const base64UrlToBuffer = (value: string) => { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + + return Buffer.from(padded, 'base64'); +}; + +const parseJwtPart = (value: string): T => { + const decoded = base64UrlToBuffer(value).toString('utf-8'); + + return JSON.parse(decoded) as T; +}; + +const parseJwt = (jwt: string) => { + const parts = jwt.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid logout token format'); + } + + const [encodedHeader, encodedPayload, encodedSignature] = parts; + const header = parseJwtPart(encodedHeader); + const payload = parseJwtPart(encodedPayload); + + return { + header, + payload, + signingInput: `${encodedHeader}.${encodedPayload}`, + signature: base64UrlToBuffer(encodedSignature), + }; +}; + +const getKeycloakJwks = async (issuer: string): Promise => { + const now = Date.now(); + const isFresh = cachedJwks && cachedJwksIssuer === issuer && now - cachedJwksAt < JWKS_CACHE_TTL_MS; + if (isFresh) { + return cachedJwks!; + } + + const response = await fetch(`${issuer}/protocol/openid-connect/certs`, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + cache: 'no-store', + }); + + if (!response.ok) { + throw new Error('Failed to fetch Keycloak JWKS'); + } + + const jwks = (await response.json()) as JwksResponse; + if (!Array.isArray(jwks.keys) || jwks.keys.length === 0) { + throw new Error('Invalid JWKS response from Keycloak'); + } + + cachedJwks = jwks; + cachedJwksIssuer = issuer; + cachedJwksAt = now; + + return jwks; +}; + +const hasExpectedAudience = (aud: string | string[] | undefined, clientId: string) => { + if (!aud) return false; + if (typeof aud === 'string') return aud === clientId; + + return aud.includes(clientId); +}; + +const MAX_LOGOUT_TOKEN_AGE_SECONDS = 300; + +const isTokenTimeValid = (payload: LogoutTokenPayload) => { + const now = Math.floor(Date.now() / 1000); + + // iat is REQUIRED per OIDC Back-Channel Logout spec + if (typeof payload.iat !== 'number') { + return false; + } + + // Reject tokens with a future iat or issued too far in the past + if (payload.iat > now || now - payload.iat > MAX_LOGOUT_TOKEN_AGE_SECONDS) { + return false; + } + + if (typeof payload.exp === 'number' && now >= payload.exp) { + return false; + } + + if (typeof payload.nbf === 'number' && now < payload.nbf) { + return false; + } + + return true; +}; + +const verifySignature = async (logoutToken: string, issuer: string) => { + const { header, payload, signingInput, signature } = parseJwt(logoutToken); + + if (header.alg !== 'RS256') { + throw new Error('Invalid logout token: unsupported algorithm'); + } + + if (!header.kid) { + throw new Error('Invalid logout token: missing key id'); + } + + const jwks = await getKeycloakJwks(issuer); + let jwk = jwks.keys?.find((key) => key.kid === header.kid); + + if (!jwk) { + cachedJwksAt = 0; + const refreshedJwks = await getKeycloakJwks(issuer); + jwk = refreshedJwks.keys?.find((key) => key.kid === header.kid); + } + + if (!jwk) { + throw new Error('Invalid logout token: key not found'); + } + + const publicKey = createPublicKey({ + key: jwk, + format: 'jwk', + }); + + const verifier = createVerify('RSA-SHA256'); + verifier.update(signingInput); + verifier.end(); + + if (!verifier.verify(publicKey, signature)) { + throw new Error('Invalid logout token signature'); + } + + return payload; +}; + +const verifyLogoutToken = async (logoutToken: string) => { + const issuer = process.env.NEXT_PUBLIC_KEYCLOAK_URL; + const clientId = process.env.NEXT_PUBLIC_KEYCLOAK_ID; + + if (!issuer || !clientId) { + throw new Error('Missing Keycloak issuer/client configuration'); + } + + const logoutPayload = await verifySignature(logoutToken, issuer); + const hasBackchannelEvent = + logoutPayload.events?.['http://schemas.openid.net/event/backchannel-logout'] !== undefined; + + if (logoutPayload.iss !== issuer) { + throw new Error('Invalid logout token: issuer mismatch'); + } + + if (!hasBackchannelEvent) { + throw new Error('Invalid logout token: missing back-channel logout event'); + } + + if (logoutPayload.nonce !== undefined) { + throw new Error('Invalid logout token: nonce must not be present'); + } + + if (!hasExpectedAudience(logoutPayload.aud as string | string[] | undefined, clientId)) { + throw new Error('Invalid logout token: audience mismatch'); + } + + if (!isTokenTimeValid(logoutPayload)) { + throw new Error('Invalid logout token: token timing invalid'); + } + + if (!logoutPayload.sid && !logoutPayload.sub) { + throw new Error('Invalid logout token: missing sid/sub'); + } + + if (!logoutPayload.jti) { + throw new Error('Invalid logout token: missing jti'); + } + + return logoutPayload; +}; + +/** + * 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 }); + } + + const decodedPayload = await verifyLogoutToken(logoutToken); + const sid = decodedPayload.sid; + const sub = decodedPayload.sub; + + // 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); + } + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + console.error('Back-channel logout verification failed:', error); + return NextResponse.json({ error: 'Invalid logout request' }, { status: 400 }); + } +} diff --git a/apps/dashboard/src/app/(sideNavbar)/api/auth/federated-logout/route.ts b/apps/dashboard/src/app/(sideNavbar)/api/auth/federated-logout/route.ts new file mode 100644 index 00000000..1ebad4d8 --- /dev/null +++ b/apps/dashboard/src/app/(sideNavbar)/api/auth/federated-logout/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getToken } from 'next-auth/jwt'; + +export async function POST(request: NextRequest) { + try { + const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET }); + const refreshToken = token?.refreshToken; + + if (!refreshToken) { + return NextResponse.json({ ok: true, message: 'No refresh token available' }, { status: 200 }); + } + + const formBody: string[] = []; + Object.entries({ + client_id: process.env.NEXT_PUBLIC_KEYCLOAK_ID, + client_secret: process.env.KEYCLOAK_SECRET, + refresh_token: refreshToken, + }).forEach(([key, value]) => { + formBody.push(`${encodeURIComponent(key)}=${encodeURIComponent(value ?? '')}`); + }); + + await fetch(`${process.env.NEXT_PUBLIC_KEYCLOAK_URL}/protocol/openid-connect/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body: formBody.join('&'), + }); + + return NextResponse.json({ ok: true }, { status: 200 }); + } catch (error) { + console.error('Federated logout failed:', error); + return NextResponse.json({ ok: false }, { 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..1c47930c 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'; @@ -11,13 +12,13 @@ export default function SigninPage() { useEffect(() => { if (status === 'unauthenticated') { - console.log('No JWT'); - console.log(status); void signIn('keycloak', { callbackUrl: '/', redirect: true }); } else if (status === 'authenticated') { void router.push('/'); } }, [status, router]); - return

Redirecting...

; + return ( + + ); } diff --git a/apps/dashboard/src/components/AuthProvider.tsx b/apps/dashboard/src/components/AuthProvider.tsx index efe86f23..e0861ad9 100644 --- a/apps/dashboard/src/components/AuthProvider.tsx +++ b/apps/dashboard/src/components/AuthProvider.tsx @@ -2,18 +2,17 @@ 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)) { + void 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..c1500baa 100644 --- a/apps/dashboard/src/components/layout/header/HeaderProfile.tsx +++ b/apps/dashboard/src/components/layout/header/HeaderProfile.tsx @@ -21,6 +21,7 @@ import { } from '@tabler/icons-react'; import { signOut, useSession } from 'next-auth/react'; import { redirect, usePathname } from 'next/navigation'; +import { useState } from 'react'; import Link from 'next/link'; @@ -28,6 +29,7 @@ const HeaderProfile = () => { const { colorScheme, toggleColorScheme } = useMantineColorScheme(); const pathname = usePathname(); const session = useSession(); + const [isLoggingOut, setIsLoggingOut] = useState(false); if (session.status === 'loading') return null; if (session.status === 'unauthenticated') { @@ -36,7 +38,19 @@ const HeaderProfile = () => { } return null; } - if (!session.data) return null; + if (!session.data || !session.data.user || !session.data.user.username) return null; + + const handleSignOut = async () => { + setIsLoggingOut(true); + + try { + await fetch('/api/auth/federated-logout', { method: 'POST' }); + } catch { + // Always continue with local sign-out even if federated logout fails. + } + + await signOut({ redirect: true, callbackUrl: '/auth/signin' }); + }; return ( @@ -90,13 +104,7 @@ const HeaderProfile = () => { > {colorScheme === 'dark' ? 'Light Mode' : 'Dark Mode'} - } - color="red" - onClick={() => { - signOut({ redirect: true, callbackUrl: '/' }); - }} - > + } color="red" disabled={isLoggingOut} onClick={handleSignOut}> Sign out 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..fe4b21a6 100644 --- a/apps/dashboard/src/middleware.ts +++ b/apps/dashboard/src/middleware.ts @@ -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).*)'], +}; diff --git a/apps/dashboard/src/util/auth.ts b/apps/dashboard/src/util/auth.ts index a51606e8..ecb65075 100644 --- a/apps/dashboard/src/util/auth.ts +++ b/apps/dashboard/src/util/auth.ts @@ -2,6 +2,39 @@ 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'; + +const TOKEN_EXPIRY_SKEW_MS = 15_000; +const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 300; + +const decodeJwtPayload = (jwt: string): Record | null => { + try { + const [, payload] = jwt.split('.'); + if (!payload) return null; + + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + const decoded = Buffer.from(padded, 'base64').toString('utf-8'); + + return JSON.parse(decoded) as Record; + } catch { + return null; + } +}; + +const getAccessTokenExpiry = ( + accessToken: string | undefined, + fallbackTtlSeconds = DEFAULT_ACCESS_TOKEN_TTL_SECONDS, +) => { + const payload = accessToken ? decodeJwtPayload(accessToken) : null; + const exp = payload?.exp; + + if (typeof exp === 'number' && exp > 0) { + return exp * 1000 - TOKEN_EXPIRY_SKEW_MS; + } + + return Date.now() + fallbackTtlSeconds * 1000 - TOKEN_EXPIRY_SKEW_MS; +}; /** * Refreshes access token to continue the session after token expiration @@ -40,8 +73,12 @@ const refreshAccessToken = async (token: JWT) => { const refreshedTokens = await response.json(); if (!response.ok) throw refreshedTokens; - const refreshedAccessExpiresIn = refreshedTokens.expires_in ?? 0; + const refreshedAccessExpiresIn = + typeof refreshedTokens.expires_in === 'number' && refreshedTokens.expires_in > 0 + ? refreshedTokens.expires_in + : DEFAULT_ACCESS_TOKEN_TTL_SECONDS; const refreshedRefreshExpiresIn = refreshedTokens.refresh_expires_in ?? 0; + const nextAccessToken = refreshedTokens.access_token ?? token.accessToken; const nextRefreshExpiry = refreshedRefreshExpiresIn ? Date.now() + (refreshedRefreshExpiresIn - 15) * 1000 @@ -49,8 +86,8 @@ const refreshAccessToken = async (token: JWT) => { return { ...token, - accessToken: refreshedTokens.access_token, - accessTokenExpired: Date.now() + (refreshedAccessExpiresIn - 15) * 1000, + accessToken: nextAccessToken, + accessTokenExpired: getAccessTokenExpiry(nextAccessToken, refreshedAccessExpiresIn), refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, refreshTokenExpired: nextRefreshExpiry, }; @@ -73,6 +110,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, @@ -96,30 +138,49 @@ export const authOptions: AuthOptions = { // Initial sign in if (account && user) { // Add access_token, refresh_token and expirations to the token right after signin - const accessExpiresIn = account.expires_in ?? 0; + const accessExpiresIn = + typeof account.expires_in === 'number' && account.expires_in > 0 + ? account.expires_in + : DEFAULT_ACCESS_TOKEN_TTL_SECONDS; const refreshExpiresIn = account.refresh_expires_in ?? 0; token.accessToken = account.access_token; token.refreshToken = account.refresh_token; - token.accessTokenExpired = Date.now() + (accessExpiresIn - 15) * 1000; + token.accessTokenExpired = getAccessTokenExpiry(account.access_token, accessExpiresIn); 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; - console.log('Access token has expired, trying to refresh it'); - // Access token has expired, try to update it return refreshAccessToken(token); }, 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 + return { + expires: session.expires, + error: 'ForceLogout', + } as Session; } // @ts-expect-error shut up typescript @@ -136,7 +197,16 @@ export const authOptions: AuthOptions = { * Helper function to get the session on the server without having to import the authOptions object every single time * @returns The session object or null */ -export const getSession = () => getServerSession(authOptions); +export const getSession = async () => { + const session = await getServerSession(authOptions); + + // Treat errored/incomplete sessions as unauthenticated everywhere on the server. + if (!session || session.error === 'ForceLogout' || !session.user) { + return null; + } + + return session; +}; /** * Helper function to check if a user has a specific role 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; }