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]

Reply via email to