From e8403c8ff657be56116442fe3db574700c2acd5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 14 Jan 2026 10:45:13 +0100 Subject: [PATCH] feature/Make it work Single-Sign-Off of Keycloak as IdP --- README.md | 44 ++++++ src/lib/oauth/client.ts | 129 ++++++++++++++++++ src/lib/oauth/sessionHelper.ts | 22 ++- src/lib/oauth/types.ts | 7 + .../login/[provider]/callback/+server.ts | 17 ++- src/routes/login/obp/callback/+server.ts | 17 ++- src/routes/logout/+server.ts | 48 ++++++- 7 files changed, 266 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 709ba73..e7229c6 100644 --- a/README.md +++ b/README.md @@ -196,3 +196,47 @@ All log messages include the service/function name that generated the log for ea When no user identifier can be found in the JWT, the system will log all available JWT fields to help with debugging. The system prioritizes human-readable identifiers like email addresses and display names over system identifiers like UUIDs. Make sure that the `APP_CALLBACK_URL` is registered with the OAuth2/OIDC provider, so that it will properly redirect. Without this the Portal will not work. + +## Keycloak Front-Channel Logout Configuration + +When using Keycloak as your Identity Provider (IdP), the system automatically implements **Option 2: Front-Channel Logout (Browser-based)** for proper session management. + +### Configuration Requirements + +For Keycloak logout to work properly, ensure your OAuth client configuration includes: + +```javascript +{ + clientId: '[SET]', + clientSecret: '[SET]', + callbackUrl: 'http://localhost:5174/login/obp/callback', + configUrl: 'http://localhost:7787/realms/master/.well-known/openid-configuration' +} +``` + +**Note**: The system supports multiple providers simultaneously: +- **OBP-OIDC**: `http://localhost:9000/obp-oidc/.well-known/openid-configuration` +- **Keycloak**: `http://localhost:7787/realms/master/.well-known/openid-configuration` + +The Keycloak front-channel logout will only be used when the user is authenticated via the Keycloak provider. + +### How It Works + +1. **User clicks logout** in the application +2. **App redirects browser** to Keycloak's `end_session_endpoint` +3. **Keycloak ends the session** and logs the user out of all Keycloak-managed clients +4. **Keycloak redirects back** to the application's origin URL + +### Logout Flow Parameters + +The system automatically sends these parameters to Keycloak's logout endpoint: + +- `id_token_hint`: The ID token from the user's session (required for proper logout) +- `post_logout_redirect_uri`: The application origin URL (where to redirect after logout) + +### Provider-Specific Behavior + +- **Keycloak**: Uses front-channel logout via `end_session_endpoint` with ID token hint +- **Other providers**: Falls back to standard token revocation via `revocation_endpoint` + +The system automatically detects the provider type and uses the appropriate logout method. No additional configuration is needed beyond ensuring your Keycloak realm has the proper `end_session_endpoint` configured in its OIDC discovery document. diff --git a/src/lib/oauth/client.ts b/src/lib/oauth/client.ts index ab8e192..90a4817 100644 --- a/src/lib/oauth/client.ts +++ b/src/lib/oauth/client.ts @@ -133,6 +133,12 @@ export class OAuth2ClientWithConfig extends OAuth2Client { return { accessToken: () => tokens.access_token, refreshToken: () => tokens.refresh_token, + idToken: () => { + if ("id_token" in tokens && typeof tokens.id_token === "string") { + return tokens.id_token; + } + throw new Error("Missing or invalid field 'id_token'"); + }, accessTokenExpiresAt: () => tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null }; @@ -219,6 +225,12 @@ export class OAuth2ClientWithConfig extends OAuth2Client { return { accessToken: () => retryTokens.access_token, refreshToken: () => retryTokens.refresh_token, + idToken: () => { + if ("id_token" in retryTokens && typeof retryTokens.id_token === "string") { + return retryTokens.id_token; + } + throw new Error("Missing or invalid field 'id_token'"); + }, accessTokenExpiresAt: () => retryTokens.expires_in ? new Date(Date.now() + retryTokens.expires_in * 1000) : null }; @@ -233,6 +245,123 @@ export class OAuth2ClientWithConfig extends OAuth2Client { return { accessToken: () => tokens.access_token, refreshToken: () => tokens.refresh_token, + idToken: () => { + if ("id_token" in tokens && typeof tokens.id_token === "string") { + return tokens.id_token; + } + throw new Error("Missing or invalid field 'id_token'"); + }, + accessTokenExpiresAt: () => + tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null + }; + } + + async refreshAccessToken( + tokenEndpoint: string, + refreshToken: string, + scopes: string[] + ): Promise { + logger.debug('Refreshing access token...'); + + const body = new URLSearchParams(); + body.set('grant_type', 'refresh_token'); + body.set('refresh_token', refreshToken); + + if (scopes && scopes.length > 0) { + body.set('scope', scopes.join(' ')); + } + + // Prepare headers + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + }; + + // Use HTTP Basic Authentication for client credentials + if (this.storedClientSecret) { + const credentials = Buffer.from(`${this.storedClientId}:${this.storedClientSecret}`).toString( + 'base64' + ); + headers['Authorization'] = `Basic ${credentials}`; + logger.debug('Using Basic Authentication for refresh token request'); + } else { + // Public client - include client_id in body + body.set('client_id', this.storedClientId); + logger.debug('Using client_id in request body for refresh token request'); + } + + logger.debug(`Refresh token request body: ${body.toString()}`); + + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers, + body: body.toString() + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + logger.error(`Token refresh error - Status: ${response.status}, Data:`, errorData); + + // If Basic Auth failed and we have a client secret, try with credentials in body as fallback + if (response.status === 401 && this.storedClientSecret && !body.has('client_id')) { + logger.warn('Basic Auth failed for refresh, retrying with credentials in request body'); + + // Add client credentials to body for retry + body.set('client_id', this.storedClientId); + body.set('client_secret', this.storedClientSecret); + + // Remove Authorization header + delete headers['Authorization']; + + const retryResponse = await fetch(tokenEndpoint, { + method: 'POST', + headers, + body: body.toString() + }); + + if (!retryResponse.ok) { + const retryErrorData = await retryResponse.json().catch(() => ({})); + logger.error( + `Token refresh retry error - Status: ${retryResponse.status}, Data:`, + retryErrorData + ); + throw new Error( + `Token refresh failed after retry: ${retryResponse.status} ${retryResponse.statusText}` + ); + } + + const retryTokens = await retryResponse.json(); + logger.debug('Token refresh response received successfully after retry'); + + return { + accessToken: () => retryTokens.access_token, + refreshToken: () => retryTokens.refresh_token, + idToken: () => { + if ("id_token" in retryTokens && typeof retryTokens.id_token === "string") { + return retryTokens.id_token; + } + throw new Error("Missing or invalid field 'id_token'"); + }, + accessTokenExpiresAt: () => + retryTokens.expires_in ? new Date(Date.now() + retryTokens.expires_in * 1000) : null + }; + } + + throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`); + } + + const tokens = await response.json(); + logger.debug('Token refresh response received successfully'); + + return { + accessToken: () => tokens.access_token, + refreshToken: () => tokens.refresh_token, + idToken: () => { + if ("id_token" in tokens && typeof tokens.id_token === "string") { + return tokens.id_token; + } + throw new Error("Missing or invalid field 'id_token'"); + }, accessTokenExpiresAt: () => tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null }; diff --git a/src/lib/oauth/sessionHelper.ts b/src/lib/oauth/sessionHelper.ts index 33a6cea..0e53b38 100644 --- a/src/lib/oauth/sessionHelper.ts +++ b/src/lib/oauth/sessionHelper.ts @@ -3,12 +3,16 @@ const logger = createLogger('OAuthSessionHelper'); import { oauth2ProviderFactory } from './providerFactory'; import type { OAuth2ClientWithConfig } from './client'; import type { Session } from 'svelte-kit-sessions'; +import type { SessionOAuthStorageData } from './types'; + + export interface SessionOAuthData { client: OAuth2ClientWithConfig; provider: string; accessToken: string; refreshToken?: string; + idToken?: string; } export class SessionOAuthHelper { @@ -18,7 +22,7 @@ export class SessionOAuthHelper { * @returns SessionOAuthData if valid, null if invalid/missing */ static getSessionOAuth(session: Session): SessionOAuthData | null { - const oauthData = session.data.oauth; + const oauthData = session.data.oauth as SessionOAuthStorageData; if (!oauthData?.provider || !oauthData?.access_token) { return null; } @@ -32,16 +36,18 @@ export class SessionOAuthHelper { client, provider: oauthData.provider, accessToken: oauthData.access_token, - refreshToken: oauthData.refresh_token + refreshToken: oauthData.refresh_token, + idToken: oauthData.id_token }; } static async updateTokensInSession( session: Session, accessToken: string, - refreshToken?: string + refreshToken?: string, + idToken?: string ): Promise { - const currentOauth = session.data.oauth; + const currentOauth = session.data.oauth as SessionOAuthStorageData; if (!currentOauth) { throw new Error('No OAuth data in session to update.'); } @@ -51,8 +57,9 @@ export class SessionOAuthHelper { oauth: { ...currentOauth, access_token: accessToken, - refresh_token: refreshToken || currentOauth.refresh_token // Keep existing refresh token if not provided - } + refresh_token: refreshToken || currentOauth.refresh_token, // Keep existing refresh token if not provided + id_token: idToken || currentOauth.id_token // Keep existing ID token if not provided + } as SessionOAuthStorageData }); await session.save(); @@ -82,7 +89,8 @@ export class SessionOAuthHelper { await this.updateTokensInSession( session, tokens.accessToken(), - tokens.refreshToken() || refreshToken + tokens.refreshToken() || refreshToken, + tokens.idToken() ); logger.info(`Access token refreshed successfully for provider: ${provider}`); diff --git a/src/lib/oauth/types.ts b/src/lib/oauth/types.ts index 6263bcb..61b6d60 100644 --- a/src/lib/oauth/types.ts +++ b/src/lib/oauth/types.ts @@ -53,4 +53,11 @@ export interface OAuth2AccessTokenPayload { scope?: string; client_id?: string; [key: string]: any; // Allow additional properties +} + +export interface SessionOAuthStorageData { + access_token: string; + refresh_token?: string; + id_token?: string; + provider: string; } \ No newline at end of file diff --git a/src/routes/login/[provider]/callback/+server.ts b/src/routes/login/[provider]/callback/+server.ts index 3a917c1..1652f73 100644 --- a/src/routes/login/[provider]/callback/+server.ts +++ b/src/routes/login/[provider]/callback/+server.ts @@ -5,6 +5,7 @@ import type { OAuth2Tokens } from 'arctic'; import type { RequestEvent } from '@sveltejs/kit'; import { error } from '@sveltejs/kit'; import { env } from '$env/dynamic/public'; +import type { SessionOAuthStorageData } from '$lib/oauth/types'; export async function GET(event: RequestEvent): Promise { const { provider: urlProvider } = event.params; @@ -172,6 +173,13 @@ export async function GET(event: RequestEvent): Promise { }); const obpAccessToken = tokens.accessToken(); + let idToken; + try { + idToken = tokens.idToken(); + } catch (error) { + // ID token might not be available for all providers/flows + idToken = undefined; + } logger.debug(`PUBLIC_OBP_BASE_URL from env: ${env.PUBLIC_OBP_BASE_URL}`); const currentUserUrl = `${env.PUBLIC_OBP_BASE_URL}/obp/v5.1.0/users/current`; @@ -206,7 +214,7 @@ export async function GET(event: RequestEvent): Promise { ); logger.debug('Full current user data:', user); - if (user.user_id && user.email) { + if (user.user_id && (user.email || user.username)) { // Store user data in session const { session } = event.locals; await session.setData({ @@ -214,8 +222,9 @@ export async function GET(event: RequestEvent): Promise { oauth: { access_token: obpAccessToken, refresh_token: tokens.refreshToken(), + ...(idToken && { id_token: idToken }), provider: provider - } + } as SessionOAuthStorageData }); await session.save(); logger.debug('Session data set:', session.data); @@ -226,7 +235,7 @@ export async function GET(event: RequestEvent): Promise { } }); } else { - logger.error('Invalid user data received from OBP - missing user_id or email:', user); + logger.error('Invalid user data received from OBP - missing user_id or email/username:', user); // Clean up the state cookie event.cookies.delete('obp_oauth_state', { @@ -236,7 +245,7 @@ export async function GET(event: RequestEvent): Promise { return new Response(null, { status: 302, headers: { - Location: `/login?error=${encodeURIComponent('Invalid user data received. Please contact your administrator.')}` + Location: `/login?error=${encodeURIComponent('Invalid user data received - missing required user identification. Please contact your administrator.')}` } }); } diff --git a/src/routes/login/obp/callback/+server.ts b/src/routes/login/obp/callback/+server.ts index 70cbaca..93d5eba 100644 --- a/src/routes/login/obp/callback/+server.ts +++ b/src/routes/login/obp/callback/+server.ts @@ -5,6 +5,7 @@ import type { OAuth2Tokens } from 'arctic'; import type { RequestEvent } from '@sveltejs/kit'; import { error } from '@sveltejs/kit'; import { env } from '$env/dynamic/public'; +import type { SessionOAuthStorageData } from '$lib/oauth/types'; export async function GET(event: RequestEvent): Promise { // Check for OAuth error responses first (e.g., invalid credentials) @@ -158,6 +159,13 @@ export async function GET(event: RequestEvent): Promise { }); const obpAccessToken = tokens.accessToken(); + let idToken; + try { + idToken = tokens.idToken(); + } catch (error) { + // ID token might not be available for all providers/flows + idToken = undefined; + } logger.debug(`PUBLIC_OBP_BASE_URL from env: ${env.PUBLIC_OBP_BASE_URL}`); const currentUserUrl = `${env.PUBLIC_OBP_BASE_URL}/obp/v5.1.0/users/current`; @@ -192,7 +200,7 @@ export async function GET(event: RequestEvent): Promise { ); logger.debug('Full current user data:', user); - if (user.user_id && user.email) { + if (user.user_id && (user.email || user.username)) { // Store user data in session const { session } = event.locals; await session.setData({ @@ -200,8 +208,9 @@ export async function GET(event: RequestEvent): Promise { oauth: { access_token: obpAccessToken, refresh_token: tokens.refreshToken(), + ...(idToken && { id_token: idToken }), provider: provider - } + } as SessionOAuthStorageData }); await session.save(); logger.debug('Session data set:', session.data); @@ -212,7 +221,7 @@ export async function GET(event: RequestEvent): Promise { } }); } else { - logger.error('Invalid user data received from OBP - missing user_id or email:', user); + logger.error('Invalid user data received from OBP - missing user_id or email/username:', user); // Clean up the state cookie event.cookies.delete('obp_oauth_state', { @@ -222,7 +231,7 @@ export async function GET(event: RequestEvent): Promise { return new Response(null, { status: 302, headers: { - Location: `/login?error=${encodeURIComponent('Invalid user data received. Please contact your administrator.')}` + Location: `/login?error=${encodeURIComponent('Invalid user data received - missing required user identification. Please contact your administrator.')}` } }); } diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts index 40b5d44..d766d89 100644 --- a/src/routes/logout/+server.ts +++ b/src/routes/logout/+server.ts @@ -2,8 +2,25 @@ import { createLogger } from '$lib/utils/logger'; const logger = createLogger('LogoutServer'); import { SessionOAuthHelper } from "$lib/oauth/sessionHelper"; import type { RequestEvent } from "@sveltejs/kit"; +import type { SessionOAuthStorageData } from "$lib/oauth/types"; // Response is a global type, no need to import it +/** + * Logout handler that supports both token revocation and Keycloak front-channel logout. + * + * For Keycloak as IdP, this implements Option 2: Front-Channel Logout (Browser-based) + * Flow: + * 1. User clicks logout in the app + * 2. App redirects browser to Keycloak's end_session_endpoint + * 3. Keycloak ends the session and logs user out of all Keycloak-managed clients + * 4. Keycloak redirects back to post_logout_redirect_uri + * + * Parameters sent to Keycloak logout endpoint: + * - id_token_hint: The ID token from the user's session (required for proper logout) + * - post_logout_redirect_uri: Where to redirect after logout (app origin) + * + * For non-Keycloak providers, falls back to standard token revocation. + */ export async function GET(event: RequestEvent): Promise { const session = event.locals.session; @@ -28,8 +45,11 @@ export async function GET(event: RequestEvent): Promise { }); } - // Get the access token before destroying session - const accessToken = session.data.oauth?.access_token; + // Get tokens and user info before destroying session + const oauthData = session.data.oauth as SessionOAuthStorageData; + const accessToken = oauthData?.access_token; + const idToken = oauthData?.id_token; + const provider = oauthData?.provider; const userId = session.data.user.user_id; // Clear the session cookie and destroy the session @@ -38,7 +58,29 @@ export async function GET(event: RequestEvent): Promise { }); await session.destroy(); - // Try to revoke the access token if it exists and revocation endpoint is available + // Handle Keycloak front-channel logout + if (provider === 'keycloak' && idToken) { + const endSessionEndpoint = sessionOAuth.client.OIDCConfig?.end_session_endpoint; + if (endSessionEndpoint) { + logger.info("Performing Keycloak front-channel logout for user:", userId); + + const logoutUrl = new URL(endSessionEndpoint); + logoutUrl.searchParams.append('id_token_hint', idToken); + logoutUrl.searchParams.append('post_logout_redirect_uri', event.url.origin); + + // Redirect to Keycloak logout endpoint for front-channel logout + return new Response(null, { + status: 302, + headers: { + Location: logoutUrl.toString() + } + }); + } else { + logger.warn("Keycloak end_session_endpoint not found, falling back to token revocation"); + } + } + + // Fallback: Try to revoke the access token if it exists and revocation endpoint is available const tokenRevokationUrl = sessionOAuth.client.OIDCConfig?.revocation_endpoint; if (accessToken && tokenRevokationUrl) { try {