Aman-Mittal commented on code in PR #818: URL: https://github.com/apache/fineract-backoffice-ui/pull/818#discussion_r4385337669
########## src/app/core/services/oidc.service.ts: ########## @@ -0,0 +1,460 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { STORAGE } from '../adapters'; +import { ConfigService, OidcConfig } from './config.service'; + +/** The endpoints of the provider that this application uses, read from its discovery document. */ +interface DiscoveryDocument { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + end_session_endpoint?: string; +} + +interface TokenResponse { + access_token: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + refresh_expires_in?: number; +} + +/** The tokens of the signed-in user, kept for the life of the tab. */ +export interface OidcTokens { + accessToken: string; + refreshToken?: string; + idToken?: string; + /** Epoch milliseconds at which the access token stops being valid. */ + expiresAt: number; + /** Epoch milliseconds at which the refresh token stops being valid, when the provider says. */ + refreshExpiresAt?: number; +} + +/** A sign-in in progress: what must be remembered across the redirect to the provider. */ +interface OidcFlow { + state: string; + nonce: string; + verifier: string; +} + +/** Refresh this long before the access token expires. */ +export const REFRESH_SKEW_MS = 60_000; + +/** Tolerated difference between the provider's clock and this browser's, for `exp` and `iat`, in seconds. */ +const CLOCK_SKEW_S = 60; + +const DEFAULT_SCOPES = 'openid profile'; +const DEFAULT_REDIRECT_PATH = '/auth/callback'; +const DEFAULT_POST_LOGOUT_PATH = '/login'; + +/** Why a sign-in could not be completed. The message is for the log, never for the user. */ +export class OidcError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + this.name = 'OidcError'; + } +} + +function base64Url(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCodePoint(b); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +} + +function randomString(byteLength = 32): string { + return base64Url(crypto.getRandomValues(new Uint8Array(byteLength))); +} + +/** The S256 code challenge for a verifier (RFC 7636 section 4.2). */ +export async function pkceChallenge(verifier: string): Promise<string> { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return base64Url(new Uint8Array(digest)); +} + +/** The claims of a JWT. Not verified: only ever applied to an ID token the token endpoint returned. */ +function jwtClaims(jwt: string): Record<string, unknown> { + try { + const payload = jwt.split('.', 2)[1].replaceAll('-', '+').replaceAll('_', '/'); + const bytes = Uint8Array.from( + atob(payload.padEnd(Math.ceil(payload.length / 4) * 4, '=')), + (c) => c.codePointAt(0) ?? 0, + ); + return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>; + } catch { + return {}; + } +} + +/** + * OpenID Connect sign-in for a public client: authorization-code flow with PKCE (S256), token + * refresh, and RP-initiated logout. + * + * Written in-house rather than taken from a library, and kept small (see `DOCS/OIDC.md`): it + * needs no dependency and no iframe or session-check timers, and it talks to the provider with + * `fetch` so that none of this application's HTTP interceptors (which add tenant headers that a + * provider's CORS policy refuses) sees the traffic. It does not verify an ID token's signature: + * the token is received straight from the token endpoint named by the provider's discovery + * document, which OpenID Connect Core section 3.1.3.7 accepts. Fineract validates the access + * token on every call. + * + * Tokens live in tab-scoped storage and are cleared by `clearScope('session')` on sign-out. + */ +@Injectable({ providedIn: 'root' }) +export class OidcService { + private readonly configService = inject(ConfigService); + private readonly storage = inject(STORAGE); + + /** Emits when the provider refused a refresh: the session is over and the user must sign in again. */ + readonly sessionEnded = new Subject<void>(); + + private discovery: Promise<DiscoveryDocument> | null = null; + private discoveryFor: string | null = null; + private refreshing: Promise<void> | null = null; + private timer: ReturnType<typeof setTimeout> | null = null; + /** Bumped by every {@link clear}; a request that started in an earlier generation is dropped. */ + private generation = 0; + + constructor() { + if (this.hasSession()) this.scheduleRefresh(); + } + + /** The configured provider, or `null` when OIDC sign-in is not configured. */ + get config(): OidcConfig | null { + return this.configService.oidcConfig(); + } + + /** Whether this tab holds OIDC tokens. */ + hasSession(): boolean { + return !!this.tokens()?.accessToken; + } + + /** The current access token, or `null`. Use {@link ensureFresh} first when it must not be about to expire. */ + getAccessToken(): string | null { + return this.tokens()?.accessToken ?? null; + } + + /** Redirects the browser to the provider's sign-in page. */ + async startLogin(): Promise<void> { + const cfg = this.requireConfig(); + const doc = await this.discover(); + const flow: OidcFlow = { + state: randomString(), + nonce: randomString(), + verifier: randomString(48), + }; + this.storage.write('oidcFlow', flow); + const query = new URLSearchParams({ + client_id: cfg.clientId, + redirect_uri: this.redirectUri(), + response_type: 'code', + scope: this.scopes(), + state: flow.state, + nonce: flow.nonce, + code_challenge: await pkceChallenge(flow.verifier), + code_challenge_method: 'S256', + }); + this.navigate(`${doc.authorization_endpoint}?${query.toString()}`); + } + + /** + * Finishes a sign-in: checks `state`, exchanges the code (with the PKCE verifier) for tokens, + * checks the ID token's `nonce`, issuer and audience, and stores the tokens. + * + * @param params - the query parameters of the redirect back to this application + */ + async completeLogin(params: URLSearchParams): Promise<void> { + const cfg = this.requireConfig(); + const flow = this.storage.read<OidcFlow | null>('oidcFlow', null); + // One attempt per flow: a replayed redirect finds nothing to check against. + this.storage.remove('oidcFlow'); + const providerError = params.get('error'); + if (providerError) throw new OidcError(`provider returned ${providerError}`, providerError); + const code = params.get('code'); + if (!flow || !code || params.get('state') !== flow.state) { + throw new OidcError('missing or mismatched state', 'invalid_state'); + } + // Read before the first await: a sign-out during discovery must count. + const generation = this.generation; + const doc = await this.discover(); + if (generation !== this.generation) + throw new OidcError('signed out during sign-in', 'cancelled'); + const body = await this.tokenRequest(doc, { + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri(), + client_id: cfg.clientId, + code_verifier: flow.verifier, + }); + this.validateIdToken(body.id_token, flow.nonce, doc.issuer, cfg.clientId); + // A sign-out while the code was being exchanged wins: do not open a session after it. + if (generation !== this.generation) + throw new OidcError('signed out during sign-in', 'cancelled'); + this.store(body, undefined); + } + + /** + * Checks the ID token of a code exchange (OpenID Connect Core 3.1.3.7). It is required: it is what + * `endSession` later sends as `id_token_hint`, without which the provider asks for confirmation + * instead of ending the session. + * + * The signature is deliberately not verified: the token arrives in the back-channel response of + * the token endpoint named by the provider's discovery document, over TLS (rule 6 of 3.1.3.7), + * and this application takes nothing from it but these claims. Fineract validates the access + * token, signature included, on every call. + */ + private validateIdToken( + idToken: string | undefined, + nonce: string, + issuer: string, + clientId: string, + ): void { + if (!idToken) throw new OidcError('the token response has no ID token', 'invalid_id_token'); + const claims = jwtClaims(idToken); + const aud = ([] as unknown[]).concat(claims['aud']); + const now = Date.now() / 1000; + const exp = Number(claims['exp']); + const iat = Number(claims['iat']); + const failed = ( + [ + ['nonce', claims['nonce'] === nonce], + ['issuer', claims['iss'] === issuer], + ['audience', aud.includes(clientId)], + ['authorized party', claims['azp'] === undefined || claims['azp'] === clientId], + ['expiry', Number.isFinite(exp) && exp + CLOCK_SKEW_S >= now], + ['issue time', !Number.isFinite(iat) || iat - CLOCK_SKEW_S <= now], + ] as const + ).find(([, ok]) => !ok); + const problem = failed?.[0]; + if (problem) throw new OidcError(`ID token ${problem} check failed`, 'invalid_id_token'); + } + + /** The ID token of the signed-in user, or `null`. Read it before clearing the session: sign-out needs it. */ + getIdToken(): string | null { + return this.tokens()?.idToken ?? null; + } + + /** + * Makes sure the access token is valid for at least {@link REFRESH_SKEW_MS}, refreshing it if + * not. Concurrent callers share one refresh. A refusal by the provider ends the session; a + * network failure leaves it as it is and rejects, so the request still goes out and Fineract + * decides. + */ + ensureFresh(): Promise<void> { + const t = this.tokens(); + if (!t || t.expiresAt - Date.now() > REFRESH_SKEW_MS) return Promise.resolve(); + this.refreshing ??= this.refresh().finally(() => (this.refreshing = null)); + return this.refreshing; + } + + /** + * Signs out at the provider (OpenID Connect RP-Initiated Logout 1.0) and comes back to the + * login page. The local tokens are dropped first, so the application is signed out even if the + * provider cannot be reached. + * + * @param idTokenHint - the user's ID token, read by the caller before the session was cleared. + * Always sent as `id_token_hint` together with `post_logout_redirect_uri`: without the hint + * Keycloak shows a confirmation page, and a user who closes it leaves the SSO session alive. + * Defaults to the stored token, for a caller that has not cleared anything yet. + */ + async endSession(idTokenHint?: string | null): Promise<void> { + const cfg = this.config; + const idToken = idTokenHint ?? this.getIdToken(); + this.clear(); + if (!cfg) return; + try { + const doc = await this.discover(); + if (!doc.end_session_endpoint) return; + const query = new URLSearchParams({ + client_id: cfg.clientId, + post_logout_redirect_uri: this.origin() + (cfg.postLogoutPath ?? DEFAULT_POST_LOGOUT_PATH), + }); + if (idToken) query.set('id_token_hint', idToken); + this.navigate(`${doc.end_session_endpoint}?${query.toString()}`); + } catch { + // The provider is unreachable: the local sign-out above already happened. + } + } + + /** Drops the tokens and any pending timer, without telling the provider. */ + clear(): void { + // Anything in flight (a refresh, a code exchange) started before this and must not store its result. + this.generation++; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.storage.remove('oidcTokens'); + this.storage.remove('oidcFlow'); + } + + /** Seam for tests: the only place that leaves the application. */ + protected navigate(url: string): void { + window.location.assign(url); + } + + protected origin(): string { + return window.location.origin; + } + + private redirectUri(): string { + return this.origin() + (this.requireConfig().redirectPath ?? DEFAULT_REDIRECT_PATH); + } + + /** The requested scopes. `offline_access` is dropped: it would make the refresh token outlive sign-out. */ + private scopes(): string { + const asked = (this.requireConfig().scopes ?? DEFAULT_SCOPES).split(/\s+/).filter(Boolean); + return asked.filter((s) => s !== 'offline_access').join(' '); + } + + private requireConfig(): OidcConfig { + const cfg = this.config; + if (!cfg) throw new OidcError('OIDC sign-in is not configured', 'not_configured'); + return cfg; + } + + private tokens(): OidcTokens | null { + return this.storage.read<OidcTokens | null>('oidcTokens', null); + } + + private async refresh(): Promise<void> { + // Read before the first await: a sign-out during discovery must count. + const generation = this.generation; + const t = this.tokens(); + const cfg = this.config; + if (!t || !cfg) return; + if (!t.refreshToken || (t.refreshExpiresAt && t.refreshExpiresAt <= Date.now())) { + this.endLocally(); + return; + } + const doc = await this.discover(); + if (generation !== this.generation) return; + try { + const body = await this.tokenRequest(doc, { + grant_type: 'refresh_token', + refresh_token: t.refreshToken, + client_id: cfg.clientId, + }); + // Signed out while the refresh was in flight: drop the answer, or it would restore the session. + if (generation !== this.generation) return; + this.store(body, t); + } catch (e) { + if (generation !== this.generation) return; + // invalid_grant and friends: the provider session is gone. A network error is not. + if (e instanceof OidcError) this.endLocally(); + throw e; + } + } + + private endLocally(): void { + this.clear(); + this.sessionEnded.next(); + } + + private store(body: TokenResponse, previous: OidcTokens | undefined): void { + const now = Date.now(); + const tokens: OidcTokens = { + accessToken: body.access_token, + refreshToken: body.refresh_token ?? previous?.refreshToken, + idToken: body.id_token ?? previous?.idToken, + expiresAt: now + (body.expires_in ?? 60) * 1000, Review Comment: **Refresh loop.** The fallback lifetime (`expires_in ?? 60`) equals `REFRESH_SKEW_MS` (60 000), so: - `ensureFresh`'s `expiresAt - now > REFRESH_SKEW_MS` is never true, and every API call waits on a refresh; - `scheduleRefresh` computes `Math.max(0, 1000)` and re-fires every **1 s**. The same happens for any provider configured with an access-token lifetime of 60 s or less (`expires_in` is only RECOMMENDED in RFC 6749). The result is an endless `refresh_token` grant loop against the IdP. Suggested: - fall back to the JWT `exp`, or something like 300 s; - use `skew = min(60 s, lifetime / 2)`; - floor the timer delay (e.g. 15 s); - add unit tests for `expires_in` missing and set to 30. ########## DOCS/OIDC.md: ########## @@ -125,3 +125,101 @@ Using Keycloak, which is the dialect upstream documents most fully: Permissions are unaffected: they continue to come from Fineract's own role model, whichever way the user authenticated, and are evaluated by `AuthService.hasPermission()` as described in [DOCS/RBAC.md](RBAC.md). + +## Signing in through the identity provider + +The login page can offer **Sign in with your identity provider**, which signs a user in with the +authorization-code flow and PKCE (S256) as a **public client**. No client secret exists or is +needed. Fineract still only validates the Bearer token, as described above; this is the client half. + +The feature is **opt-in**. Without the keys below the login page is unchanged: a username and +password, and nothing else. + +### Configuration + +Turn it on in `config.json` (or the deployment's `branding/config.json`): + +```json +{ + "oidcLoginEnabled": true, + "oidc": { + "issuer": "https://keycloak.example/realms/fineract", + "clientId": "fineract-backoffice-ui", + "scopes": "openid profile" + }, + "passwordLoginEnabled": false +} +``` + +| Key | Default | Meaning | +| ---------------------- | ---------------- | ------------------------------------------------------------------------------- | +| `oidcLoginEnabled` | `false` | Offers the identity-provider button. Must be exactly `true`. | +| `oidc.issuer` | _(required)_ | Issuer URL; discovery is read from `<issuer>/.well-known/openid-configuration`. | +| `oidc.clientId` | _(required)_ | The public client registered with the provider. | +| `oidc.scopes` | `openid profile` | Space-separated. `offline_access` is dropped even if listed. | +| `oidc.redirectPath` | `/auth/callback` | Path on this application's origin that receives the authorization code. | +| `oidc.postLogoutPath` | `/login` | Path on this application's origin to return to after sign-out. | +| `passwordLoginEnabled` | `true` | `false` removes the username and password form (see below). | + +- `oidcLoginEnabled` is ignored unless `oidc.issuer` and `oidc.clientId` are both set. +- `passwordLoginEnabled: false` is ignored unless OIDC sign-in is enabled and complete, so a mistaken + config cannot lock everyone out. It hides the form only; it does not stop Fineract from accepting + Basic credentials, which is a server setting. +- The Content-Security-Policy must allow the provider: add its origin to `connect-src`. The Review Comment: **The shipped container can't do this.** `deploy/nginx.conf.template:50` hard-codes `connect-src 'self'`, and `deploy/entrypoint.sh` only substitutes `FINERACT_UPSTREAM`, `FINERACT_PROXY_SSL_VERIFY` and `FINERACT_FORWARDED_PROTO`. With the official image, the discovery `fetch` and the token `fetch` to the IdP are blocked, so users always see "The identity provider could not be reached". The authorize redirect still works because it's a navigation. Please add an entrypoint setting (e.g. `OIDC_ISSUER_ORIGIN`) that's appended to `connect-src`, or support proxying the IdP's `.well-known` and token endpoints through the same origin. Either way, document it here instead of asking operators to hand-edit the CSP. ########## src/app/core/services/auth.service.ts: ########## @@ -153,10 +212,59 @@ export class AuthService { ); } + constructor() { + // The provider refused a token refresh: the session is over. Local sign-out only, the + // provider has already ended its side. + this.oidc.sessionEnded.subscribe(() => { + if (!this.isAuthenticated()) return; + this.clearSession(); + void this.injector + .get(Router) + .navigate(['/login'], { queryParams: { reason: SESSION_EXPIRED_REASON } }); + }); + } + /** - * Logs out the current user and clears the session state. + * Completes an identity-provider sign-in: asks Fineract who the Bearer token belongs to + * (`GET /v1/userdetails`) and opens the session from the answer. The tokens must already be + * stored (`OidcService.completeLogin`). + * + * @returns the resulting session. On failure the tokens are dropped, so nobody is left half signed in. + */ + completeOidcLogin(): Observable<UserSession> { + const tenantId = this.currentTenantId(); + return this.http + .get<UserDetailsResponse>(`${this.configService.apiUrl}/userdetails`, { + headers: new HttpHeaders({ + Authorization: `Bearer ${this.oidc.getAccessToken() ?? ''}`, + 'Fineract-Platform-TenantId': tenantId, + 'X-Mifos-Platform-TenantId': tenantId, + }), + context: skipErrorToast(), + }) + .pipe( + map((details) => sessionFromUserDetails(details)), + tap({ + next: (session) => this.setSession(session), + error: () => this.oidc.clear(), + }), + ); + } + + /** + * Logs out the current user and clears the session state. An identity-provider session is + * also ended at the provider (the browser is redirected there and back to the login page). */ logout(): void { + const viaProvider = this.isOidcSession() || this.oidc.hasSession(); + // Read before clearing: clearSession() wipes the tokens, and the provider needs the ID token + // as id_token_hint to end its session without asking the user to confirm. + const idToken = this.oidc.getIdToken(); + this.clearSession(); + if (viaProvider) void this.oidc.endSession(idToken); Review Comment: **OIDC users lose the sign-out reason.** `idle.service.ts` and `error.interceptor.ts` call `logout()` and then navigate to `/login?reason=…`. For OIDC sessions, `endSession` does a `window.location.assign(end_session_endpoint…)` that returns to the fixed `postLogoutPath`, without the reason. So someone timed out for inactivity, or whose token Fineract rejected, sees a plain login page with no explanation; basic-auth users still get the message. Please carry the reason through, e.g. stash it in session storage before redirecting and read it on `/login`, or append it to `post_logout_redirect_uri` if the IdP allows that. ########## src/app/core/adapters/storage/storage-keys.ts: ########## @@ -51,6 +51,10 @@ export type StorageScope = 'session' | 'device'; export const STORAGE_KEYS = { /** The authenticated session, including the Basic auth key. Tab-scoped, never persisted. */ session: { key: 'fineract_session', scope: 'session' }, + /** The OIDC tokens of the signed-in user. Tab-scoped, never persisted. */ + oidcTokens: { key: 'fineract_oidc_tokens', scope: 'session' }, Review Comment: Security hardening, non-blocking. This matches how the Basic key is stored, and the strict CSP limits XSS. But a refresh token taken from the session-scoped store is worse than a stolen Basic key: the client is public and nothing ties the token to this browser, so whoever holds it can mint new access tokens from anywhere until it expires. At minimum, `DOCS/security.md` should require refresh-token rotation at the IdP (Keycloak "Revoke Refresh Token", reuse 0). Better options are keeping the refresh token in memory only (accepting a re-login on reload) or DPoP. Also, when the IdP has no `end_session_endpoint`, logout returns early (`oidc.service.ts:288`) without calling the revocation endpoint, so the refresh token stays valid at the IdP. ########## src/app/core/services/auth.service.ts: ########## @@ -153,10 +212,59 @@ export class AuthService { ); } + constructor() { + // The provider refused a token refresh: the session is over. Local sign-out only, the + // provider has already ended its side. + this.oidc.sessionEnded.subscribe(() => { + if (!this.isAuthenticated()) return; + this.clearSession(); + void this.injector + .get(Router) + .navigate(['/login'], { queryParams: { reason: SESSION_EXPIRED_REASON } }); + }); + } + /** - * Logs out the current user and clears the session state. + * Completes an identity-provider sign-in: asks Fineract who the Bearer token belongs to + * (`GET /v1/userdetails`) and opens the session from the answer. The tokens must already be + * stored (`OidcService.completeLogin`). + * + * @returns the resulting session. On failure the tokens are dropped, so nobody is left half signed in. + */ + completeOidcLogin(): Observable<UserSession> { + const tenantId = this.currentTenantId(); + return this.http + .get<UserDetailsResponse>(`${this.configService.apiUrl}/userdetails`, { Review Comment: **Blocking (platform dependency).** On current Fineract this call 404s in the mode an external IdP uses. `UserDetailsApiResource` is `@ConditionalOnProperty("fineract.security.oauth2.enabled")` (Fineract as its own authorization server), and federation (`fineract.security.oidc-federation.enabled`) doesn't load it. So every sign-in ends on "server cannot identify users". Please link the Fineract change (Jira plus PR) this depends on, and gate or document the feature by minimum Fineract version until it's released. ########## src/app/core/services/oidc.service.ts: ########## @@ -0,0 +1,460 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { STORAGE } from '../adapters'; +import { ConfigService, OidcConfig } from './config.service'; + +/** The endpoints of the provider that this application uses, read from its discovery document. */ +interface DiscoveryDocument { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + end_session_endpoint?: string; +} + +interface TokenResponse { + access_token: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + refresh_expires_in?: number; +} + +/** The tokens of the signed-in user, kept for the life of the tab. */ +export interface OidcTokens { + accessToken: string; + refreshToken?: string; + idToken?: string; + /** Epoch milliseconds at which the access token stops being valid. */ + expiresAt: number; + /** Epoch milliseconds at which the refresh token stops being valid, when the provider says. */ + refreshExpiresAt?: number; +} + +/** A sign-in in progress: what must be remembered across the redirect to the provider. */ +interface OidcFlow { + state: string; + nonce: string; + verifier: string; +} + +/** Refresh this long before the access token expires. */ +export const REFRESH_SKEW_MS = 60_000; + +/** Tolerated difference between the provider's clock and this browser's, for `exp` and `iat`, in seconds. */ +const CLOCK_SKEW_S = 60; + +const DEFAULT_SCOPES = 'openid profile'; +const DEFAULT_REDIRECT_PATH = '/auth/callback'; +const DEFAULT_POST_LOGOUT_PATH = '/login'; + +/** Why a sign-in could not be completed. The message is for the log, never for the user. */ +export class OidcError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + this.name = 'OidcError'; + } +} + +function base64Url(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCodePoint(b); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +} + +function randomString(byteLength = 32): string { + return base64Url(crypto.getRandomValues(new Uint8Array(byteLength))); +} + +/** The S256 code challenge for a verifier (RFC 7636 section 4.2). */ +export async function pkceChallenge(verifier: string): Promise<string> { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return base64Url(new Uint8Array(digest)); +} + +/** The claims of a JWT. Not verified: only ever applied to an ID token the token endpoint returned. */ +function jwtClaims(jwt: string): Record<string, unknown> { + try { + const payload = jwt.split('.', 2)[1].replaceAll('-', '+').replaceAll('_', '/'); + const bytes = Uint8Array.from( + atob(payload.padEnd(Math.ceil(payload.length / 4) * 4, '=')), + (c) => c.codePointAt(0) ?? 0, + ); + return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>; + } catch { + return {}; + } +} + +/** + * OpenID Connect sign-in for a public client: authorization-code flow with PKCE (S256), token + * refresh, and RP-initiated logout. + * + * Written in-house rather than taken from a library, and kept small (see `DOCS/OIDC.md`): it + * needs no dependency and no iframe or session-check timers, and it talks to the provider with + * `fetch` so that none of this application's HTTP interceptors (which add tenant headers that a + * provider's CORS policy refuses) sees the traffic. It does not verify an ID token's signature: + * the token is received straight from the token endpoint named by the provider's discovery + * document, which OpenID Connect Core section 3.1.3.7 accepts. Fineract validates the access + * token on every call. + * + * Tokens live in tab-scoped storage and are cleared by `clearScope('session')` on sign-out. + */ +@Injectable({ providedIn: 'root' }) +export class OidcService { + private readonly configService = inject(ConfigService); + private readonly storage = inject(STORAGE); + + /** Emits when the provider refused a refresh: the session is over and the user must sign in again. */ + readonly sessionEnded = new Subject<void>(); + + private discovery: Promise<DiscoveryDocument> | null = null; + private discoveryFor: string | null = null; + private refreshing: Promise<void> | null = null; + private timer: ReturnType<typeof setTimeout> | null = null; + /** Bumped by every {@link clear}; a request that started in an earlier generation is dropped. */ + private generation = 0; + + constructor() { + if (this.hasSession()) this.scheduleRefresh(); + } + + /** The configured provider, or `null` when OIDC sign-in is not configured. */ + get config(): OidcConfig | null { + return this.configService.oidcConfig(); + } + + /** Whether this tab holds OIDC tokens. */ + hasSession(): boolean { + return !!this.tokens()?.accessToken; + } + + /** The current access token, or `null`. Use {@link ensureFresh} first when it must not be about to expire. */ + getAccessToken(): string | null { + return this.tokens()?.accessToken ?? null; + } + + /** Redirects the browser to the provider's sign-in page. */ + async startLogin(): Promise<void> { + const cfg = this.requireConfig(); + const doc = await this.discover(); + const flow: OidcFlow = { + state: randomString(), + nonce: randomString(), + verifier: randomString(48), + }; + this.storage.write('oidcFlow', flow); + const query = new URLSearchParams({ + client_id: cfg.clientId, + redirect_uri: this.redirectUri(), + response_type: 'code', + scope: this.scopes(), + state: flow.state, + nonce: flow.nonce, + code_challenge: await pkceChallenge(flow.verifier), + code_challenge_method: 'S256', + }); + this.navigate(`${doc.authorization_endpoint}?${query.toString()}`); + } + + /** + * Finishes a sign-in: checks `state`, exchanges the code (with the PKCE verifier) for tokens, + * checks the ID token's `nonce`, issuer and audience, and stores the tokens. + * + * @param params - the query parameters of the redirect back to this application + */ + async completeLogin(params: URLSearchParams): Promise<void> { + const cfg = this.requireConfig(); + const flow = this.storage.read<OidcFlow | null>('oidcFlow', null); + // One attempt per flow: a replayed redirect finds nothing to check against. + this.storage.remove('oidcFlow'); + const providerError = params.get('error'); + if (providerError) throw new OidcError(`provider returned ${providerError}`, providerError); + const code = params.get('code'); + if (!flow || !code || params.get('state') !== flow.state) { + throw new OidcError('missing or mismatched state', 'invalid_state'); + } + // Read before the first await: a sign-out during discovery must count. + const generation = this.generation; + const doc = await this.discover(); + if (generation !== this.generation) + throw new OidcError('signed out during sign-in', 'cancelled'); + const body = await this.tokenRequest(doc, { + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri(), + client_id: cfg.clientId, + code_verifier: flow.verifier, + }); + this.validateIdToken(body.id_token, flow.nonce, doc.issuer, cfg.clientId); + // A sign-out while the code was being exchanged wins: do not open a session after it. + if (generation !== this.generation) + throw new OidcError('signed out during sign-in', 'cancelled'); + this.store(body, undefined); + } + + /** + * Checks the ID token of a code exchange (OpenID Connect Core 3.1.3.7). It is required: it is what + * `endSession` later sends as `id_token_hint`, without which the provider asks for confirmation + * instead of ending the session. + * + * The signature is deliberately not verified: the token arrives in the back-channel response of + * the token endpoint named by the provider's discovery document, over TLS (rule 6 of 3.1.3.7), + * and this application takes nothing from it but these claims. Fineract validates the access + * token, signature included, on every call. + */ + private validateIdToken( + idToken: string | undefined, + nonce: string, + issuer: string, + clientId: string, + ): void { + if (!idToken) throw new OidcError('the token response has no ID token', 'invalid_id_token'); + const claims = jwtClaims(idToken); + const aud = ([] as unknown[]).concat(claims['aud']); + const now = Date.now() / 1000; + const exp = Number(claims['exp']); + const iat = Number(claims['iat']); + const failed = ( + [ + ['nonce', claims['nonce'] === nonce], + ['issuer', claims['iss'] === issuer], + ['audience', aud.includes(clientId)], + ['authorized party', claims['azp'] === undefined || claims['azp'] === clientId], + ['expiry', Number.isFinite(exp) && exp + CLOCK_SKEW_S >= now], + ['issue time', !Number.isFinite(iat) || iat - CLOCK_SKEW_S <= now], + ] as const + ).find(([, ok]) => !ok); + const problem = failed?.[0]; + if (problem) throw new OidcError(`ID token ${problem} check failed`, 'invalid_id_token'); + } + + /** The ID token of the signed-in user, or `null`. Read it before clearing the session: sign-out needs it. */ + getIdToken(): string | null { + return this.tokens()?.idToken ?? null; + } + + /** + * Makes sure the access token is valid for at least {@link REFRESH_SKEW_MS}, refreshing it if + * not. Concurrent callers share one refresh. A refusal by the provider ends the session; a + * network failure leaves it as it is and rejects, so the request still goes out and Fineract + * decides. + */ + ensureFresh(): Promise<void> { + const t = this.tokens(); + if (!t || t.expiresAt - Date.now() > REFRESH_SKEW_MS) return Promise.resolve(); + this.refreshing ??= this.refresh().finally(() => (this.refreshing = null)); + return this.refreshing; + } + + /** + * Signs out at the provider (OpenID Connect RP-Initiated Logout 1.0) and comes back to the + * login page. The local tokens are dropped first, so the application is signed out even if the + * provider cannot be reached. + * + * @param idTokenHint - the user's ID token, read by the caller before the session was cleared. + * Always sent as `id_token_hint` together with `post_logout_redirect_uri`: without the hint + * Keycloak shows a confirmation page, and a user who closes it leaves the SSO session alive. + * Defaults to the stored token, for a caller that has not cleared anything yet. + */ + async endSession(idTokenHint?: string | null): Promise<void> { + const cfg = this.config; + const idToken = idTokenHint ?? this.getIdToken(); + this.clear(); + if (!cfg) return; + try { + const doc = await this.discover(); + if (!doc.end_session_endpoint) return; + const query = new URLSearchParams({ + client_id: cfg.clientId, + post_logout_redirect_uri: this.origin() + (cfg.postLogoutPath ?? DEFAULT_POST_LOGOUT_PATH), + }); + if (idToken) query.set('id_token_hint', idToken); + this.navigate(`${doc.end_session_endpoint}?${query.toString()}`); + } catch { + // The provider is unreachable: the local sign-out above already happened. + } + } + + /** Drops the tokens and any pending timer, without telling the provider. */ + clear(): void { + // Anything in flight (a refresh, a code exchange) started before this and must not store its result. + this.generation++; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.storage.remove('oidcTokens'); + this.storage.remove('oidcFlow'); + } + + /** Seam for tests: the only place that leaves the application. */ + protected navigate(url: string): void { + window.location.assign(url); + } + + protected origin(): string { + return window.location.origin; + } + + private redirectUri(): string { + return this.origin() + (this.requireConfig().redirectPath ?? DEFAULT_REDIRECT_PATH); + } + + /** The requested scopes. `offline_access` is dropped: it would make the refresh token outlive sign-out. */ + private scopes(): string { + const asked = (this.requireConfig().scopes ?? DEFAULT_SCOPES).split(/\s+/).filter(Boolean); + return asked.filter((s) => s !== 'offline_access').join(' '); + } + + private requireConfig(): OidcConfig { + const cfg = this.config; + if (!cfg) throw new OidcError('OIDC sign-in is not configured', 'not_configured'); + return cfg; + } + + private tokens(): OidcTokens | null { + return this.storage.read<OidcTokens | null>('oidcTokens', null); + } + + private async refresh(): Promise<void> { + // Read before the first await: a sign-out during discovery must count. + const generation = this.generation; + const t = this.tokens(); + const cfg = this.config; + if (!t || !cfg) return; + if (!t.refreshToken || (t.refreshExpiresAt && t.refreshExpiresAt <= Date.now())) { + this.endLocally(); + return; + } + const doc = await this.discover(); + if (generation !== this.generation) return; + try { + const body = await this.tokenRequest(doc, { + grant_type: 'refresh_token', + refresh_token: t.refreshToken, + client_id: cfg.clientId, + }); + // Signed out while the refresh was in flight: drop the answer, or it would restore the session. + if (generation !== this.generation) return; + this.store(body, t); + } catch (e) { + if (generation !== this.generation) return; + // invalid_grant and friends: the provider session is gone. A network error is not. + if (e instanceof OidcError) this.endLocally(); Review Comment: **A transient outage signs everyone out.** `tokenRequest` (L414–418) throws `OidcError` for any `!response.ok`, which includes 500, 502 and 503, and an HTML gateway page whose body parses as `{}` and becomes `token_error`. This line then calls `endLocally()`, so a short IdP or proxy blip during a background refresh sends every signed-in teller to `/login?reason=session-expired`. That contradicts the comment above and the docs ("a network failure does not"). Please end the session only on 400 or 401 with `error` ∈ {`invalid_grant`, `invalid_client`, `unauthorized_client`}; treat 5xx and unparseable responses like a network error and retry. Please also add a unit test; the current ones cover only a refusal and `fetch` rejecting. ########## src/app/core/services/oidc.service.ts: ########## @@ -0,0 +1,460 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { STORAGE } from '../adapters'; +import { ConfigService, OidcConfig } from './config.service'; + +/** The endpoints of the provider that this application uses, read from its discovery document. */ +interface DiscoveryDocument { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + end_session_endpoint?: string; +} + +interface TokenResponse { + access_token: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + refresh_expires_in?: number; +} + +/** The tokens of the signed-in user, kept for the life of the tab. */ +export interface OidcTokens { + accessToken: string; + refreshToken?: string; + idToken?: string; + /** Epoch milliseconds at which the access token stops being valid. */ + expiresAt: number; + /** Epoch milliseconds at which the refresh token stops being valid, when the provider says. */ + refreshExpiresAt?: number; +} + +/** A sign-in in progress: what must be remembered across the redirect to the provider. */ +interface OidcFlow { + state: string; + nonce: string; + verifier: string; +} + +/** Refresh this long before the access token expires. */ +export const REFRESH_SKEW_MS = 60_000; + +/** Tolerated difference between the provider's clock and this browser's, for `exp` and `iat`, in seconds. */ +const CLOCK_SKEW_S = 60; + +const DEFAULT_SCOPES = 'openid profile'; +const DEFAULT_REDIRECT_PATH = '/auth/callback'; +const DEFAULT_POST_LOGOUT_PATH = '/login'; + +/** Why a sign-in could not be completed. The message is for the log, never for the user. */ +export class OidcError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + this.name = 'OidcError'; + } +} + +function base64Url(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCodePoint(b); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +} + +function randomString(byteLength = 32): string { + return base64Url(crypto.getRandomValues(new Uint8Array(byteLength))); +} + +/** The S256 code challenge for a verifier (RFC 7636 section 4.2). */ +export async function pkceChallenge(verifier: string): Promise<string> { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return base64Url(new Uint8Array(digest)); +} + +/** The claims of a JWT. Not verified: only ever applied to an ID token the token endpoint returned. */ +function jwtClaims(jwt: string): Record<string, unknown> { + try { + const payload = jwt.split('.', 2)[1].replaceAll('-', '+').replaceAll('_', '/'); + const bytes = Uint8Array.from( + atob(payload.padEnd(Math.ceil(payload.length / 4) * 4, '=')), + (c) => c.codePointAt(0) ?? 0, + ); + return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>; + } catch { + return {}; + } +} + +/** + * OpenID Connect sign-in for a public client: authorization-code flow with PKCE (S256), token + * refresh, and RP-initiated logout. + * + * Written in-house rather than taken from a library, and kept small (see `DOCS/OIDC.md`): it + * needs no dependency and no iframe or session-check timers, and it talks to the provider with + * `fetch` so that none of this application's HTTP interceptors (which add tenant headers that a + * provider's CORS policy refuses) sees the traffic. It does not verify an ID token's signature: + * the token is received straight from the token endpoint named by the provider's discovery + * document, which OpenID Connect Core section 3.1.3.7 accepts. Fineract validates the access + * token on every call. + * + * Tokens live in tab-scoped storage and are cleared by `clearScope('session')` on sign-out. + */ +@Injectable({ providedIn: 'root' }) +export class OidcService { + private readonly configService = inject(ConfigService); + private readonly storage = inject(STORAGE); + + /** Emits when the provider refused a refresh: the session is over and the user must sign in again. */ + readonly sessionEnded = new Subject<void>(); + + private discovery: Promise<DiscoveryDocument> | null = null; + private discoveryFor: string | null = null; + private refreshing: Promise<void> | null = null; + private timer: ReturnType<typeof setTimeout> | null = null; + /** Bumped by every {@link clear}; a request that started in an earlier generation is dropped. */ + private generation = 0; + + constructor() { + if (this.hasSession()) this.scheduleRefresh(); + } + + /** The configured provider, or `null` when OIDC sign-in is not configured. */ + get config(): OidcConfig | null { + return this.configService.oidcConfig(); + } + + /** Whether this tab holds OIDC tokens. */ + hasSession(): boolean { + return !!this.tokens()?.accessToken; + } + + /** The current access token, or `null`. Use {@link ensureFresh} first when it must not be about to expire. */ + getAccessToken(): string | null { + return this.tokens()?.accessToken ?? null; + } + + /** Redirects the browser to the provider's sign-in page. */ + async startLogin(): Promise<void> { + const cfg = this.requireConfig(); + const doc = await this.discover(); + const flow: OidcFlow = { + state: randomString(), + nonce: randomString(), + verifier: randomString(48), + }; + this.storage.write('oidcFlow', flow); + const query = new URLSearchParams({ + client_id: cfg.clientId, + redirect_uri: this.redirectUri(), + response_type: 'code', + scope: this.scopes(), + state: flow.state, + nonce: flow.nonce, + code_challenge: await pkceChallenge(flow.verifier), + code_challenge_method: 'S256', + }); + this.navigate(`${doc.authorization_endpoint}?${query.toString()}`); + } + + /** + * Finishes a sign-in: checks `state`, exchanges the code (with the PKCE verifier) for tokens, + * checks the ID token's `nonce`, issuer and audience, and stores the tokens. + * + * @param params - the query parameters of the redirect back to this application + */ + async completeLogin(params: URLSearchParams): Promise<void> { + const cfg = this.requireConfig(); + const flow = this.storage.read<OidcFlow | null>('oidcFlow', null); + // One attempt per flow: a replayed redirect finds nothing to check against. + this.storage.remove('oidcFlow'); + const providerError = params.get('error'); + if (providerError) throw new OidcError(`provider returned ${providerError}`, providerError); + const code = params.get('code'); + if (!flow || !code || params.get('state') !== flow.state) { + throw new OidcError('missing or mismatched state', 'invalid_state'); + } + // Read before the first await: a sign-out during discovery must count. + const generation = this.generation; + const doc = await this.discover(); + if (generation !== this.generation) + throw new OidcError('signed out during sign-in', 'cancelled'); + const body = await this.tokenRequest(doc, { + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri(), + client_id: cfg.clientId, + code_verifier: flow.verifier, + }); + this.validateIdToken(body.id_token, flow.nonce, doc.issuer, cfg.clientId); + // A sign-out while the code was being exchanged wins: do not open a session after it. + if (generation !== this.generation) + throw new OidcError('signed out during sign-in', 'cancelled'); + this.store(body, undefined); + } + + /** + * Checks the ID token of a code exchange (OpenID Connect Core 3.1.3.7). It is required: it is what + * `endSession` later sends as `id_token_hint`, without which the provider asks for confirmation + * instead of ending the session. + * + * The signature is deliberately not verified: the token arrives in the back-channel response of + * the token endpoint named by the provider's discovery document, over TLS (rule 6 of 3.1.3.7), + * and this application takes nothing from it but these claims. Fineract validates the access + * token, signature included, on every call. + */ + private validateIdToken( + idToken: string | undefined, + nonce: string, + issuer: string, + clientId: string, + ): void { + if (!idToken) throw new OidcError('the token response has no ID token', 'invalid_id_token'); + const claims = jwtClaims(idToken); + const aud = ([] as unknown[]).concat(claims['aud']); + const now = Date.now() / 1000; + const exp = Number(claims['exp']); + const iat = Number(claims['iat']); + const failed = ( + [ + ['nonce', claims['nonce'] === nonce], + ['issuer', claims['iss'] === issuer], + ['audience', aud.includes(clientId)], + ['authorized party', claims['azp'] === undefined || claims['azp'] === clientId], + ['expiry', Number.isFinite(exp) && exp + CLOCK_SKEW_S >= now], + ['issue time', !Number.isFinite(iat) || iat - CLOCK_SKEW_S <= now], + ] as const + ).find(([, ok]) => !ok); + const problem = failed?.[0]; + if (problem) throw new OidcError(`ID token ${problem} check failed`, 'invalid_id_token'); + } + + /** The ID token of the signed-in user, or `null`. Read it before clearing the session: sign-out needs it. */ + getIdToken(): string | null { + return this.tokens()?.idToken ?? null; + } + + /** + * Makes sure the access token is valid for at least {@link REFRESH_SKEW_MS}, refreshing it if + * not. Concurrent callers share one refresh. A refusal by the provider ends the session; a + * network failure leaves it as it is and rejects, so the request still goes out and Fineract + * decides. + */ + ensureFresh(): Promise<void> { + const t = this.tokens(); + if (!t || t.expiresAt - Date.now() > REFRESH_SKEW_MS) return Promise.resolve(); + this.refreshing ??= this.refresh().finally(() => (this.refreshing = null)); + return this.refreshing; + } + + /** + * Signs out at the provider (OpenID Connect RP-Initiated Logout 1.0) and comes back to the + * login page. The local tokens are dropped first, so the application is signed out even if the + * provider cannot be reached. + * + * @param idTokenHint - the user's ID token, read by the caller before the session was cleared. + * Always sent as `id_token_hint` together with `post_logout_redirect_uri`: without the hint + * Keycloak shows a confirmation page, and a user who closes it leaves the SSO session alive. + * Defaults to the stored token, for a caller that has not cleared anything yet. + */ + async endSession(idTokenHint?: string | null): Promise<void> { + const cfg = this.config; + const idToken = idTokenHint ?? this.getIdToken(); + this.clear(); + if (!cfg) return; + try { + const doc = await this.discover(); + if (!doc.end_session_endpoint) return; + const query = new URLSearchParams({ + client_id: cfg.clientId, + post_logout_redirect_uri: this.origin() + (cfg.postLogoutPath ?? DEFAULT_POST_LOGOUT_PATH), + }); + if (idToken) query.set('id_token_hint', idToken); + this.navigate(`${doc.end_session_endpoint}?${query.toString()}`); + } catch { + // The provider is unreachable: the local sign-out above already happened. + } + } + + /** Drops the tokens and any pending timer, without telling the provider. */ + clear(): void { + // Anything in flight (a refresh, a code exchange) started before this and must not store its result. + this.generation++; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.storage.remove('oidcTokens'); + this.storage.remove('oidcFlow'); + } + + /** Seam for tests: the only place that leaves the application. */ + protected navigate(url: string): void { + window.location.assign(url); + } + + protected origin(): string { + return window.location.origin; + } + + private redirectUri(): string { + return this.origin() + (this.requireConfig().redirectPath ?? DEFAULT_REDIRECT_PATH); + } + + /** The requested scopes. `offline_access` is dropped: it would make the refresh token outlive sign-out. */ + private scopes(): string { + const asked = (this.requireConfig().scopes ?? DEFAULT_SCOPES).split(/\s+/).filter(Boolean); + return asked.filter((s) => s !== 'offline_access').join(' '); + } + + private requireConfig(): OidcConfig { + const cfg = this.config; + if (!cfg) throw new OidcError('OIDC sign-in is not configured', 'not_configured'); + return cfg; + } + + private tokens(): OidcTokens | null { + return this.storage.read<OidcTokens | null>('oidcTokens', null); + } + + private async refresh(): Promise<void> { + // Read before the first await: a sign-out during discovery must count. + const generation = this.generation; + const t = this.tokens(); + const cfg = this.config; + if (!t || !cfg) return; + if (!t.refreshToken || (t.refreshExpiresAt && t.refreshExpiresAt <= Date.now())) { + this.endLocally(); + return; + } + const doc = await this.discover(); + if (generation !== this.generation) return; + try { + const body = await this.tokenRequest(doc, { + grant_type: 'refresh_token', + refresh_token: t.refreshToken, + client_id: cfg.clientId, + }); + // Signed out while the refresh was in flight: drop the answer, or it would restore the session. + if (generation !== this.generation) return; + this.store(body, t); + } catch (e) { + if (generation !== this.generation) return; + // invalid_grant and friends: the provider session is gone. A network error is not. + if (e instanceof OidcError) this.endLocally(); + throw e; + } + } + + private endLocally(): void { + this.clear(); + this.sessionEnded.next(); + } + + private store(body: TokenResponse, previous: OidcTokens | undefined): void { + const now = Date.now(); + const tokens: OidcTokens = { + accessToken: body.access_token, + refreshToken: body.refresh_token ?? previous?.refreshToken, + idToken: body.id_token ?? previous?.idToken, + expiresAt: now + (body.expires_in ?? 60) * 1000, + refreshExpiresAt: body.refresh_expires_in + ? now + body.refresh_expires_in * 1000 + : previous?.refreshExpiresAt, + }; + this.storage.write('oidcTokens', tokens); + this.scheduleRefresh(); + } + + private scheduleRefresh(delayOverride?: number): void { + if (this.timer) clearTimeout(this.timer); + const t = this.tokens(); + if (!t) return; + const delay = delayOverride ?? Math.max(t.expiresAt - Date.now() - REFRESH_SKEW_MS, 1000); + this.timer = setTimeout(() => { + // A refresh that failed on the network is tried again shortly, while the token is still good. + this.ensureFresh().catch(() => { + if (this.hasSession()) this.scheduleRefresh(10_000); + }); + }, delay); + } + + private async tokenRequest( + doc: DiscoveryDocument, + form: Record<string, string>, + ): Promise<TokenResponse> { + const response = await fetch(doc.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: new URLSearchParams(form).toString(), + credentials: 'omit', + }); + const json = (await response.json().catch(() => ({}))) as Partial<TokenResponse> & { + error?: string; + }; + if (!response.ok || !json.access_token) { + throw new OidcError( + `token endpoint answered ${response.status}`, + json.error ?? 'token_error', + ); + } + return json as TokenResponse; + } + + /** Reads the provider's discovery document once per issuer, and refuses endpoints on another origin. */ + private discover(): Promise<DiscoveryDocument> { + const issuer = this.requireConfig().issuer.replace(/\/+$/, ''); + if (!this.discovery || this.discoveryFor !== issuer) { + this.discoveryFor = issuer; + this.discovery = this.loadDiscovery(issuer).catch((e: unknown) => { + this.discovery = null; + throw e; + }); + } + return this.discovery; + } + + private async loadDiscovery(issuer: string): Promise<DiscoveryDocument> { + const response = await fetch(`${issuer}/.well-known/openid-configuration`, { + credentials: 'omit', + }); + if (!response.ok) throw new OidcError(`discovery answered ${response.status}`, 'discovery'); + const doc = (await response.json()) as DiscoveryDocument; + const origin = new URL(issuer).origin; + const sameOrigin = (u?: string): boolean => { + try { + return !u || new URL(u).origin === origin; + } catch { + return false; + } + }; + if ( + doc.issuer !== issuer || Review Comment: Interoperability: - **Trailing-slash issuers.** L425 strips trailing slashes from the configured issuer, then this compares it to `doc.issuer`, which must match exactly. Providers that publish a trailing slash, e.g. Auth0's `"issuer": "https://tenant.auth0.com/"`, can never pass. Please compare against the configured value as-is, and normalise only the URL used to build `.well-known`. - **Endpoint origins.** The same-origin requirement on every endpoint (L454) rules out providers whose token endpoint lives on another host (e.g. Google's `oauth2.googleapis.com`). Either allow an explicit `allowedEndpointOrigins`, or state the limitation in `OIDC.md`. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
