Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 });
}
Comment on lines +11 to +18
Copy link

Copilot AI Jan 27, 2026

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.

Copilot uses AI. Check for mistakes.

// 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
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical security vulnerability: The JWT logout token is decoded without signature verification. This means the endpoint will accept logout tokens from any source, not just Keycloak. An attacker could craft a malicious JWT with any session ID or user ID to invalidate arbitrary sessions.

The JWT should be verified using Keycloak's public key before processing. Consider using a library like 'jose' or 'jsonwebtoken' to verify the token signature against Keycloak's JWKS endpoint.

Copilot uses AI. Check for mistakes.

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');

Comment on lines +42 to +43
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug console.log statement should be removed before merging to production. This log provides minimal value and will add noise to production logs.

Consider removing this log statement.

Suggested change
console.log('Back-channel logout received');

Copilot uses AI. Check for mistakes.
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 });
}
}
5 changes: 4 additions & 1 deletion apps/dashboard/src/app/(sideNavbar)/auth/signin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { Button } from '@mantine/core';
import { signIn, useSession } from 'next-auth/react';

import { useRouter } from 'next/navigation';
Expand All @@ -19,5 +20,7 @@ export default function SigninPage() {
}
}, [status, router]);

return <p>Redirecting...</p>;
return (
<Button onClick={() => void signIn('keycloak', { callbackUrl: '/', redirect: true })}>Sign In with Keycloak</Button>
); // TODO: Add button to trigger sign-in manually
}
12 changes: 6 additions & 6 deletions apps/dashboard/src/components/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug console.log statement should be removed before merging to production. This log will add noise to production logs.

Consider removing or converting this to debug-level logs that can be conditionally enabled based on environment.

Suggested change
console.log('AuthProvider: Forcing logout due to invalid session');

Copilot uses AI. Check for mistakes.
signOut({ callbackUrl: '/auth/signin', redirect: true });
}
}, [session]);
if (session?.error === 'ForceLogout') {

if (session?.error === 'ForceLogout' || (session && !session.user)) {
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Menu>
Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/components/layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ export default async function AppLayout({ children, hideNavbar, customNavbar, ..
p="md"
{...props}
>
{!hideNavbar && <Navbar roles={session?.user.realm_access.roles || []} />}
{!hideNavbar && <Navbar roles={session?.user?.realm_access.roles || []} />}

{customNavbar}

<Header roles={session?.user.realm_access.roles || []} />
<Header roles={session?.user?.realm_access.roles || []} />

<AppShellMain style={{ position: 'relative', paddingBottom: 'calc(var(--mantine-spacing-xl) * 1.5)' }}>
{children}
Expand Down
25 changes: 23 additions & 2 deletions apps/dashboard/src/middleware.ts
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).*)'],
};
35 changes: 30 additions & 5 deletions apps/dashboard/src/util/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing console.error to console.log reduces error visibility in production logs and monitoring systems. Error logging is important for debugging token refresh failures, especially in production environments where these errors indicate authentication issues.

Consider keeping console.error for actual error conditions to maintain proper log levels and facilitate debugging.

Suggested change
console.log('Failed to refresh access token', error);
console.error('Failed to refresh access token', error);

Copilot uses AI. Check for mistakes.
return {
...token,
error: 'RefreshAccessTokenError',
Expand All @@ -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',
Copy link

Copilot AI Jan 27, 2026

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.

Suggested change
scope: 'openid email profile',
// Include offline_access to better support session management and back-channel logout.
scope: 'openid email profile offline_access',

Copilot uses AI. Check for mistakes.
},
},
profile: (profile) => {
return {
...profile,
Expand Down Expand Up @@ -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' };
Comment on lines +125 to +127
Copy link

Copilot AI Jan 27, 2026

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 uses AI. Check for mistakes.
}

// Return previous token if the access token has not expired yet
if (Date.now() < token.accessTokenExpired || token.accessTokenExpired == null) return token;

Expand All @@ -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);
Copy link

Copilot AI Jan 27, 2026

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 uses AI. Check for mistakes.
return {
expires: session.expires,
error: 'ForceLogout',
} as Session;
Comment on lines +144 to +147
Copy link

Copilot AI Jan 27, 2026

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.

Copilot uses AI. Check for mistakes.
}

// @ts-expect-error shut up typescript
Expand Down
35 changes: 35 additions & 0 deletions apps/dashboard/src/util/invalidatedSessions.ts
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
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential memory leak: Sessions invalidated by userId (when sessionId is not present) are never cleaned up. The markSessionAsChecked function is only called when a token is validated, but if a user has multiple sessions or the session is never accessed after invalidation, the userId entry will remain in the Set indefinitely.

Consider implementing a time-based expiration strategy or limiting the size of the invalidated sessions Set to prevent unbounded memory growth.

Copilot uses AI. Check for mistakes.
}
1 change: 1 addition & 0 deletions apps/dashboard/typings/next-auth/next-auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ declare module 'next-auth/jwt' {
refreshToken: string;
accessTokenExpired: number;
refreshTokenExpired: number | undefined;
sessionId?: string;
user: User;
error: string;
}
Expand Down