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
13 changes: 12 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -64,4 +75,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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
95 changes: 94 additions & 1 deletion server/routes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
})
})
Expand Down
10 changes: 10 additions & 0 deletions server/services/OAuth2ClientWithConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
1 change: 1 addition & 0 deletions server/types/oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
6 changes: 4 additions & 2 deletions src/shared-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +21,7 @@ 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 = 'v7.0.0'

Expand Down