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/4] 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/4] 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/4] 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[] From 8409f872d0d6edb82b6342175125c289ad548301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 11 Jun 2026 13:48:10 +0200 Subject: [PATCH 4/4] Document OAuth2 multi-provider login setup incl. Google - README: new 'OAuth2 / OIDC login providers' section explaining provider discovery via OBP-API /well-known, the both-sides configuration requirement, a step-by-step Login with Google walkthrough, and a migration note for the legacy VITE_OBP_OAUTH2_CLIENT_ID vars (no longer read; rename to VITE_OBP_OIDC_CLIENT_ID). - .env.example: Google provider block now documents how to obtain credentials, the redirect URI to register, and the OBP-API props (oauth2.oidc_provider, oauth2.jwk_set.url) needed on the other side. --- .env.example | 8 ++++++++ README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/.env.example b/.env.example index 214e350..53b35e4 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,14 @@ VITE_OBP_CONSUMER_KEY=your-obp-oidc-client-id # VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret ### Google Provider (Optional) ### +### 1. Create an OAuth client in Google Cloud Console (APIs & Services -> Credentials +### -> Create Credentials -> OAuth client ID, type "Web application"). +### 2. Add VITE_OAUTH2_REDIRECT_URL (see above, default +### http://localhost:5173/api/oauth2/callback) as an Authorized redirect URI. +### 3. On the OBP-API side: include "google" in oauth2.oidc_provider and add +### https://www.googleapis.com/oauth2/v3/certs to oauth2.jwk_set.url, +### otherwise the provider is not advertised by /well-known and Google +### id_tokens are rejected. See README "Login with Google" for details. # VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com # VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret diff --git a/README.md b/README.md index 2ea32be..d338906 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,61 @@ 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. +##### OAuth2 / OIDC login providers + +At startup the backend asks OBP-API which OIDC providers are available +(`GET $VITE_OBP_API_HOST/obp/v5.1.0/well-known`) and initializes a login +strategy for each advertised provider that has credentials configured in +`.env`. A provider is only offered on the login page when **both** sides +are configured: + + * **OBP-API side** — the provider must be listed in the + `oauth2.oidc_provider` props (e.g. `oauth2.oidc_provider=obp-oidc,keycloak,google`) + and its JWKS URL must be present in `oauth2.jwk_set.url` so OBP-API can + validate the tokens. + * **API Explorer side** — the matching `VITE__CLIENT_ID` / + `VITE__CLIENT_SECRET` pair must be set in `.env` + (`VITE_OBP_OIDC_*`, `VITE_KEYCLOAK_*`, `VITE_GOOGLE_*`, `VITE_GITHUB_*`, + or `VITE_CUSTOM_OIDC_*` — see `.env.example`). + +> **Migrating an old `.env`:** the legacy `VITE_OBP_OAUTH2_CLIENT_ID` / +> `VITE_OBP_OAUTH2_CLIENT_SECRET` variables are no longer read — rename them to +> `VITE_OBP_OIDC_CLIENT_ID` / `VITE_OBP_OIDC_CLIENT_SECRET`, otherwise the +> obp-oidc provider fails to initialize with "No strategy found". + +All providers share one callback URL: `VITE_OAUTH2_REDIRECT_URL` +(default `http://localhost:5173/api/oauth2/callback`). Providers that fail to +initialize (missing credentials, unreachable well-known URL) are logged at +startup and retried every 30 seconds — look for +`OAuth2ProviderFactory: Available strategies: ...` in the log to see what loaded. + +###### Login with Google + +1. In [Google Cloud Console](https://console.cloud.google.com/apis/credentials) + go to **APIs & Services → Credentials → Create Credentials → OAuth client ID** + and choose type **Web application**. +2. Add `VITE_OAUTH2_REDIRECT_URL` (e.g. `http://localhost:5173/api/oauth2/callback`) + as an **Authorized redirect URI**. It must match exactly. +3. Put the generated credentials in `.env`: + + ``` + VITE_GOOGLE_CLIENT_ID=.apps.googleusercontent.com + VITE_GOOGLE_CLIENT_SECRET= + ``` +4. On the OBP-API instance, make sure the props include: + + ``` + oauth2.oidc_provider=obp-oidc,keycloak,google + oauth2.jwk_set.url=...,https://www.googleapis.com/oauth2/v3/certs + ``` +5. Restart `npm run dev` (env vars are read at process start). The startup log + should show `OK Google strategy loaded` and `OK google initialized`. + +Note: the Explorer sends the Google **id_token** as the Bearer token to +OBP-API (Google access tokens are opaque, not JWTs). OBP-API validates it +against Google's JWKS and auto-creates a user keyed by the token's +`iss` + `sub` claims. + ##### Logout behaviour (`VITE_OBP_LOGOUT_MODE`) Controls what happens when a user logs out (`GET /api/user/logoff`):