From ccff762fc0c8b02c655af9550f052c913a7449f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 14 Apr 2026 07:01:27 +0200 Subject: [PATCH 1/3] feature: set OBPv7.0.0 as the default resource docs version Previously defaulted to OBPv6.0.0. Updated VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION in .env.example and the in-code fallback in src/obp/index.ts. Infrastructure API calls (entitlements, api-collections, consents, resource-docs fetch) are pinned in shared-constants.ts and are unaffected. --- .env.example | 2 +- src/obp/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 3efc8b9..e16698d 100644 --- a/.env.example +++ b/.env.example @@ -64,4 +64,4 @@ VITE_CHATBOT_ENABLED=false # VITE_CHATBOT_URL=http://localhost:5000 ### Resource Docs Version ### -VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv6.0.0 +VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv7.0.0 diff --git a/src/obp/index.ts b/src/obp/index.ts index 5cad0d8..0cd51c6 100644 --- a/src/obp/index.ts +++ b/src/obp/index.ts @@ -32,7 +32,7 @@ import { DEFAULT_OBP_API_VERSION } from '../shared-constants' export const OBP_API_VERSION = DEFAULT_OBP_API_VERSION // Default to showing v6.0.0 documentation in the UI (can be overridden by env var) export const OBP_API_DEFAULT_RESOURCE_DOC_VERSION = - import.meta.env.VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION ?? 'OBPv6.0.0' + import.meta.env.VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION ?? 'OBPv7.0.0' const default_collection_name = 'Favourites' export async function serverStatus(): Promise { From c23522dd38b4a04a0aa310523afce86357264beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 20 Apr 2026 13:04:06 +0200 Subject: [PATCH 2/3] feature: route resource-docs and api/versions through v7.0.0 http4s endpoints --- src/shared-constants.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/shared-constants.ts b/src/shared-constants.ts index 2c8d1ba..0586aa7 100644 --- a/src/shared-constants.ts +++ b/src/shared-constants.ts @@ -2,14 +2,15 @@ export const DEFAULT_OBP_API_VERSION = 'v5.1.0' // Hardcoded API versions for all application endpoints -// Using v5.1.0 as the standard stable version - more stable than v6.0.0 and bugs can be fixed +// Using v7.0.0 for resource-docs and api/versions — both endpoints are migrated to http4s. // These versions should NOT change based on user's documentation version selection in the UI /** * Resource documentation endpoint version * Endpoint: GET /obp/{version}/resource-docs/{docVersion}/obp + * v7.0.0 getResourceDocsObpV700 accepts any valid API version string and delegates to the correct doc set. */ -export const RESOURCE_DOCS_API_VERSION = 'v5.1.0' +export const RESOURCE_DOCS_API_VERSION = 'v7.0.0' /** * Message documentation endpoint version @@ -20,8 +21,9 @@ export const MESSAGE_DOCS_API_VERSION = 'v5.1.0' /** * API versions list endpoint version * Endpoint: GET /obp/{version}/api/versions + * v7.0.0 getScannedApiVersions is migrated to http4s. */ -export const API_VERSIONS_LIST_API_VERSION = 'v6.0.0' +export const API_VERSIONS_LIST_API_VERSION = 'v7.0.0' /** * Glossary endpoint version From c2c0ed10975acde5aff77faed93e9e069d73d02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 5 Jun 2026 10:53:22 +0200 Subject: [PATCH 3/3] feature: configurable logout mode (VITE_OBP_LOGOUT_MODE) Add VITE_OBP_LOGOUT_MODE to control GET /user/logoff behaviour: - public (default): clear the local session, then redirect to the OIDC provider's end_session_endpoint (RP-initiated SSO logout) so the Keycloak/OIDC session is also ended. Falls back to a local redirect when the provider, end_session_endpoint, or id_token is unavailable. - internal: local-only logout, leaving the provider SSO session intact for silent re-login. Unrecognised values warn and default to public. Adds getEndSessionEndpoint() to OAuth2ClientWithConfig and the supporting oauth2 type. Documented in README and .env.example. --- .env.example | 11 +++ README.md | 19 +++++ server/routes/user.ts | 95 ++++++++++++++++++++++- server/services/OAuth2ClientWithConfig.ts | 10 +++ server/types/oauth2.ts | 1 + 5 files changed, 135 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index e16698d..214e350 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,17 @@ VITE_OBP_SERVER_SESSION_PASSWORD=change-me-to-a-secure-random-string ### OAuth2 Redirect URL (shared by all providers) ### VITE_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback +### Logout behaviour ### +### Controls what happens when a user logs out: +### public (default) - Full SSO logout: also ends the Keycloak/OIDC session +### (via end_session_endpoint), so the next login requires +### credentials. Use for public-facing / shared machines. +### internal - Local-only logout: clears the app session but keeps the +### provider SSO session, so re-login is silent. Use for +### deployments within a single trusted organisation. +### Unset or unrecognised values fall back to "public". +VITE_OBP_LOGOUT_MODE=public + ### Redis Configuration (Optional - uses localhost:6379 if not set) ### # VITE_OBP_REDIS_URL=redis://127.0.0.1:6379 # VITE_OBP_REDIS_PASSWORD= diff --git a/README.md b/README.md index 00a04c7..2ea32be 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,25 @@ The callback URL (if running locally) should be http://localhost:5173/api/callba Copy and paste the Consumer Key and Consumer Secret and add it to your .env file here. You can use .env.example as a basis of your .env file. +##### Logout behaviour (`VITE_OBP_LOGOUT_MODE`) + +Controls what happens when a user logs out (`GET /api/user/logoff`): + + * **`public`** (default) — Full SSO logout. After clearing the local session, + the user is redirected to the provider's `end_session_endpoint`, ending the + Keycloak/OIDC session too, so the next login requires credentials. Safe + default for public-facing or shared machines. + * **`internal`** — Local-only logout. Clears the app session but leaves the + provider's SSO session intact, so re-login is silent. Intended for + deployments used within a single organisation on trusted machines. + +Unset or unrecognised values fall back to `public`. + +> **Keycloak setup for `public` mode:** add the app's home URL +> (`VITE_OBP_API_EXPLORER_HOST`, e.g. `http://localhost:5173`) to the client's +> **Valid post logout redirect URIs** in the Keycloak admin console, otherwise +> the provider rejects the post-logout redirect. + ### Testing Unit tests are located in `server/test` and `src/test`. diff --git a/server/routes/user.ts b/server/routes/user.ts index 5e2397f..9211dd5 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -29,15 +29,93 @@ import { Router } from 'express' import type { Request, Response } from 'express' import { Container } from 'typedi' import OBPClientService from '../services/OBPClientService.js' +import { OAuth2ProviderManager } from '../services/OAuth2ProviderManager.js' import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js' const router = Router() // Get services from container const obpClientService = Container.get(OBPClientService) +const providerManager = Container.get(OAuth2ProviderManager) const obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST +/** + * Logout behaviour is controlled by the VITE_OBP_LOGOUT_MODE environment variable: + * + * public (default) - Full SSO logout. After clearing the local session we + * redirect the browser to the provider's + * end_session_endpoint so the Keycloak/OIDC session is + * also ended. The user must re-enter credentials on the + * next login. Safe default for public-facing / shared + * machines. + * + * internal - Local-only logout. We clear the local app session but + * leave the provider's SSO session intact, so the next + * login is silent. Intended for deployments used within + * a single organisation on trusted machines. + * + * Any unset/unrecognised value falls back to "public". + */ +function getLogoutMode(): 'public' | 'internal' { + const mode = process.env.VITE_OBP_LOGOUT_MODE?.trim().toLowerCase() + if (mode === 'internal') { + return 'internal' + } + if (mode && mode !== 'public') { + console.warn( + `User: Unrecognised VITE_OBP_LOGOUT_MODE "${process.env.VITE_OBP_LOGOUT_MODE}", defaulting to "public".` + ) + } + return 'public' +} + +/** + * Builds the provider's RP-initiated logout (end_session_endpoint) URL for the + * given provider/id_token. Returns null when a proper end-session request can't + * be built (no provider, no end_session_endpoint, or no id_token), in which case + * the caller should fall back to a local-only logout. + */ +function buildEndSessionUrl( + provider: string | undefined, + idToken: string | undefined, + req: Request +): string | null { + if (!provider) { + console.warn('User: No provider in session; falling back to local logout.') + return null + } + + const client = providerManager.getProvider(provider) + if (!client) { + console.warn(`User: Provider "${provider}" not found; falling back to local logout.`) + return null + } + + const endSessionEndpoint = client.getEndSessionEndpoint() + if (!endSessionEndpoint) { + console.warn( + `User: Provider "${provider}" has no end_session_endpoint; falling back to local logout.` + ) + return null + } + + if (!idToken) { + console.warn('User: No id_token in session; falling back to local logout.') + return null + } + + const postLogoutRedirectUri = obpExplorerHome || `${req.protocol}://${req.get('host')}` + const endSessionUrl = new URL(endSessionEndpoint) + endSessionUrl.searchParams.set('id_token_hint', idToken) + endSessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri) + if (client.clientId) { + endSessionUrl.searchParams.set('client_id', client.clientId) + } + + return endSessionUrl.toString() +} + /** * GET /user/current * Get current logged in user information @@ -109,6 +187,13 @@ router.get('/user/logoff', (req: Request, res: Response) => { console.log('User: Logging out user') const session = req.session as any + const logoutMode = getLogoutMode() + + // Capture details needed for full SSO logout before clearing the session + const idToken = session.oauth2_id_token + const provider = session.oauth2_provider + const endSessionUrl = logoutMode === 'public' ? buildEndSessionUrl(provider, idToken, req) : null + // Clear OAuth2 session data delete session.oauth2_access_token delete session.oauth2_refresh_token @@ -130,8 +215,16 @@ router.get('/user/logoff', (req: Request, res: Response) => { console.log('User: Session destroyed successfully') } + // In "public" mode, end the provider SSO session via RP-initiated logout. + // buildEndSessionUrl() returns null when that isn't possible, so we fall + // back to the local-only logout redirect below. + if (endSessionUrl) { + console.log('User: Full SSO logout, redirecting to provider end_session_endpoint') + return res.redirect(endSessionUrl) + } + const redirectPage = (req.query.redirect as string) || obpExplorerHome || '/' - console.log('User: Redirecting to:', redirectPage) + console.log(`User: Local logout (mode=${logoutMode}), redirecting to:`, redirectPage) res.redirect(redirectPage) }) }) diff --git a/server/services/OAuth2ClientWithConfig.ts b/server/services/OAuth2ClientWithConfig.ts index 195dd02..51313ae 100644 --- a/server/services/OAuth2ClientWithConfig.ts +++ b/server/services/OAuth2ClientWithConfig.ts @@ -194,6 +194,16 @@ export class OAuth2ClientWithConfig extends OAuth2Client { return this.OIDCConfig.userinfo_endpoint } + /** + * Get the end session (RP-initiated logout) endpoint from OIDC config, if the + * provider advertises one. Optional: not all providers support it. + * + * @returns End session endpoint URL, or undefined if not available + */ + getEndSessionEndpoint(): string | undefined { + return this.OIDCConfig?.end_session_endpoint + } + /** * Check if OIDC configuration is initialized * diff --git a/server/types/oauth2.ts b/server/types/oauth2.ts index b149481..5be5c63 100644 --- a/server/types/oauth2.ts +++ b/server/types/oauth2.ts @@ -71,6 +71,7 @@ export interface OIDCConfiguration { token_endpoint: string userinfo_endpoint: string jwks_uri: string + end_session_endpoint?: string registration_endpoint?: string scopes_supported?: string[] response_types_supported?: string[]