You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@fineract.apache.org by "guymoyo (via GitHub)" <gi...@apache.org> on 2026/10/19 12:46:35 UTC

[PR] feat(auth): sign in through an OpenID Connect identity provider [fineract-backoffice-ui]

guymoyo opened a new pull request, #818:
URL: https://github.com/apache/fineract-backoffice-ui/pull/818

   ## What and why
   
   Adds an opt-in "Sign in with your identity provider" path to the login page: authorization-code flow with PKCE (S256) as a public client, driven by an `oidc` block in `config.json` (`oidcLoginEnabled`, `oidc.issuer`, `oidc.clientId`, `oidc.scopes`, `passwordLoginEnabled`). Without those keys the login page is unchanged, and password login stays on unless a deployment turns it off (which is ignored unless OIDC is fully configured). No new dependency; `offline_access` is never requested.
   
   The UI loads the user context from `GET /v1/userdetails` with the Bearer token and maps it to the same session a Basic login produces, so menus, guards and `rbacEnabled` work unchanged. **Platform note:** in Fineract 1.15.0 that endpoint is only loaded with `fineract.security.oauth2.enabled`, so with OIDC federation (`fineract.security.oidc-federation.enabled`) alone it answers 404. Fineract needs a change to also load it under the federation flag; that upstream work is tracked separately (Jira to be filed). The UI does not fail open: on a 404 it drops the tokens, opens no session and shows a clear message on the callback page.
   
   `DOCS/OIDC.md` documents the config keys, the Keycloak client settings (public client, PKCE S256, exact redirect URIs, no `offline_access`, audience mapper) and the userdetails requirement.
   
   Closes #370
   
   ## Verification
   
   - `npm ci`, `npm run lint` (0 errors, no change to the suppressions baseline), `npm run test:unit` (312 files, 2546 tests), `npm run build`, `npm run i18n:check`, `check:icons`, `check:a11y-names`, `check:route-permissions` all pass.
   - Mocked Playwright (`--project=mocked e2e/login.spec.ts e2e/oidc-login.spec.ts`) passes. The OIDC spec covers the config matrix (absent, wrong-typed or incomplete config shows nothing new), password form hiding, the full round trip against a mocked provider (discovery, PKCE S256 redirect, code exchange, Bearer `/v1/userdetails`), the 404 case, and a mismatched `state`.
   - Not run here: the `backend` Playwright project (`oidc-login-backend.spec.ts` was updated for the new behaviour but needs Docker).
   - Verified by hand end to end against Fineract 1.15.0 + Keycloak 26, with the platform change described above applied.
   
   ## Screenshots
   
   Available on request.
   
   ## Checklist
   
   - [x] I did not hand-edit generated files under `src/app/api/`.
   - [x] New component or service code uses the adapter boundary in `src/app/core/adapters/` instead of direct browser globals or imperative third-party APIs.
   - [x] User-facing strings use translation keys.
   - [x] I added or updated tests appropriate to this change, or explained why tests were not needed.
   - [x] UI workflow changes include suitable e2e coverage, including real-backend testing where relevant.
   - [x] Commits are signed β€” see [Commit Signing](CONTRIBUTING.md#commit-signing) in CONTRIBUTING.md.
   - [ ] I followed the [AI-assisted contributions guidance](CONTRIBUTING.md#ai-assisted-contributions).
   - [ ] If this adds a feature or changes a workflow, I raised it on
         [dev@fineract.apache.org](https://lists.apache.org/list.html?dev@fineract.apache.org) first β€”
         or it is a bug fix, refactor or test change, where that does not apply.
   


-- 
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: issues-unsubscribe@fineract.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat(auth): sign in through an OpenID Connect identity provider [fineract-backoffice-ui]

Posted by "github-actions[bot] (via GitHub)" <gi...@apache.org>.
github-actions[bot] commented on PR #818:
URL: https://github.com/apache/fineract-backoffice-ui/pull/818#issuecomment-6273435395

   <!-- e2e-report-backend -->
   ### E2E β€” real Fineract
   ## 🎭 E2E Tests
   
   **βœ… All green** β€” 171 passed Β· 0 failed Β· 0 skipped, across 50 spec files in 10m 54s.
   
   ### By spec file
   
   | Spec | βœ… | ❌ | ⏭️ | πŸ”’ | Time |
   | --- | ---: | ---: | ---: | ---: | ---: |
   | backend.setup.ts | 3 | 0 | 0 | 0 | 6.1s |
   | accounting-closure-backend.spec.ts | 2 | 0 | 0 | 0 | 12.8s |
   | batch-api-operations.spec.ts | 5 | 0 | 0 | 0 | 28.1s |
   | center-servicing.spec.ts | 2 | 0 | 0 | 0 | 17.4s |
   | charge-servicing-backend.spec.ts | 6 | 0 | 0 | 0 | 30.3s |
   | client-legal-form-search.spec.ts | 1 | 0 | 0 | 0 | 5.3s |
   | client-transfer.spec.ts | 4 | 0 | 0 | 0 | 48.7s |
   | collection-sheet.spec.ts | 1 | 0 | 0 | 0 | 6.2s |
   | deposit-account-servicing.spec.ts | 3 | 0 | 0 | 0 | 25.9s |
   | deposit-lifecycle-backend.spec.ts | 4 | 0 | 0 | 0 | 23.6s |
   | deposit-product-configuration.spec.ts | 3 | 0 | 0 | 0 | 18.0s |
   | double-submit-backend.spec.ts | 2 | 0 | 0 | 0 | 12.3s |
   | form-layout-reflow.spec.ts | 6 | 0 | 0 | 0 | 35.0s |
   | group-membership.spec.ts | 3 | 0 | 0 | 0 | 48.1s |
   | loan-account-actions.spec.ts | 3 | 0 | 0 | 0 | 25.3s |
   | loan-approved-amount-revision.spec.ts | 3 | 0 | 0 | 0 | 18.0s |
   | loan-charge-off.spec.ts | 2 | 0 | 0 | 0 | 18.5s |
   | loan-flows-non-admin-roles.spec.ts | 8 | 0 | 0 | 0 | 1m 37s |
   | loan-product-accounting.spec.ts | 1 | 0 | 0 | 0 | 31.1s |
   | loan-repayment-gating.spec.ts | 2 | 0 | 0 | 0 | 8.8s |
   | loan-servicing-rbac-backend.spec.ts | 4 | 0 | 0 | 0 | 11.1s |
   | loan-servicing.spec.ts | 2 | 0 | 0 | 0 | 17.7s |
   | login.spec.ts | 4 | 0 | 0 | 0 | 5.2s |
   | oidc-login-backend.spec.ts | 2 | 0 | 0 | 0 | 3.9s |
   | parity-screens.spec.ts | 8 | 0 | 0 | 0 | 43.0s |
   | rbac-backend-restricted-user.spec.ts | 7 | 0 | 0 | 0 | 33.6s |
   | rbac-dead-end-controls.spec.ts | 7 | 0 | 0 | 0 | 32.2s |
   | rbac-disabled-flag-backend.spec.ts | 6 | 0 | 0 | 0 | 26.2s |
   | rbac-multi-permission.spec.ts | 9 | 0 | 0 | 0 | 54.8s |
   | rbac-office-scoped-user.spec.ts | 6 | 0 | 0 | 0 | 26.3s |
   | bulk-import-entity-types.spec.ts | 1 | 0 | 0 | 0 | 10.3s |
   | delinquency-configuration.spec.ts | 2 | 0 | 0 | 0 | 18.6s |
   | full-demo.spec.ts | 1 | 0 | 0 | 0 | 58.1s |
   | interest-rate-charts.spec.ts | 1 | 0 | 0 | 0 | 7.7s |
   | loan-lifecycle.spec.ts | 4 | 0 | 0 | 0 | 1m 60s |
   | loan-schedule-type.spec.ts | 3 | 0 | 0 | 0 | 32.6s |
   | rbac-permissionless-user.spec.ts | 4 | 0 | 0 | 0 | 21.0s |
   | report-mailing-job.spec.ts | 3 | 0 | 0 | 0 | 18.7s |
   | report-parameter-backend.spec.ts | 4 | 0 | 0 | 0 | 22.4s |
   | savings-flows-non-admin-roles.spec.ts | 7 | 0 | 0 | 0 | 1m 39s |
   | savings-transaction-correction.spec.ts | 1 | 0 | 0 | 0 | 6.1s |
   | savings-transaction-gating.spec.ts | 2 | 0 | 0 | 0 | 6.8s |
   | share-account-servicing.spec.ts | 2 | 0 | 0 | 0 | 17.8s |
   | share-product-accounting.spec.ts | 1 | 0 | 0 | 0 | 25.1s |
   | teller-cash-management.spec.ts | 2 | 0 | 0 | 0 | 38.0s |
   | tenant-date-defaults-backend.spec.ts | 1 | 0 | 0 | 0 | 7.0s |
   | working-capital-loan-actions-backend.spec.ts | 3 | 0 | 0 | 0 | 21.5s |
   | working-capital-product-accounting-backend.spec.ts | 2 | 0 | 0 | 0 | 21.3s |
   | working-capital-rbac-backend.spec.ts | 2 | 0 | 0 | 0 | 7.7s |
   | working-capital-servicing-backend.spec.ts | 6 | 0 | 0 | 0 | 36.4s |
   
   <details>
   <summary><b>All 171 tests</b> β€” click to expand</summary>
   
   **`backend.setup.ts`**
   
   - βœ… seed backend reference data β€” `2.0s`
   - βœ… seed backend reference data β€” `1.9s`
   - βœ… seed backend reference data β€” `2.2s`
   
   **`accounting-closure-backend.spec.ts`**
   
   - βœ… Accounting closures against Fineract β€Ί a closed period reads as Closed, and the platform refuses a posting inside it β€” `6.9s`
   - βœ… Accounting closures against Fineract β€Ί the form keeps the user on the page and names the reason when Fineract refuses β€” `5.8s`
   
   **`batch-api-operations.spec.ts`**
   
   - βœ… Batch API Operations against Fineract β€Ί runs the sample batch scenario β€” create client, create loan, add and read back a charge β€” `5.4s`
   - βœ… Batch API Operations against Fineract β€Ί shows a parse error instead of submitting when the batch input is not valid JSON β€” `8.0s`
   - βœ… Batch API enclosingTransaction semantics against Fineract β€Ί rolls back the earlier steps when enclosingTransaction is true and a later step fails β€” `5.0s`
   - βœ… Batch API enclosingTransaction semantics against Fineract β€Ί does not roll back the earlier steps when enclosingTransaction is false and a later step fails β€” `4.9s`
   - βœ… Batch API Operations on a mobile viewport against Fineract β€Ί the sample batch scenario is reachable and works by touch at mobile width β€” `4.9s`
   
   **`center-servicing.spec.ts`**
   
   - βœ… Center servicing β€Ί a center is activated, staffed and given a group β€” `12.3s`
   - βœ… Center servicing β€Ί notes are recorded against the center β€” `5.1s`
   
   **`charge-servicing-backend.spec.ts`**
   
   - βœ… loan charge servicing β€Ί waives a loan charge, and the platform records the waiver β€” `6.3s`
   - βœ… loan charge servicing β€Ί cannot reverse a waiver: the platform answers 500, not a business rule β€” `700ms`
   - βœ… loan charge servicing β€Ί offers no Pay on a charge the platform will not let be paid β€” `4.8s`
   - βœ… loan charge servicing β€Ί adjusts a loan charge to a smaller amount β€” `5.7s`
   - βœ… savings charge servicing β€Ί pays a savings charge with the amount and due date the platform demands β€” `6.4s`
   - βœ… savings charge servicing β€Ί carries an active flag, which is what gates Inactivate β€” `6.3s`
   
   **`client-legal-form-search.spec.ts`**
   
   - βœ… Client list: legal form filtering β€Ί an entity client appears in the main client list β€” `5.3s`
   
   **`client-transfer.spec.ts`**
   
   - βœ… Client transfer between offices β€Ί a proposed transfer is held until the destination accepts, and then the client moves β€” `12.6s`
   - βœ… Client transfer between offices β€Ί a rejected transfer leaves the client on hold, and withdrawing is the way back β€” `13.1s`
   - βœ… Client transfer between offices β€Ί a client can be transferred in one step when the user may act for both offices β€” `11.4s`
   - βœ… Client staff assignment β€Ί an officer can be assigned and then removed β€” `11.6s`
   
   **`collection-sheet.spec.ts`**
   
   - βœ… Collection Sheet β€Ί scopes staff to the office, sends the choice, and reports an empty sheet β€” `6.2s`
   
   **`deposit-account-servicing.spec.ts`**
   
   - βœ… Term deposit account servicing β€Ί an account is approved, activated and closed before maturity β€” `11.4s`
   - βœ… Term deposit account servicing β€Ί a deposit is recorded, listed, and reversed without leaving the list β€” `8.2s`
   - βœ… Term deposit account servicing β€Ί an application can be rejected instead of approved β€” `6.3s`
   
   **`deposit-lifecycle-backend.spec.ts`**
   
   - βœ… fixed deposit products β€Ί carry the rate the user typed, not a hidden default β€” `5.0s`
   - βœ… an active fixed deposit account β€Ί shows its balance and maturity figures β€” `5.5s`
   - βœ… an active fixed deposit account β€Ί can be closed into the client savings account β€” `7.1s`
   - βœ… an active fixed deposit account β€Ί can have a charge added to it β€” `6.1s`
   
   **`deposit-product-configuration.spec.ts`**
   
   - βœ… Deposit product configuration β€Ί a fixed deposit product survives being edited β€” `6.1s`
   - βœ… Deposit product configuration β€Ί a recurring deposit product can be created at all β€” `5.8s`
   - βœ… Deposit product configuration β€Ί a savings product carries its accounting configuration β€” `6.2s`
   
   **`double-submit-backend.spec.ts`**
   
   - βœ… A double-click submits once β€Ί a repayment is recorded once, not twice β€” `6.0s`
   - βœ… A double-click submits once β€Ί a client is created once, not twice β€” `6.2s`
   
   **`form-layout-reflow.spec.ts`**
   
   - βœ… form layout at the narrow viewport β€Ί Create GL Account gives every field a usable width at 320px β€” `5.3s`
   - βœ… form layout at the narrow viewport β€Ί Create Loan gives every field a usable width at 320px β€” `5.1s`
   - βœ… form layout at the narrow viewport β€Ί Create Office gives every field a usable width at 320px β€” `5.6s`
   - βœ… form layout at the narrow viewport β€Ί Create Staff gives every field a usable width at 320px β€” `4.9s`
   - βœ… form layout at the narrow viewport β€Ί no page-level horizontal scroll at 320px β€” `9.1s`
   - βœ… form layout at the wide viewport β€Ί Create GL Account still uses more than one column at 1280px β€” `5.0s`
   
   **`group-membership.spec.ts`**
   
   - βœ… Group membership and lifecycle β€Ί a group is activated, staffed, given members and a committee, then emptied β€” `20.4s`
   - βœ… Group membership and lifecycle β€Ί notes are recorded against the group and can be removed again β€” `8.5s`
   - βœ… Group membership and lifecycle β€Ί an empty group is closed with a reason, and a group with members is refused β€” `19.2s`
   
   **`loan-account-actions.spec.ts`**
   
   - βœ… Loan account lifecycle actions β€Ί new action menu items appear only for active loans β€” `7.3s`
   - βœ… Loan account lifecycle actions β€Ί undo disbursal shows a confirm dialog and reverts the loan to Approved β€” `9.5s`
   - βœ… Loan account lifecycle actions β€Ί write off requires confirmation and moves the loan out of Active status β€” `8.5s`
   
   **`loan-approved-amount-revision.spec.ts`**
   
   - βœ… Loan approved and available-disbursement amount revision β€Ί revises the approved amount on an active, partially-disbursed loan β€” `6.4s`
   - βœ… Loan approved and available-disbursement amount revision β€Ί revises the available disbursement amount, which also changes the approved amount β€” `6.0s`
   - βœ… Loan approved and available-disbursement amount revision β€Ί shows the platform’s own refusal, not a generic validation message β€” `5.6s`
   
   **`loan-charge-off.spec.ts`**
   
   - βœ… Loan servicing commands β€Ί charges a loan off through the UI and reverses it β€” `10.0s`
   - βœ… Loan servicing commands β€Ί records a goodwill credit through the shared transaction form β€” `8.5s`
   
   **`loan-flows-non-admin-roles.spec.ts`**
   
   - βœ… a loan taken through four separated duties, every account built in the UI β€Ί an administrator builds the four roles and accounts through the forms β€” `36.0s`
   - βœ… a loan taken through four separated duties, every account built in the UI β€Ί the officer books the application and is not offered approval of their own loan β€” `14.7s`
   - βœ… a loan taken through four separated duties, every account built in the UI β€Ί the approver, differing from the officer in one code, is offered it and approves β€” `5.4s`
   - βœ… a loan taken through four separated duties, every account built in the UI β€Ί the approver is not offered disbursement of the loan they just approved β€” `4.5s`
   - βœ… a loan taken through four separated duties, every account built in the UI β€Ί the disburser, holding only DISBURSE_LOAN, completes the payout end to end β€” `7.0s`
   - βœ… a loan taken through four separated duties, every account built in the UI β€Ί and the same route now refuses an UPDATE_LOAN holder, as the platform does β€” `7.3s`
   - βœ… a loan taken through four separated duties, every account built in the UI β€Ί a command with no mapped code is left to the platform rather than guessed at β€” `4.9s`
   - βœ… a loan taken through four separated duties, every account built in the UI β€Ί a repayment duty reaches its own form, and records a repayment β€” `16.7s`
   
   **`loan-product-accounting.spec.ts`**
   
   - βœ… Loan product accounting β€Ί a cash-accounting product is configured, round-trips on edit, and posts to the ledger β€” `31.1s`
   
   **`loan-repayment-gating.spec.ts`**
   
   - βœ… Repayment is offered only where the platform allows it β€Ί withheld on a loan awaiting approval, offered once it is active β€” `8.4s`
   - βœ… Repayment is offered only where the platform allows it β€Ί the platform refuses a repayment on a loan awaiting approval β€” `384ms`
   
   **`loan-servicing-rbac-backend.spec.ts`**
   
   - βœ… loan servicing and charge command permissions, on the platform β€Ί each command is authorised by the code the UI declares for it β€” `5.2s`
   - βœ… loan servicing and charge command permissions, on the platform β€Ί no portfolio write code authorises any of its entity commands β€” `5.6s`
   - βœ… loan servicing and charge command permissions, on the platform β€Ί the deferred-income adjustments are not commands at loan level β€” `160ms`
   - βœ… loan servicing and charge command permissions, on the platform β€Ί the deferred-income adjustments are commands on one transaction β€” `178ms`
   
   **`loan-servicing.spec.ts`**
   
   - βœ… Loan servicing: notes and transaction adjustment β€Ί notes can be added and removed, with a confirm dialog on delete β€” `7.7s`
   - βœ… Loan servicing: notes and transaction adjustment β€Ί a repayment transaction can be viewed and adjusted with a corrected amount β€” `10.1s`
   
   **`login.spec.ts`**
   
   - βœ… Login β€Ί login page displays correctly β€” `1.3s`
   - βœ… Login β€Ί login form has required fields β€” `1.3s`
   - βœ… Login β€Ί submit button is disabled when form is empty β€” `1.5s`
   - βœ… Login β€Ί submit button is enabled when form is filled β€” `1.1s`
   
   **`oidc-login-backend.spec.ts`**
   
   - βœ… identity-provider route against a real Fineract β€Ί is offered beside the password form, and an unreachable provider sends nothing to Fineract β€” `1.3s`
   - βœ… identity-provider route against a real Fineract β€Ί a real password sign-in still gets a session with the route offered β€” `2.6s`
   
   **`parity-screens.spec.ts`**
   
   - βœ… Screens added for platform parity β€Ί a manual journal entry can be read whole and reversed β€” `6.1s`
   - βœ… Screens added for platform parity β€Ί an entry that is already reversed is not offered again β€” `4.4s`
   - βœ… Screens added for platform parity β€Ί a report definition can be created, edited and deleted; a core one cannot β€” `5.4s`
   - βœ… Screens added for platform parity β€Ί a core report opens read-only with only its in-use setting β€” `4.1s`
   - βœ… Screens added for platform parity β€Ί a pending loan is approved from the queue, in a batch β€” `6.6s`
   - βœ… Screens added for platform parity β€Ί a fixed deposit is listed as a deposit, not as a savings account β€” `4.7s`
   - βœ… Screens added for platform parity β€Ί an office has a screen, and it carries its custom fields β€” `4.8s`
   - βœ… Screens added for platform parity β€Ί a savings account carries notes, and the note survives a reload β€” `6.9s`
   
   **`rbac-backend-restricted-user.spec.ts`**
   
   - βœ… a genuinely restricted Fineract user β€Ί holds exactly the permissions their role was granted β€” `260ms`
   - βœ… a genuinely restricted Fineract user β€Ί reaches the screen their permission covers β€” `4.8s`
   - βœ… a genuinely restricted Fineract user β€Ί is refused a screen their permission does not cover, by URL and by the backend β€” `4.5s`
   - βœ… a genuinely restricted Fineract user β€Ί is refused a write screen they can read the list for, and the write itself β€” `6.1s`
   - βœ… a genuinely restricted Fineract user β€Ί is not offered the actions it would be refused for β€” `4.0s`
   - βœ… a genuinely restricted Fineract user β€Ί is shown an action it cannot take, disabled and saying what it needs β€” `5.3s`
   - βœ… a genuinely restricted Fineract user β€Ί the superuser the rest of the suite uses is unaffected β€” `8.7s`
   
   **`rbac-dead-end-controls.spec.ts`**
   
   - βœ… a client's account tables β€Ί withhold the link, but not the number, from a user who cannot open the account β€” `4.9s`
   - βœ… a client's account tables β€Ί keep the link for a user who holds READ_LOAN β€” `5.4s`
   - βœ… the office transactions list β€Ί withholds Create from a reader, and the route refuses it too β€” `5.0s`
   - βœ… the office transactions list β€Ί offers Create to an administrator β€” `3.7s`
   - βœ… the office transactions list β€Ί refuses the list at the door for the code the platform does not check β€” `4.2s`
   - βœ… a group's member list β€Ί names the member without linking when the reader cannot open clients β€” `4.1s`
   - βœ… a group's member list β€Ί keeps the link for a reader who holds READ_CLIENT β€” `5.0s`
   
   **`rbac-disabled-flag-backend.spec.ts`**
   
   - βœ… rbacEnabled: false, with a user who holds only READ_CLIENT β€Ί is shown the whole navigation, including modules their role does not cover β€” `2.7s`
   - βœ… rbacEnabled: false, with a user who holds only READ_CLIENT β€Ί is admitted by the route guard to a screen their role does not cover β€” `7.5s`
   - βœ… rbacEnabled: false, with a user who holds only READ_CLIENT β€Ί is still refused by Fineract, which the flag does not reach β€” `630ms`
   - βœ… rbacEnabled: false, with a user who holds only READ_CLIENT β€Ί is told the list is beyond their role, and not offered a retry that cannot succeed β€” `4.7s`
   - βœ… rbacEnabled: false, with a user who holds only READ_CLIENT β€Ί gets the same treatment on a second refused list, not a one-screen fix β€” `5.3s`
   - βœ… rbacEnabled: false, with a user who holds only READ_CLIENT β€Ί still loads the list their role does cover, with no failure state at all β€” `5.2s`
   
   **`rbac-multi-permission.spec.ts`**
   
   - βœ… a route declaring more than one permission code (OR semantics) β€Ί is admitted by either declared code alone β€” `4.9s`
   - βœ… a route declaring more than one permission code (OR semantics) β€Ί is admitted by the other declared code alone β€” `4.2s`
   - βœ… a route declaring more than one permission code (OR semantics) β€Ί is refused when holding neither declared code, by the router and by the backend β€” `4.6s`
   - βœ… ALL_FUNCTIONS_READ, against the real Fineract permission catalogue β€Ί reaches read screens across modules it holds no specific code for β€” `8.8s`
   - βœ… ALL_FUNCTIONS_READ, against the real Fineract permission catalogue β€Ί is refused every write screen, and the writes themselves β€” `7.1s`
   - βœ… a restricted session across a real page reload β€Ί keeps the same permission boundary after reloading, not just after a fresh login β€” `9.6s`
   - βœ… a second real action-level gate, distinct from loan repayment β€Ί is shown the Approve action disabled and naming what it needs, refused by the backend too β€” `5.1s`
   - βœ… Security module writes (users, roles), against the real backend β€Ί reaches the list screens but is refused the write screens β€” `10.4s`
   - βœ… Security module writes (users, roles), against the real backend β€Ί is refused creating a user and modifying a role, by the backend itself β€” `237ms`
   
   **`rbac-office-scoped-user.spec.ts`**
   
   - βœ… an account scoped to a branch office β€Ί differs from a head-office account in office alone, not in permissions β€” `334ms`
   - βœ… an account scoped to a branch office β€Ί is shown only its own office in the client list, and told which office it is in β€” `4.9s`
   - βœ… an account scoped to a branch office β€Ί sees a shorter list than an identically-permissioned head-office account β€” `4.8s`
   - βœ… an account scoped to a branch office β€Ί is refused another office’s client by the platform, and told so once β€” `5.1s`
   - βœ… an account scoped to a branch office β€Ί can still open the record that is in its own office β€” `4.3s`
   - βœ… an account scoped to a branch office β€Ί is not offered an edit it would be refused for on the Offices screen β€” `6.8s`
   
   **`bulk-import-entity-types.spec.ts`**
   
   - βœ… every offered entity type is one the platform accepts β€” `10.3s`
   
   **`delinquency-configuration.spec.ts`**
   
   - βœ… creates a delinquency range, then edits it β€” `9.3s`
   - βœ… creates a delinquency bucket from the ranges the template offers β€” `9.3s`
   
   **`full-demo.spec.ts`**
   
   - βœ… Full feature demo recording β€Ί walk through loan schedule type, lifecycle, custom fields, collateral, and disbursement β€” `58.1s`
   
   **`interest-rate-charts.spec.ts`**
   
   - βœ… lists charts without a server error β€” `7.7s`
   
   **`loan-lifecycle.spec.ts`**
   
   - βœ… Loan lifecycle: creation, approval, disbursement β€Ί create, approve, and disburse a Cumulative loan β€” `31.4s`
   - βœ… Loan lifecycle: creation, approval, disbursement β€Ί create, approve, and disburse a Progressive loan β€” `31.5s`
   - βœ… Loan lifecycle: creation, approval, disbursement β€Ί an approved loan can be returned to pending approval β€” `28.9s`
   - βœ… Loan lifecycle: creation, approval, disbursement β€Ί the delinquency tab reads a real loan, and the empty data tabs stay hidden β€” `27.7s`
   
   **`loan-schedule-type.spec.ts`**
   
   - βœ… Loan Schedule Type (Cumulative vs Progressive) β€Ί loan products list shows a schedule type chip per product β€” `9.1s`
   - βœ… Loan Schedule Type (Cumulative vs Progressive) β€Ί create a Progressive loan product end-to-end and verify it round-trips β€” `13.4s`
   - βœ… Loan Schedule Type (Cumulative vs Progressive) β€Ί loan creation shows the schedule type badge for a Progressive product β€” `10.2s`
   
   **`rbac-permissionless-user.spec.ts`**
   
   - βœ… an account whose role grants nothing β€Ί is authenticated by Fineract and authorized by it for nothing β€” `770ms`
   - βœ… an account whose role grants nothing β€Ί is signed in and landed on a screen rather than stranded at the login form β€” `4.6s`
   - βœ… an account whose role grants nothing β€Ί is offered no permission-gated module, and refused each one by URL and by Fineract β€” `10.5s`
   - βœ… an account whose role grants nothing β€Ί is left a navigation whose entries actually work β€” `5.2s`
   
   **`report-mailing-job.spec.ts`**
   
   - βœ… report mailing job creation β€Ί is no longer refused for a missing parameter β€” `5.6s`
   - βœ… report mailing job creation β€Ί the payload carries the locale and the schedule β€” `5.6s`
   - βœ… report mailing job creation at the narrow viewport β€Ί every field is reachable and the job still saves at 320px β€” `7.6s`
   
   **`report-parameter-backend.spec.ts`**
   
   - βœ… Dynamic report parameters against Fineract β€Ί keeps the parameter form available when a report has cascading lookups β€” `4.4s`
   - βœ… Dynamic report parameters against Fineract β€Ί changing Office changes the Client Listing row set β€” `6.0s`
   - βœ… Cascading report parameters against Fineract β€Ί sends the parent value to the child lookup and clears the child when it changes β€” `7.2s`
   - βœ… Chart reports against Fineract β€Ί renders a chart report as a chart rather than a table β€” `4.7s`
   
   **`savings-flows-non-admin-roles.spec.ts`**
   
   - βœ… a savings account taken through four separated duties, every account built in the UI β€Ί an administrator builds the five roles and accounts through the forms β€” `48.8s`
   - βœ… a savings account taken through four separated duties, every account built in the UI β€Ί the officer books the application and is not offered approval of it β€” `8.6s`
   - βœ… a savings account taken through four separated duties, every account built in the UI β€Ί the approver, differing in one code, is offered it and approves β€” `4.5s`
   - βœ… a savings account taken through four separated duties, every account built in the UI β€Ί the activator activates, and cannot take a deposit β€” `5.7s`
   - βœ… a savings account taken through four separated duties, every account built in the UI β€Ί the teller, holding only the transaction codes, records a deposit end to end β€” `5.9s`
   - βœ… a savings account taken through four separated duties, every account built in the UI β€Ί the cash-withdrawal control asks for the cash code, not the application one β€” `19.0s`
   - βœ… a savings account taken through four separated duties, every account built in the UI β€Ί the transaction route refuses an UPDATE_SAVINGSACCOUNT holder, as the platform does β€” `6.6s`
   
   **`savings-transaction-correction.spec.ts`**
   
   - βœ… Savings transaction correction β€Ί a deposit is reversed and a hold is released β€” `6.1s`
   
   **`savings-transaction-gating.spec.ts`**
   
   - βœ… Savings transactions are offered only on an active account β€Ί withheld while awaiting approval, offered once active β€” `6.6s`
   - βœ… Savings transactions are offered only on an active account β€Ί the platform refuses a deposit on an account awaiting approval β€” `204ms`
   
   **`share-account-servicing.spec.ts`**
   
   - βœ… Share account servicing β€Ί an account is approved, activated, traded and closed β€” `12.0s`
   - βœ… Share account servicing β€Ί an application can be rejected β€” `5.9s`
   
   **`share-product-accounting.spec.ts`**
   
   - βœ… Share product accounting β€Ί a share product is mapped to equity and round-trips on edit β€” `25.1s`
   
   **`teller-cash-management.spec.ts`**
   
   - βœ… Teller cash management β€Ί a cashier is listed, receives an allocation, and settles cash back β€” `26.6s`
   - βœ… Teller cash management β€Ί settling more than the cashier holds is refused and the form stays usable β€” `11.4s`
   
   **`tenant-date-defaults-backend.spec.ts`**
   
   - βœ… Date defaults come from the platform, not the browser β€Ί a client can be created while the browser is a day ahead β€” `7.0s`
   
   **`working-capital-loan-actions-backend.spec.ts`**
   
   - βœ… Working Capital loan actions, against a real Fineract β€Ί charges off an active loan, and then offers only the undo β€” `8.1s`
   - βœ… Working Capital loan actions, against a real Fineract β€Ί credits the borrower with a goodwill credit, and the balance falls β€” `5.9s`
   - βœ… Working Capital loan actions, against a real Fineract β€Ί writes the loan off, which closes it and opens the recovery path β€” `7.4s`
   
   **`working-capital-product-accounting-backend.spec.ts`**
   
   - βœ… Working Capital product accounting, on the platform β€Ί configures the accrual rule and its twelve accounts, and the platform accepts it β€” `15.5s`
   - βœ… Working Capital product accounting, on the platform β€Ί prefills a configured product and re-saves it without blanking anything β€” `5.8s`
   
   **`working-capital-rbac-backend.spec.ts`**
   
   - βœ… Working Capital command permissions, on the platform β€Ί each command is authorised by the code the UI declares for it β€” `5.5s`
   - βœ… Working Capital command permissions, on the platform β€Ί UPDATE_WORKINGCAPITALLOAN authorises none of them β€” `2.3s`
   
   **`working-capital-servicing-backend.spec.ts`**
   
   - βœ… transaction corrections β€Ί undoes a repayment, and the platform marks it reversed β€” `5.5s`
   - βœ… transaction corrections β€Ί offers no correction on the disbursement, which the platform refuses by type β€” `5.6s`
   - βœ… transaction corrections β€Ί adjusts a repayment, which reverses it and books the new amount β€” `5.2s`
   - βœ… charge adjustment β€Ί adjusts a charge, and the outstanding amount follows β€” `5.2s`
   - βœ… custom datatables β€Ί adds, edits and deletes a row of a multi-row table on the loan β€” `7.2s`
   - βœ… custom datatables β€Ί creates a loan that a create-time check would otherwise refuse β€” `7.7s`
   
   </details>
   
   <details>
   <summary><b>Slowest 10</b> β€” what the shard counts should be tuned against</summary>
   
   | Test | Spec | Time |
   | --- | --- | ---: |
   | Full feature demo recording β€Ί walk through loan schedule type, lifecycle, custom fields, collateral, and disbursement | `full-demo.spec.ts` | 58.1s |
   | a savings account taken through four separated duties, every account built in the UI β€Ί an administrator builds the five roles and accounts through the forms | `savings-flows-non-admin-roles.spec.ts` | 48.8s |
   | a loan taken through four separated duties, every account built in the UI β€Ί an administrator builds the four roles and accounts through the forms | `loan-flows-non-admin-roles.spec.ts` | 36.0s |
   | Loan lifecycle: creation, approval, disbursement β€Ί create, approve, and disburse a Progressive loan | `loan-lifecycle.spec.ts` | 31.5s |
   | Loan lifecycle: creation, approval, disbursement β€Ί create, approve, and disburse a Cumulative loan | `loan-lifecycle.spec.ts` | 31.4s |
   | Loan product accounting β€Ί a cash-accounting product is configured, round-trips on edit, and posts to the ledger | `loan-product-accounting.spec.ts` | 31.1s |
   | Loan lifecycle: creation, approval, disbursement β€Ί an approved loan can be returned to pending approval | `loan-lifecycle.spec.ts` | 28.9s |
   | Loan lifecycle: creation, approval, disbursement β€Ί the delinquency tab reads a real loan, and the empty data tabs stay hidden | `loan-lifecycle.spec.ts` | 27.7s |
   | Teller cash management β€Ί a cashier is listed, receives an allocation, and settles cash back | `teller-cash-management.spec.ts` | 26.6s |
   | Share product accounting β€Ί a share product is mapped to equity and round-trips on edit | `share-product-accounting.spec.ts` | 25.1s |
   
   </details>
   
   
   πŸ“Ό [Download the HTML report, videos and traces](https://github.com/apache/fineract-backoffice-ui/actions/runs/39254700392) β€” see the `playwright-report-backend` artifact.
   
   <sub>Generated by [run 39254700392](https://github.com/apache/fineract-backoffice-ui/actions/runs/39254700392) from `71fd35a`. The run executed a fork branch, so treat its contents as unverified.</sub>


-- 
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: issues-unsubscribe@fineract.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat(auth): sign in through an OpenID Connect identity provider [fineract-backoffice-ui]

Posted by "github-actions[bot] (via GitHub)" <gi...@apache.org>.
github-actions[bot] commented on PR #818:
URL: https://github.com/apache/fineract-backoffice-ui/pull/818#issuecomment-6277809411

   <!-- pr-sequence-diagram -->
   ### πŸ”„ What this change talks to
   
   From the 9 application TypeScript file(s) changed in this pull request.
   
   ```mermaid
   sequenceDiagram
     autonumber
     participant AuthService as AuthService (Service)
     participant STORAGE as STORAGE (Injected)
     participant ConfigService as ConfigService (Service)
     participant Injector as Injector (Injected)
     participant OidcService as OidcService (Injected)
     participant HttpClient as HttpClient (Injected)
     participant OidcError as OidcError (Service)
     participant LoginComponent as LoginComponent (Component)
     participant BrandingService as BrandingService (Injected)
     participant ThemeService as ThemeService (Injected)
     participant FormBuilder as FormBuilder (Injected)
     participant I18N as I18N (Injected)
     AuthService->>STORAGE: readRaw()
     AuthService->>ConfigService: config()
     AuthService->>Injector: get()
     AuthService->>OidcService: getAccessToken()
     AuthService->>OidcService: clear()
     AuthService->>OidcService: hasSession()
     AuthService->>OidcService: getIdToken()
     AuthService->>OidcService: endSession()
     AuthService->>HttpClient: post()
     AuthService->>STORAGE: clearScope()
     AuthService->>STORAGE: writeRaw()
     AuthService->>STORAGE: write()
     ConfigService->>STORAGE: write()
     OidcError->>ConfigService: oidcConfig()
     OidcError->>STORAGE: write()
     OidcError->>STORAGE: remove()
     LoginComponent->>BrandingService: appName()
     LoginComponent->>ThemeService: isDarkMode()
     LoginComponent->>BrandingService: logoDarkUrl()
     LoginComponent->>BrandingService: logoUrl()
     LoginComponent->>BrandingService: resolveLogo()
     LoginComponent->>ConfigService: config()
     LoginComponent->>FormBuilder: group()
     LoginComponent->>AuthService: currentTenantId()
     LoginComponent->>I18N: use()
     LoginComponent->>OidcService: startLogin()
     LoginComponent->>I18N: translate()
     LoginComponent->>ConfigService: setApiUrl()
     LoginComponent->>AuthService: login()
     LoginComponent->>AuthService: twoFactorPending()
   ```
   
   _Truncated for readability: 2 further participant(s) not shown._
   
   > Static analysis of `inject()` fields and the calls made on them β€” not an execution trace. It cannot see dynamic dispatch, and generated API clients under `src/app/api` are collapsed into the single `Fineract` participant.
   
   
   
   <sub>Generated by [run 39299377263](https://github.com/apache/fineract-backoffice-ui/actions/runs/39299377263) from `6e4bcc7`. The run executed a fork branch, so treat its contents as unverified.</sub>


-- 
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: issues-unsubscribe@fineract.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat(auth): sign in through an OpenID Connect identity provider [fineract-backoffice-ui]

Posted by "github-actions[bot] (via GitHub)" <gi...@apache.org>.
github-actions[bot] commented on PR #818:
URL: https://github.com/apache/fineract-backoffice-ui/pull/818#issuecomment-6273434906

   <!-- e2e-report-mocked -->
   ### E2E β€” mocked backend
   ## 🎭 E2E Tests
   
   **βœ… All green** β€” 431 passed Β· 0 failed Β· 1 skipped, across 40 spec files in 9m 55s.
   
   ### By spec file
   
   | Spec | βœ… | ❌ | ⏭️ | πŸ”’ | Time |
   | --- | ---: | ---: | ---: | ---: | ---: |
   | accessibility.spec.ts | 5 | 0 | 0 | 0 | 14.6s |
   | all-functions-read-shortcut.spec.ts | 5 | 0 | 0 | 0 | 7.4s |
   | branded-deployment.spec.ts | 9 | 0 | 0 | 0 | 13.1s |
   | breadcrumb.spec.ts | 2 | 0 | 0 | 0 | 3.9s |
   | client-date-picker-revisit.spec.ts | 1 | 0 | 0 | 0 | 6.2s |
   | client-servicing-gaps.spec.ts | 14 | 0 | 0 | 0 | 43.3s |
   | client.spec.ts | 1 | 0 | 0 | 0 | 3.0s |
   | collateral-currency-search.spec.ts | 5 | 0 | 0 | 0 | 13.1s |
   | date-picker-revisit.spec.ts | 6 | 0 | 0 | 0 | 18.2s |
   | deployment-customization.spec.ts | 19 | 0 | 0 | 0 | 26.4s |
   | deposit-product-edit.spec.ts | 2 | 0 | 0 | 0 | 4.6s |
   | e2e-journey.spec.ts | 81 | 0 | 0 | 0 | 2m 39s |
   | feature-coverage.spec.ts | 49 | 0 | 0 | 0 | 1m 55s |
   | functional-coverage.spec.ts | 42 | 0 | 0 | 0 | 2m 5s |
   | global-search-nav-shortcuts.spec.ts | 4 | 0 | 0 | 0 | 10.6s |
   | global-search-navigation.spec.ts | 8 | 0 | 0 | 0 | 22.3s |
   | group-detail.spec.ts | 8 | 0 | 0 | 0 | 35.4s |
   | guidance-tour.spec.ts | 5 | 0 | 1 | 0 | 14.9s |
   | keyboard-shortcuts.spec.ts | 5 | 0 | 0 | 0 | 11.6s |
   | list-load-failure.spec.ts | 5 | 0 | 0 | 0 | 18.8s |
   | list-pagination.spec.ts | 17 | 0 | 0 | 0 | 1m 11s |
   | loan-delinquency-actions.spec.ts | 11 | 0 | 0 | 0 | 45.9s |
   | loan-portfolio-summary.spec.ts | 4 | 0 | 0 | 0 | 15.8s |
   | loan-product-down-payment.spec.ts | 13 | 0 | 0 | 0 | 1m 1s |
   | loan-schedule-type-gating.spec.ts | 6 | 0 | 0 | 0 | 22.4s |
   | charge-refund.spec.ts | 1 | 0 | 0 | 0 | 6.2s |
   | client-accounts.spec.ts | 2 | 0 | 0 | 0 | 9.9s |
   | client-load-error.spec.ts | 2 | 0 | 0 | 0 | 7.4s |
   | contract-termination.spec.ts | 1 | 0 | 0 | 0 | 8.2s |
   | loan-servicing-gaps.spec.ts | 18 | 0 | 0 | 0 | 1m 8s |
   | logout-reason-notice.spec.ts | 4 | 0 | 0 | 0 | 3.0s |
   | oidc-login.spec.ts | 12 | 0 | 0 | 0 | 21.4s |
   | rbac-feature-flag.spec.ts | 11 | 0 | 0 | 0 | 20.8s |
   | rbac-route-protection.spec.ts | 18 | 0 | 0 | 0 | 1m 47s |
   | report-enhancements.spec.ts | 2 | 0 | 0 | 0 | 7.8s |
   | reporting.spec.ts | 2 | 0 | 0 | 0 | 4.4s |
   | scheduler-jobs.spec.ts | 3 | 0 | 0 | 0 | 12.2s |
   | sidebar-active-state.spec.ts | 1 | 0 | 0 | 0 | 1m 27s |
   | two-factor-authentication.spec.ts | 10 | 0 | 0 | 0 | 20.2s |
   | working-capital-loan-actions.spec.ts | 17 | 0 | 0 | 0 | 1m 16s |
   
   <details>
   <summary><b>All 432 tests</b> β€” click to expand</summary>
   
   **`accessibility.spec.ts`**
   
   - βœ… Runtime accessibility β€Ί client list has no blocking violations β€” `2.7s`
   - βœ… Runtime accessibility β€Ί client list links meet color contrast in dark mode β€” `2.7s`
   - βœ… Runtime accessibility β€Ί client form has no blocking violations β€” `3.2s`
   - βœ… Runtime accessibility β€Ί banner and dashboard content have no blocking violations β€” `2.7s`
   - βœ… Runtime accessibility β€Ί create office dialog has no blocking violations β€” `3.3s`
   
   **`all-functions-read-shortcut.spec.ts`**
   
   - βœ… ALL_FUNCTIONS_READ shortcut β€Ί grants read access to the Clients list without any explicit READ_CLIENT permission β€” `1.9s`
   - βœ… ALL_FUNCTIONS_READ shortcut β€Ί does not grant the Create Client action β€” `1.4s`
   - βœ… ALL_FUNCTIONS_READ shortcut β€Ί does not grant Create Client even combined with an explicit READ_CLIENT permission β€” `1.4s`
   - βœ… ALL_FUNCTIONS_READ shortcut β€Ί a user with the real CREATE_CLIENT permission still sees the button β€” `1.2s`
   - βœ… ALL_FUNCTIONS_READ shortcut β€Ί ALL_FUNCTIONS (full superuser) still sees the Create Client button β€” `1.4s`
   
   **`branded-deployment.spec.ts`**
   
   - βœ… a fully branded deployment β€Ί wears the institution’s name and mark, not Fineract’s β€” `1.4s`
   - βœ… a fully branded deployment β€Ί carries the institution’s palette into its own chrome and into Ionic β€” `1.4s`
   - βœ… a fully branded deployment β€Ί drops the sections the institution does not run β€” `1.3s`
   - βœ… a fully branded deployment β€Ί renames and reorders entries to the institution’s vocabulary β€” `1.4s`
   - βœ… a fully branded deployment β€Ί offers the institution’s own systems beside Fineract’s screens β€” `1.3s`
   - βœ… a fully branded deployment β€Ί restates the labels the institution words differently β€” `1.3s`
   - βœ… a fully branded deployment β€Ί keeps a readable palette in dark mode, with a label colour to match β€” `1.3s`
   - βœ… a fully branded deployment β€Ί does not leak a light colour into the dark palette β€” `1.4s`
   - βœ… a fully branded deployment β€Ί reports no configuration defects for the published example β€” `2.3s`
   
   **`breadcrumb.spec.ts`**
   
   - βœ… a single-crumb page shows no breadcrumb trail β€” `1.5s`
   - βœ… a nested page shows a trail, and the ancestor crumb navigates back β€” `2.4s`
   
   **`client-date-picker-revisit.spec.ts`**
   
   - βœ… Client form date pickers β€Ί stay usable on a second visit to the create form β€” `6.2s`
   
   **`client-servicing-gaps.spec.ts`**
   
   - βœ… Client transfers β€Ί proposing a transfer sends the destination, the date and the format that parses it β€” `3.5s`
   - βœ… Client transfers β€Ί a one-step transfer omits the date the platform refuses β€” `3.3s`
   - βœ… Client transfers β€Ί the client’s own office is not offered as a destination β€” `3.3s`
   - βœ… Client transfers β€Ί accepting a transfer sends a note and nothing else β€” `3.0s`
   - βœ… Client transfers β€Ί accepting with no note sends an empty body β€” `3.0s`
   - βœ… Client transfers β€Ί rejecting a transfer is offered while one is pending β€” `3.5s`
   - βœ… Client transfers β€Ί withdrawing is offered from on hold, which is the only way back β€” `3.0s`
   - βœ… Client transfers β€Ί answers to a transfer are not offered when none is pending β€” `2.3s`
   - βœ… Client staff and default savings account β€Ί assigning a member of staff sends the staff id β€” `3.7s`
   - βœ… Client staff and default savings account β€Ί unassigning echoes the current holder back, which the platform requires β€” `3.1s`
   - βœ… Client staff and default savings account β€Ί unassigning is not offered when nobody is assigned β€” `2.2s`
   - βœ… Client staff and default savings account β€Ί the assigned officer is shown on the header β€” `2.5s`
   - βœ… Client staff and default savings account β€Ί setting the default savings account sends the account id β€” `3.6s`
   - βœ… Undoing a rejection β€Ί posts the command name the platform recognises, not the menu identifier β€” `3.3s`
   
   **`client.spec.ts`**
   
   - βœ… Client Management β€Ί should create a new client β€” `3.0s`
   
   **`collateral-currency-search.spec.ts`**
   
   - βœ… Collateral product currency β€” searchable select (#​626) β€Ί the field is closed with no option list until it is opened β€” `2.5s`
   - βœ… Collateral product currency β€” searchable select (#​626) β€Ί typing narrows a long, alphabetical list to the matching currency β€” `2.9s`
   - βœ… Collateral product currency β€” searchable select (#​626) β€Ί matches by currency code as well as by name β€” `2.6s`
   - βœ… Collateral product currency β€” searchable select (#​626) β€Ί shows a "no results" message rather than an empty panel β€” `2.3s`
   - βœ… Collateral product currency β€” searchable select (#​626) β€Ί Cancel closes the picker without changing the selection β€” `2.9s`
   
   **`date-picker-revisit.spec.ts`**
   
   - βœ… Date pickers on revisited forms β€Ί office form keeps its pickers usable on every visit β€” `3.4s`
   - βœ… Date pickers on revisited forms β€Ί staff form keeps its pickers usable on every visit β€” `3.1s`
   - βœ… Date pickers on revisited forms β€Ί holiday form keeps its pickers usable on every visit β€” `3.0s`
   - βœ… Date pickers on revisited forms β€Ί teller form keeps its pickers usable on every visit β€” `2.8s`
   - βœ… Date pickers on revisited forms β€Ί accounting closure form keeps its pickers usable on every visit β€” `2.8s`
   - βœ… Date pickers on revisited forms β€Ί floating rate form keeps its pickers usable on every visit β€” `3.1s`
   
   **`deployment-customization.spec.ts`**
   
   - βœ… with no overlay mounted β€Ί boots, keeps the shipped branding, and shows the full menu β€” `1.2s`
   - βœ… with no overlay mounted β€Ί does not ask for an overlay this deployment has not declared β€” `1.7s`
   - βœ… with no overlay mounted β€Ί logs no federation parse error on load β€” `1.2s`
   - βœ… branding β€Ί applies colour and density tokens to the document β€” `1.2s`
   - βœ… branding β€Ί recolours Ionic through the same tokens β€” `1.2s`
   - βœ… branding β€Ί renames the product in the header and the tab title β€” `2.2s`
   - βœ… branding β€Ί serves the deployment logo and names it after the deployment β€” `1.8s`
   - βœ… branding β€Ί refuses a fill that cannot carry the white text baked into the stylesheet β€” `1.3s`
   - βœ… branding β€Ί accepts a light accent and derives a dark label for it β€” `1.3s`
   - βœ… branding β€Ί ignores a token outside the published allow-list, and says so β€” `1.2s`
   - βœ… navigation β€Ί hides an entry named by its id β€” `1.2s`
   - βœ… navigation β€Ί does not hide an entry named by its labelKey β€” `1.3s`
   - βœ… navigation β€Ί renames and reorders entries β€” `1.8s`
   - βœ… navigation β€Ί adds an external entry that opens in a new tab β€” `1.4s`
   - βœ… navigation β€Ί nests an added entry under an existing group β€” `1.3s`
   - βœ… navigation β€Ί gates an added entry on permissions like any built-in one β€” `1.3s`
   - βœ… navigation β€Ί drops an external entry with no usable url rather than rendering a dead link β€” `1.2s`
   - βœ… layering β€Ί merges over config.json instead of replacing it β€” `1.2s`
   - βœ… layering β€Ί a malformed overlay leaves the application usable β€” `1.3s`
   
   **`deposit-product-edit.spec.ts`**
   
   - βœ… Editing a deposit product keeps what it was not asked to change β€Ί a fixed deposit product keeps its deposit amount, accounting and rate chart on save β€” `2.3s`
   - βœ… Editing a deposit product keeps what it was not asked to change β€Ί a recurring deposit product keeps its deposit amount, accounting and rate chart on save β€” `2.4s`
   
   **`e2e-journey.spec.ts`**
   
   - βœ… E2E: Login Flow β€Ί should display login page with all required fields β€” `1.1s`
   - βœ… E2E: Login Flow β€Ί should have Sign In button disabled when fields are empty β€” `1.2s`
   - βœ… E2E: Login Flow β€Ί should enable Sign In when credentials are entered β€” `1.0s`
   - βœ… E2E: Login Flow β€Ί should redirect to dashboard after successful login β€” `1.3s`
   - βœ… E2E: Login Flow β€Ί should display language selector on login page β€” `1.1s`
   - βœ… E2E: Login Flow β€Ί should display API endpoint selector with value β€” `1.1s`
   - βœ… E2E: Dashboard β€Ί should display dashboard summary cards β€” `1.3s`
   - βœ… E2E: Dashboard β€Ί should display system health as Online β€” `1.5s`
   - βœ… E2E: Dashboard β€Ί should display pending approvals section β€” `1.2s`
   - βœ… E2E: Dashboard β€Ί should display loan and savings status distribution charts β€” `1.3s`
   - βœ… E2E: Dashboard β€Ί should display system operational status β€” `1.3s`
   - βœ… E2E: Dashboard β€Ί should have header with business date, render time, and logout β€” `1.3s`
   - βœ… E2E: Dashboard β€Ί should have sidebar toggle button β€” `1.3s`
   - βœ… E2E: Dashboard β€Ί should have global search bar in header β€” `1.3s`
   - βœ… E2E: Dashboard β€Ί should have Guide button in header β€” `1.3s`
   - βœ… E2E: Dashboard β€Ί should have dark mode toggle button β€” `1.4s`
   - βœ… E2E: Sidebar Navigation β€Ί should have all main navigation links β€” `1.3s`
   - βœ… E2E: Sidebar Navigation β€Ί should have Products section with sub-links β€” `1.3s`
   - βœ… E2E: Sidebar Navigation β€Ί should have Transfers section with sub-links β€” `1.3s`
   - βœ… E2E: Sidebar Navigation β€Ί should have Accounting section with sub-links β€” `1.4s`
   - βœ… E2E: Sidebar Navigation β€Ί should have Security section β€” `1.4s`
   - βœ… E2E: Sidebar Navigation β€Ί should have Settings section β€” `1.4s`
   - βœ… E2E: Sidebar Navigation β€Ί should have Organization section β€” `1.3s`
   - βœ… E2E: Sidebar Navigation β€Ί should have System section β€” `1.4s`
   - βœ… E2E: Sidebar Navigation β€Ί should navigate to all major pages via sidebar β€” `2.8s`
   - βœ… E2E: Offices β€Ί should display offices list with table β€” `1.6s`
   - βœ… E2E: Offices β€Ί should have Create Office button β€” `1.5s`
   - βœ… E2E: Offices β€Ί should navigate to office create form β€” `1.7s`
   - βœ… E2E: Offices β€Ί should validate office create form - save disabled when empty β€” `1.6s`
   - βœ… E2E: Offices β€Ί should fill office create form and enable save β€” `2.2s`
   - βœ… E2E: Offices β€Ί should cancel office creation and return to list β€” `1.6s`
   - βœ… E2E: Offices β€Ί should have search input on offices page β€” `1.4s`
   - βœ… E2E: Offices β€Ί should show edit button for each office β€” `1.4s`
   - βœ… E2E: Offices β€Ί should have pagination controls β€” `1.4s`
   - βœ… E2E: Clients β€Ί should display clients list page β€” `1.4s`
   - βœ… E2E: Clients β€Ί should display client data in table β€” `1.6s`
   - βœ… E2E: Clients β€Ί should show ACTIVE status badge β€” `1.4s`
   - βœ… E2E: Clients β€Ί should have Create Client button β€” `1.6s`
   - βœ… E2E: Clients β€Ί should show empty state when no clients exist β€” `1.5s`
   - βœ… E2E: Clients β€Ί should have search input and status filter β€” `1.9s`
   - βœ… E2E: Clients β€Ί should have pagination controls β€” `2.1s`
   - βœ… E2E: Client Creation β€Ί should navigate to client create form β€” `2.8s`
   - βœ… E2E: Client Creation β€Ί should display all required client form fields, across the wizard steps β€” `3.6s`
   - βœ… E2E: Client Creation β€Ί should display optional client form fields, across the wizard steps β€” `3.2s`
   - βœ… E2E: Client Creation β€Ί should have Next disabled until the current step is valid β€” `3.0s`
   - βœ… E2E: Client Creation β€Ί should populate office dropdown from API β€” `2.8s`
   - βœ… E2E: Client Creation β€Ί should fill client form and enable save on the last step β€” `3.7s`
   - βœ… E2E: Client Creation β€Ί should submit client and redirect to the new client view β€” `4.1s`
   - βœ… E2E: Client Creation β€Ί should cancel client creation and return to clients list β€” `2.6s`
   - βœ… E2E: Client Creation β€Ί should have add office quick-action button β€” `2.3s`
   - βœ… E2E: Client Creation β€Ί should have help icons for form fields β€” `2.1s`
   - βœ… E2E: Client Creation β€Ί should have calendar buttons for date fields β€” `3.1s`
   - βœ… E2E: Client Creation β€Ί should have pre-filled dates in Submitted On and Activation Date β€” `3.2s`
   - βœ… E2E: Client Creation β€Ί should have Active checkbox checked by default β€” `2.2s`
   - βœ… E2E: Client Creation β€Ί should handle Entity legal form showing fullname field β€” `3.6s`
   - βœ… E2E: Loan Products Page β€Ί should display loan products list page β€” `2.3s`
   - βœ… E2E: Loan Products Page β€Ί should have Create Loan Product button β€” `2.4s`
   - βœ… E2E: Loan Products Page β€Ί should display table with correct headers β€” `2.3s`
   - βœ… E2E: Loan Products Page β€Ί should show empty state when no products exist β€” `1.9s`
   - βœ… E2E: Loan Products Page β€Ί should have search input β€” `1.9s`
   - βœ… E2E: Loan Products Page β€Ί should navigate to loan product create form β€” `2.6s`
   - βœ… E2E: Savings Products Page β€Ί should display savings products list page β€” `2.3s`
   - βœ… E2E: Other Products Pages β€Ί Fixed Deposit Products page loads β€” `2.4s`
   - βœ… E2E: Other Products Pages β€Ί Recurring Deposit Products page loads β€” `2.2s`
   - βœ… E2E: Other Products Pages β€Ί Share Products page loads β€” `2.3s`
   - βœ… E2E: Other Products Pages β€Ί Tax Components page loads β€” `2.4s`
   - βœ… E2E: Other Products Pages β€Ί Tax Groups page loads β€” `2.2s`
   - βœ… E2E: Other Products Pages β€Ί Floating Rates page loads β€” `2.3s`
   - βœ… E2E: Loans Portfolio β€Ί should display loans portfolio page β€” `2.4s`
   - βœ… E2E: Loans Portfolio β€Ί should have Create Loan Account button β€” `2.3s`
   - βœ… E2E: Loans Portfolio β€Ί should display table with correct headers β€” `2.4s`
   - βœ… E2E: Loans Portfolio β€Ί should show empty state when no loans exist β€” `2.3s`
   - βœ… E2E: Loans Portfolio β€Ί should have search input β€” `2.1s`
   - βœ… E2E: Loans Portfolio β€Ί should have pagination controls β€” `2.3s`
   - βœ… E2E: Header & Global Elements β€Ί should display Fineract logo and title in header β€” `2.1s`
   - βœ… E2E: Header & Global Elements β€Ί should display logged-in username β€” `2.1s`
   - βœ… E2E: Header & Global Elements β€Ί should display office name for user β€” `1.6s`
   - βœ… E2E: Header & Global Elements β€Ί should have Logout button β€” `2.1s`
   - βœ… E2E: Header & Global Elements β€Ί should have language selector in header β€” `1.9s`
   - βœ… E2E: Header & Global Elements β€Ί should display render time β€” `2.1s`
   - βœ… E2E: Header & Global Elements β€Ί should display business date β€” `2.1s`
   
   **`feature-coverage.spec.ts`**
   
   - βœ… Navigation & Sidebar β€Ί all main navigation links are visible in sidebar β€” `2.2s`
   - βœ… Navigation & Sidebar β€Ί sidebar navigation routes to correct pages β€” `3.5s`
   - βœ… Navigation & Sidebar β€Ί navigating to /accounting redirects to /accounting/chart-of-accounts β€” `3.5s`
   - βœ… Navigation & Sidebar β€Ί navigating to /organization redirects to /organization/offices β€” `3.5s`
   - βœ… Navigation & Sidebar β€Ί navigating to /system redirects to /system/data-tables β€” `3.3s`
   - βœ… Navigation & Sidebar β€Ί an unknown route keeps its URL and offers recovery actions β€” `3.0s`
   - βœ… Navigation & Sidebar β€Ί header shows logged-in user info β€” `2.0s`
   - βœ… Navigation & Sidebar β€Ί help tour button is visible and interactive β€” `1.6s`
   - βœ… Navigation & Sidebar β€Ί logout button is visible β€” `1.7s`
   - βœ… Navigation & Sidebar β€Ί language selector is present β€” `2.0s`
   - βœ… Dashboard β€Ί dashboard shows key metrics cards β€” `2.0s`
   - βœ… Dashboard β€Ί dashboard shows pending approvals section β€” `2.0s`
   - βœ… Dashboard β€Ί dashboard shows loan and savings status distribution β€” `2.0s`
   - βœ… Dashboard β€Ί dashboard shows system operational status β€” `2.1s`
   - βœ… Groups β€Ί groups page renders with title β€” `2.3s`
   - βœ… Groups β€Ί groups page has create button β€” `2.2s`
   - βœ… Centers β€Ί centers page renders with title β€” `2.3s`
   - βœ… Centers β€Ί centers page has create button β€” `2.3s`
   - βœ… Organization β€Ί offices page loads with list β€” `2.4s`
   - βœ… Organization β€Ί staff page loads β€” `2.4s`
   - βœ… Organization β€Ί funds page loads β€” `2.3s`
   - βœ… Organization β€Ί payment types page loads β€” `2.4s`
   - βœ… Accounting β€Ί chart of accounts loads β€” `2.4s`
   - βœ… Accounting β€Ί journal entries page loads β€” `2.5s`
   - βœ… Accounting β€Ί accounting closures loads β€” `1.9s`
   - βœ… Accounting β€Ί accounting rules loads β€” `1.9s`
   - βœ… Accounting β€Ί financial activity mappings loads β€” `2.2s`
   - βœ… Accounting β€Ί charges page loads β€” `2.3s`
   - βœ… Security β€Ί users page loads β€” `2.1s`
   - βœ… Security β€Ί roles page loads β€” `2.3s`
   - βœ… Security β€Ί audit logs page loads β€” `2.4s`
   - βœ… Security β€Ί leftover audit trails path lands on audits, not the dashboard β€” `3.5s`
   - βœ… Products - Loan & Savings β€Ί loan products page loads β€” `2.3s`
   - βœ… Products - Loan & Savings β€Ί savings products page loads β€” `2.3s`
   - βœ… Products - Loan & Savings β€Ί tax components page loads β€” `2.3s`
   - βœ… Products - Loan & Savings β€Ί tax groups page loads β€” `2.2s`
   - βœ… Products - Loan & Savings β€Ί floating rates page loads β€” `2.3s`
   - βœ… Products - Loan & Savings β€Ί savings accounts page loads β€” `2.4s`
   - βœ… Settings β€Ί global configurations page loads β€” `2.4s`
   - βœ… Settings β€Ί holidays page loads β€” `2.3s`
   - βœ… Settings β€Ί working days page loads β€” `2.3s`
   - βœ… System β€Ί data tables page loads β€” `2.1s`
   - βœ… System β€Ί bulk import page loads β€” `2.3s`
   - βœ… System β€Ί delinquency page loads β€” `2.1s`
   - βœ… Tellers β€Ί tellers page loads with create button β€” `2.3s`
   - βœ… Transfers β€Ί account transfer page loads β€” `2.4s`
   - βœ… Transfers β€Ί standing instructions page loads β€” `2.3s`
   - βœ… Transfers β€Ί SI history page loads β€” `2.3s`
   - βœ… Embedded Fintech β€Ί asset owners page loads β€” `1.9s`
   
   **`functional-coverage.spec.ts`**
   
   - βœ… Client CRUD Workflow β€Ί should complete full client creation flow β€” Person β€” `4.3s`
   - βœ… Client CRUD Workflow β€Ί should complete full client creation flow β€” Entity β€” `3.5s`
   - βœ… Client CRUD Workflow β€Ί should cancel client creation and return to list β€” `2.7s`
   - βœ… Client CRUD Workflow β€Ί should disable office and legal form in edit mode β€” `3.5s`
   - βœ… Client View & Status Transitions β€Ί should display client view with correct details β€” `3.3s`
   - βœ… Client View & Status Transitions β€Ί should show actions menu for pending client (status 100) β€” `3.3s`
   - βœ… Client View & Status Transitions β€Ί should show actions menu for active client (status 300) β€” `3.8s`
   - βœ… Client View & Status Transitions β€Ί should show actions menu for closed client (status 600) β€” `3.2s`
   - βœ… Client View & Status Transitions β€Ί should show New Account menu for active client β€” `3.5s`
   - βœ… Client View & Status Transitions β€Ί should navigate from client view to client list via breadcrumb β€” `3.8s`
   - βœ… Client View & Status Transitions β€Ί should navigate back from client view β€” `3.7s`
   - βœ… Client View Tabs β€Ί should display all expected tabs on client view β€” `3.2s`
   - βœ… Client View Tabs β€Ί should switch between client view tabs β€” `4.1s`
   - βœ… Search & Filter Interactions β€Ί should have search input on clients page and accept input β€” `2.3s`
   - βœ… Search & Filter Interactions β€Ί should have office filter on groups page β€” `2.5s`
   - βœ… Search & Filter Interactions β€Ί should have office filter on loans page β€” `2.2s`
   - βœ… Search & Filter Interactions β€Ί should have search input on charges page β€” `2.2s`
   - βœ… Navigation Flow Verification β€Ί should navigate full client lifecycle: list β†’ create β†’ back β†’ list β€” `3.0s`
   - βœ… Navigation Flow Verification β€Ί should navigate from dashboard to all top-level pages via sidebar β€” `4.9s`
   - βœ… Navigation Flow Verification β€Ί should go from loan products β†’ create β†’ back β€” `3.2s`
   - βœ… Office CRUD β€Ί should validate office create form β€” save disabled when empty β€” `2.7s`
   - βœ… Office CRUD β€Ί should validate office create form β€” save enabled when filled β€” `3.8s`
   - βœ… Office CRUD β€Ί should cancel office creation β€” `2.6s`
   - βœ… Organization Create Flows β€Ί staff create form loads β€” `2.6s`
   - βœ… Organization Create Flows β€Ί funds create form loads β€” `2.5s`
   - βœ… Organization Create Flows β€Ί payment types create form loads β€” `2.5s`
   - βœ… Accounting CRUD β€Ί accounting closure create form loads β€” `2.6s`
   - βœ… Accounting CRUD β€Ί accounting rule create form loads β€” `2.9s`
   - βœ… Accounting CRUD β€Ί financial activity mapping create form loads β€” `2.3s`
   - βœ… Accounting CRUD β€Ί charge create form loads β€” `2.6s`
   - βœ… Accounting CRUD β€Ί chart of accounts create form loads β€” `2.3s`
   - βœ… Settings CRUD β€Ί global configurations page has editable toggle β€” `2.3s`
   - βœ… Settings CRUD β€Ί holiday create form loads β€” `2.7s`
   - βœ… Settings CRUD β€Ί working days page has save button β€” `2.4s`
   - βœ… System CRUD β€Ί data table create form loads β€” `2.6s`
   - βœ… System CRUD β€Ί delinquency page has create buttons for ranges and buckets β€” `2.3s`
   - βœ… Security CRUD β€Ί role create form loads β€” `2.4s`
   - βœ… Security CRUD β€Ί audit logs search form has date range β€” `2.7s`
   - βœ… Security CRUD β€Ί audit logs list exports the loaded page as CSV β€” `2.7s`
   - βœ… Reporting β€” Run Report Flow β€Ί should navigate to report and verify report parameters β€” `3.5s`
   - βœ… Teller CRUD β€Ί teller create form loads β€” `2.7s`
   - βœ… Create Office Dialog from Client Form β€Ί should open create office dialog from client create form β€” `2.8s`
   
   **`global-search-nav-shortcuts.spec.ts`**
   
   - βœ… Global search navigation shortcuts β€Ί offers a settings page the entity search cannot return β€” `2.4s`
   - βœ… Global search navigation shortcuts β€Ί navigates to the page behind a shortcut β€” `2.6s`
   - βœ… Global search navigation shortcuts β€Ί keeps the results open across the mousedown that starts a click, so a slow render cannot race the dropdown closed β€” `2.8s`
   - βœ… Global search navigation shortcuts β€Ί withholds a shortcut the user has no permission to reach β€” `2.9s`
   
   **`global-search-navigation.spec.ts`**
   
   - βœ… Global search navigation β€Ί does not query the backend for a single character β€” `3.0s`
   - βœ… Global search navigation β€Ί sends one request for a phrase typed straight through β€” `3.4s`
   - βœ… Global search navigation β€Ί opens the client behind a client result β€” `2.6s`
   - βœ… Global search navigation β€Ί opens the loan behind a loan result β€” `2.7s`
   - βœ… Global search navigation β€Ί opens the savings account behind a savings result β€” `2.5s`
   - βœ… Global search navigation β€Ί clears the query and closes the dropdown after a selection β€” `2.7s`
   - βœ… Global search navigation β€Ί closes the dropdown when focus leaves without a selection β€” `2.5s`
   - βœ… Global search navigation β€Ί shows nothing rather than an empty dropdown when there are no matches β€” `2.9s`
   
   **`group-detail.spec.ts`**
   
   - βœ… Group detail β€Ί custom field tabs expose the app keyboard and panel contract at 1280px β€” `4.2s`
   - βœ… Group detail β€Ί custom field tabs expose the app keyboard and panel contract at 390px β€” `4.6s`
   - βœ… Group detail β€Ί asks for the associations, and shows every member rather than only the active ones β€” `3.9s`
   - βœ… Group detail β€Ί closes a group with the reason id and a Fineract-formatted date β€” `5.7s`
   - βœ… Group detail β€Ί does not offer a client who is already a member, and associates the one it does β€” `4.3s`
   - βœ… Group detail β€Ί unassigns a committee role by the assignment id, not the role id β€” `4.3s`
   - βœ… Group detail β€Ί offers a retry when the group cannot be loaded β€” `3.7s`
   - βœ… Group detail β€Ί says so when no closure reason has been defined β€” `4.7s`
   
   **`guidance-tour.spec.ts`**
   
   - βœ… the guided tour β€Ί highlights the System Status card, not the sidebar, on the dashboard tour β€” `2.4s`
   - βœ… the guided tour β€Ί presents the card as a dialog named and described by its own copy β€” `2.3s`
   - βœ… the guided tour β€Ί announces the step counter as a polite live region β€” `2.5s`
   - βœ… the guided tour β€Ί closes on Escape and hands focus back to the Guide button β€” `2.0s`
   - βœ… the guided tour β€Ί names an unmatched route after itself instead of handing it the dashboard tour β€” `3.6s`
   - ⏭️ the guided tour at a phone viewport β€Ί opens from the overflow menu as a full-width bottom sheet β€” `2.0s`
   
   **`keyboard-shortcuts.spec.ts`**
   
   - βœ… Alt+C jumps straight to Create Client from anywhere β€” `2.4s`
   - βœ… Alt+G jumps to Create Group β€” `2.4s`
   - βœ… Alt+E jumps to Create Center β€” `2.2s`
   - βœ… Alt+H opens the guidance tour for the current page β€” `2.1s`
   - βœ… typing in the global search box does not trigger the Create Client shortcut β€” `2.5s`
   
   **`list-load-failure.spec.ts`**
   
   - βœ… entity-to-entity mapping β€Ί renders the rows the endpoint returns, rather than claiming there are none β€” `3.4s`
   - βœ… entity-to-entity mapping β€Ί shows a failure state, not an empty one, when the payload cannot be read β€” `3.6s`
   - βœ… entity-to-entity mapping β€Ί retries and recovers β€” `4.1s`
   - βœ… report mailing jobs β€Ί reads rows out of the paged envelope the endpoint returns β€” `3.7s`
   - βœ… report mailing jobs β€Ί shows a failure state when the request errors β€” `4.0s`
   
   **`list-pagination.spec.ts`**
   
   - βœ… Server total modes β€Ί shows an exact loan count of 1 without opting in β€” `3.7s`
   - βœ… Server total modes β€Ί shows an exact loan count of 11 without opting in β€” `4.0s`
   - βœ… Server total modes β€Ί shows an exact loan count of 21 without opting in β€” `3.9s`
   - βœ… Server total modes β€Ί keeps the unknown total for a full audit page β€” `3.7s`
   - βœ… List pagination β€Ί advances through pages and back again β€” `4.1s`
   - βœ… List pagination β€Ί jumps to the last page β€” `3.8s`
   - βœ… List pagination β€Ί returns to the first page when a filter changes β€” `4.6s`
   - βœ… List pagination β€Ί keeps search text when paging and shows the v2 account number β€” `4.4s`
   - βœ… List pagination β€Ί redirects the old search page and exposes only one client navigation item β€” `5.4s`
   - βœ… List pagination β€Ί keeps an account-number search broad and disables unsupported Office sorting β€” `4.9s`
   - βœ… List pagination β€Ί returns to the first page when searching β€” `4.8s`
   - βœ… Profile β€Ί renders the signed-in user rather than hanging on a spinner β€” `4.0s`
   - βœ… Profile β€Ί changes the signed-in user password from the profile screen β€” `3.8s`
   - βœ… Profile β€Ί shows an error when the profile cannot be loaded β€” `3.8s`
   - βœ… List load failure β€Ί reports the failure and reloads when the user retries β€” `4.4s`
   - βœ… List load failure β€Ί calendars: reports the failure and reloads when the user retries β€” `4.0s`
   - βœ… List load failure β€Ί accounting rules: reports the failure and reloads when the user retries β€” `4.0s`
   
   **`loan-delinquency-actions.spec.ts`**
   
   - βœ… Delinquency pause and resume on a regular loan β€Ί shows the dates as dates, and the pause periods under their real fields β€” `3.9s`
   - βœ… Delinquency pause and resume on a regular loan β€Ί pauses delinquency for a period, from the business date β€” `4.6s`
   - βœ… Delinquency pause and resume on a regular loan β€Ί will not take a pause that ends before, or when, it starts β€” `4.0s`
   - βœ… Delinquency pause and resume on a regular loan β€Ί sends nothing when the pause dialog is cancelled β€” `5.1s`
   - βœ… Delinquency pause and resume on a regular loan β€Ί shows the platform’s own reason when it refuses a pause, and stays usable β€” `4.4s`
   - βœ… Delinquency pause and resume on a regular loan β€Ί resumes a pause that is in effect, dated the business date and with no end date β€” `4.6s`
   - βœ… Delinquency pause and resume on a regular loan β€Ί stops offering resume once the pause has been ended β€” `4.2s`
   - βœ… Delinquency pause and resume on a regular loan β€Ί does not offer resume when no pause is in effect β€” `3.8s`
   - βœ… Delinquency pause and resume on a regular loan β€Ί offers neither action without the permission to create a delinquency action β€” `3.8s`
   - βœ… Delinquency pause and resume on a regular loan β€Ί offers the permitted user the actions that permission opens, and no others β€” `3.6s`
   - βœ… Delinquency pause and resume on a regular loan β€Ί offers neither action on a loan that is not active β€” `3.8s`
   
   **`loan-portfolio-summary.spec.ts`**
   
   - βœ… Loan Portfolio Summary β€Ί searching sends entities: loans and shows the grouped results β€” `3.7s`
   - βœ… Loan Portfolio Summary β€Ί Edit Parameters returns from the results view to the filter form β€” `3.7s`
   - βœ… Loan Portfolio Summary β€Ί checking the outstanding-amount filter sends the between min/max values β€” `3.9s`
   - βœ… Loan Portfolio Summary β€Ί switching to a single-value comparison condition sends one value β€” `4.5s`
   
   **`loan-product-down-payment.spec.ts`**
   
   - βœ… Loan product down payment and tranches β€Ί down payment is offered only for a progressive product β€” `4.6s`
   - βœ… Loan product down payment and tranches β€Ί the percentage and auto-repayment appear only once down payment is on β€” `4.7s`
   - βœ… Loan product down payment and tranches β€Ί multi-tranche disbursement reveals its tranche count, on either engine β€” `4.2s`
   - βœ… Loan product down payment and tranches β€Ί submits the down payment and tranche settings it was given β€” `5.1s`
   - βœ… Loan product down payment and tranches β€Ί does not send a down payment on a cumulative product β€” `5.7s`
   - βœ… Loan product down payment and tranches β€Ί income recognition is offered only for a progressive product β€” `4.9s`
   - βœ… Loan product down payment and tranches β€Ί the capitalisation details appear only once it is on β€” `4.4s`
   - βœ… Loan product down payment and tranches β€Ί submits the income recognition settings it was given β€” `5.0s`
   - βœ… Loan product down payment and tranches β€Ί does not send income recognition on a cumulative product β€” `5.8s`
   - βœ… Loan product down payment and tranches β€Ί interest recalculation reveals its mandatory settings only once it is on β€” `3.9s`
   - βœ… Loan product down payment and tranches β€Ί the rest interval appears only when the frequency is not the repayment period β€” `4.6s`
   - βœ… Loan product down payment and tranches β€Ί submits the interest recalculation settings it was given β€” `4.2s`
   - βœ… Loan product down payment and tranches β€Ί does not send recalculation settings once it is switched back off β€” `4.0s`
   
   **`loan-schedule-type-gating.spec.ts`**
   
   - βœ… Loan schedule type gating β€Ί a cumulative loan offers neither progressive-only tab, and fetches neither β€” `3.8s`
   - βœ… Loan schedule type gating β€Ί a progressive loan shows only the capabilities it actually has β€” `4.1s`
   - βœ… Loan schedule type gating β€Ί fetches buy-down fees for a progressive loan that has no external id β€” `3.3s`
   - βœ… Loan schedule type gating β€Ί a progressive loan with both capabilities shows both β€” `3.7s`
   - βœ… Loan schedule type gating β€Ί a charged-off loan says so and offers the reversal instead β€” `4.0s`
   - βœ… Loan schedule type gating β€Ί a healthy loan offers charge-off and no marker β€” `3.6s`
   
   **`charge-refund.spec.ts`**
   
   - βœ… refunds the remaining balance of a collected loan charge β€” `6.2s`
   
   **`client-accounts.spec.ts`**
   
   - βœ… loan account rows show the principal returned by the accounts endpoint β€” `3.8s`
   - βœ… empty account tabs offer the matching creation action β€” `6.1s`
   
   **`client-load-error.spec.ts`**
   
   - βœ… shows a not-found state and returns to the list, without an error toast β€” `3.8s`
   - βœ… offers a retry for a transient failure, and recovers on success β€” `3.6s`
   
   **`contract-termination.spec.ts`**
   
   - βœ… terminates and undoes a progressive loan contract β€” `8.2s`
   
   **`loan-servicing-gaps.spec.ts`**
   
   - βœ… Loan servicing gaps β€Ί edits an interest pause through PUT while preserving its existing dates β€” `3.7s`
   - βœ… Loan servicing gaps β€Ί undo approval sends an empty body, without the locale the platform refuses β€” `4.4s`
   - βœ… Loan servicing gaps β€Ί undo approval carries the reason when one was given β€” `4.3s`
   - βœ… Loan servicing gaps β€Ί undo approval is offered only while the loan is approved β€” `3.5s`
   - βœ… Loan servicing gaps β€Ί the delinquency tab shows the summary the loan carries and the tags it fetches β€” `3.6s`
   - βœ… Loan servicing gaps β€Ί the data-only tabs stay hidden when the loan carries nothing for them β€” `3.5s`
   - βœ… Loan servicing gaps β€Ί each data-only tab appears and renders once the loan carries its data β€” `3.4s`
   - βœ… Loan servicing gaps β€Ί disburse to savings sends the date and amount, with the platform date format β€” `4.1s`
   - βœ… Loan servicing gaps β€Ί disburse to savings is offered only on an approved loan β€” `3.5s`
   - βœ… Loan servicing gaps β€Ί unassign loan officer sends the date, and appears only when one is assigned β€” `4.0s`
   - βœ… Loan servicing gaps β€Ί unassign loan officer is hidden on a loan with no officer β€” `3.5s`
   - βœ… Loan servicing gaps β€Ί undo last disbursal appears only on a multi-disbursal loan, and sends an empty body β€” `3.9s`
   - βœ… Loan servicing gaps β€Ί undo last disbursal is hidden on a single-disbursal loan β€” `3.6s`
   - βœ… Loan servicing gaps β€Ί close as rescheduled is offered on an active loan β€” `4.0s`
   - βœ… Loan servicing gaps β€Ί revising the amounts is offered on an approved loan β€” `3.4s`
   - βœ… Loan servicing gaps β€Ί revising the amounts is hidden before approval β€” `3.2s`
   - βœ… Loan servicing gaps β€Ί revising the approved amount sends the new amount and locale β€” `4.3s`
   - βœ… Loan servicing gaps β€Ί revising the available disbursement amount sends the new amount, including zero β€” `4.4s`
   
   **`logout-reason-notice.spec.ts`**
   
   - βœ… explains an inactivity sign-out β€” `1.4s`
   - βœ… explains an expired session, with different wording β€” `1.2s`
   - βœ… stays silent when the user came to sign in of their own accord β€” `160ms`
   - βœ… stays silent for a reason it does not recognise, rather than showing a blank notice β€” `238ms`
   
   **`oidc-login.spec.ts`**
   
   - βœ… identity-provider route on the login page β€Ί a deployment that never set the flag sees the screen it always saw β€” `1.3s`
   - βœ… identity-provider route on the login page β€Ί does not offer the route when the flag is false β€” `1.7s`
   - βœ… identity-provider route on the login page β€Ί does not offer the route when the flag is "true" β€” `1.2s`
   - βœ… identity-provider route on the login page β€Ί does not offer the route when the flag is 1 β€” `1.2s`
   - βœ… identity-provider route on the login page β€Ί does not offer the route when the oidc block is missing, and keeps the form β€” `1.2s`
   - βœ… identity-provider route on the login page β€Ί offers the route above the password form, which stays in place β€” `1.8s`
   - βœ… identity-provider route on the login page β€Ί removes the password form only when the deployment asks and OIDC is configured β€” `2.0s`
   - βœ… identity-provider route on the login page β€Ί username and password still sign in when the route is offered β€” `1.9s`
   - βœ… identity-provider route on the login page β€Ί is not offered while a second factor is being asked for β€” `1.4s`
   - βœ… signing in through the identity provider β€Ί sends the browser to the provider with PKCE S256, then opens a session from userdetails β€” `3.2s`
   - βœ… signing in through the identity provider β€Ί does not open a session when the server does not serve userdetails (404) β€” `3.3s`
   - βœ… signing in through the identity provider β€Ί does not open a session when the callback state does not match β€” `1.2s`
   
   **`rbac-feature-flag.spec.ts`**
   
   - βœ… RBAC permission gating (rbacEnabled = true) β€Ί a limited user does not see permission-gated nav groups β€” `1.8s`
   - βœ… RBAC permission gating (rbacEnabled = true) β€Ί a user with READ_USER sees only the Security entries that permission covers β€” `1.5s`
   - βœ… RBAC permission gating (rbacEnabled = true) β€Ί the Security group fills in as the user is granted each of its permissions β€” `1.7s`
   - βœ… RBAC permission gating (rbacEnabled = true) β€Ί a superuser (ALL_FUNCTIONS) sees all permission-gated groups β€” `1.9s`
   - βœ… RBAC institution-feature gating (rbacEnabled = true) β€Ί universal institution shows Groups, Centers and Collection Sheet β€” `2.1s`
   - βœ… RBAC institution-feature gating (rbacEnabled = true) β€Ί commercial bank (cb) hides all group-lending features β€” `1.9s`
   - βœ… RBAC institution-feature gating (rbacEnabled = true) β€Ί credit union (cu) shows Groups but hides Centers and Collection Sheet β€” `1.9s`
   - βœ… rbacEnabled = false β€Ί restores the pre-RBAC behaviour of showing everything β€” `2.0s`
   - βœ… deployment navigation overrides β€Ί removes the entries the deployment names by id, leaving the rest alone β€” `2.0s`
   - βœ… deployment navigation overrides β€Ί ignores a labelKey where an id is required β€” `2.1s`
   - βœ… deployment navigation overrides β€Ί removes them even where RBAC is off β€” `1.9s`
   
   **`rbac-route-protection.spec.ts`**
   
   - βœ… route authorization β€Ί a superuser reaches every sampled route and sees the full navigation β€” `13.8s`
   - βœ… route authorization β€Ί a single-module user is confined to that module β€” `9.4s`
   - βœ… route authorization β€Ί a read-only user gets the lists and not the forms β€” `12.0s`
   - βœ… route authorization β€Ί a user with an empty permission list is refused everywhere and the app still works β€” `9.8s`
   - βœ… route authorization β€Ί a session carrying no permissions field is treated as carrying none β€” `6.0s`
   - βœ… route authorization β€Ί an unrecognised permission code grants nothing β€” `6.0s`
   - βœ… route authorization β€Ί an unauthenticated visitor is asked to sign in, not told they are forbidden β€” `6.0s`
   - βœ… route authorization β€Ί a deployment with RBAC turned off behaves as it did before the guard β€” `8.3s`
   - βœ… the Access Denied page β€Ί says what happened, under a single heading β€” `4.1s`
   - βœ… the Access Denied page β€Ί moves focus to the heading, since the user did not ask to come here β€” `3.8s`
   - βœ… the Access Denied page β€Ί announces itself politely β€” `3.6s`
   - βœ… the Access Denied page β€Ί names the permissions the screen wanted, so the user can ask for them β€” `3.8s`
   - βœ… the Access Denied page β€Ί offers a way back that the user is allowed to take β€” `3.7s`
   - βœ… the Access Denied page β€Ί is reachable by keyboard alone β€” `3.6s`
   - βœ… permission feedback β€Ί a visible nav entry says what the user can do in that module β€” `1.8s`
   - βœ… permission feedback β€Ί a nav entry carries no hint when there is nothing specific to say β€” `2.0s`
   - βœ… navigation and routes agree β€Ί a hidden menu entry is also refused by URL, and a shown one is not β€” `5.7s`
   - βœ… navigation and routes agree β€Ί the create button is withheld from a user whose create route would refuse them β€” `4.1s`
   
   **`report-enhancements.spec.ts`**
   
   - βœ… Report Enhancements, Pagination, and Help Tour β€Ί should run report, show paginated data, and download CSV β€” `4.3s`
   - βœ… Report Enhancements, Pagination, and Help Tour β€Ί should trigger guidance help tour and navigate steps β€” `3.5s`
   
   **`reporting.spec.ts`**
   
   - βœ… Reporting β€Ί should display list of reports β€” `2.3s`
   - βœ… Reporting β€Ί should navigate to run report page β€” `2.1s`
   
   **`scheduler-jobs.spec.ts`**
   
   - βœ… Scheduler Jobs bulk run β€Ί Run Selected is disabled until a job is checked β€” `3.8s`
   - βœ… Scheduler Jobs bulk run β€Ί Select All checks every job, and running confirms then triggers each one β€” `4.2s`
   - βœ… Scheduler Jobs bulk run β€Ί declining the confirmation runs nothing β€” `4.2s`
   
   **`sidebar-active-state.spec.ts`**
   
   - βœ… Sidebar active route matching β€Ί does not mark a parent sidebar link active on nested routes β€” `1m 27s`
   
   **`two-factor-authentication.spec.ts`**
   
   - βœ… two-step verification β€Ί asks for a code and lets the user in once it is accepted β€” `1.9s`
   - βœ… two-step verification β€Ί sends the token on every later request β€” `1.8s`
   - βœ… two-step verification β€Ί keeps the user on the step when the code is wrong, and says why β€” `1.9s`
   - βœ… two-step verification β€Ί accepts a correct code after a wrong one β€” `2.1s`
   - βœ… two-step verification β€Ί offers the choice when there is more than one channel β€” `1.8s`
   - βœ… two-step verification β€Ί says so when the account has no channel configured β€” `1.7s`
   - βœ… two-step verification β€Ί returns to the choice when the code could not be sent β€” `1.7s`
   - βœ… two-step verification β€Ί does not let a half-finished session reach the application β€” `3.6s`
   - βœ… two-step verification β€Ί backing out returns to the password form β€” `1.7s`
   - βœ… two-step verification β€Ί a deployment without a second factor signs in exactly as before β€” `2.0s`
   
   **`working-capital-loan-actions.spec.ts`**
   
   - βœ… Working Capital loan delinquency, breach and near-breach actions, and originators β€Ί shows delinquency action history and submits a new one β€” `4.7s`
   - βœ… Working Capital loan delinquency, breach and near-breach actions, and originators β€Ί shows breach action history and submits a new one β€” `5.6s`
   - βœ… Working Capital loan delinquency, breach and near-breach actions, and originators β€Ί shows near-breach action history and submits a new one β€” `4.4s`
   - βœ… Working Capital loan delinquency, breach and near-breach actions, and originators β€Ί attaches and detaches a loan originator β€” `4.8s`
   - βœ… Working Capital loan delinquency, breach and near-breach actions, and originators β€Ί adds a new charge and waives an existing one β€” `4.8s`
   - βœ… Working Capital loan delinquency, breach and near-breach actions, and originators β€Ί shows rate-change history, amortization schedule, and delinquency range tags β€” `4.0s`
   - βœ… Working Capital loan delinquency, breach and near-breach actions, and originators β€Ί marks the loan as fraud, applies a discount, and changes the payment rate β€” `5.8s`
   - βœ… Working Capital loan transactions, loss actions and modification β€Ί credits the borrower through a goodwill credit β€” `4.3s`
   - βœ… Working Capital loan transactions, loss actions and modification β€Ί refunds a merchant payout β€” `4.2s`
   - βœ… Working Capital loan transactions, loss actions and modification β€Ί refunds a credit balance only on an overpaid loan β€” `4.4s`
   - βœ… Working Capital loan transactions, loss actions and modification β€Ί charges off an active loan β€” `4.4s`
   - βœ… Working Capital loan transactions, loss actions and modification β€Ί swaps charge-off for its undo once the loan is charged off β€” `3.7s`
   - βœ… Working Capital loan transactions, loss actions and modification β€Ί writes off an active loan β€” `4.4s`
   - βœ… Working Capital loan transactions, loss actions and modification β€Ί offers recovery and undo on a written-off loan, but not a second write-off β€” `3.8s`
   - βœ… Working Capital loan transactions, loss actions and modification β€Ί withholds undo write-off once a recovery has been collected β€” `3.9s`
   - βœ… Working Capital loan transactions, loss actions and modification β€Ί modifies a submitted application through the edit route β€” `5.3s`
   - βœ… Working Capital loan transactions, loss actions and modification β€Ί disables the actions a read-only role may not perform β€” `3.5s`
   
   </details>
   
   <details>
   <summary><b>Slowest 10</b> β€” what the shard counts should be tuned against</summary>
   
   | Test | Spec | Time |
   | --- | --- | ---: |
   | Sidebar active route matching β€Ί does not mark a parent sidebar link active on nested routes | `sidebar-active-state.spec.ts` | 1m 27s |
   | route authorization β€Ί a superuser reaches every sampled route and sees the full navigation | `rbac-route-protection.spec.ts` | 13.8s |
   | route authorization β€Ί a read-only user gets the lists and not the forms | `rbac-route-protection.spec.ts` | 12.0s |
   | route authorization β€Ί a user with an empty permission list is refused everywhere and the app still works | `rbac-route-protection.spec.ts` | 9.8s |
   | route authorization β€Ί a single-module user is confined to that module | `rbac-route-protection.spec.ts` | 9.4s |
   | route authorization β€Ί a deployment with RBAC turned off behaves as it did before the guard | `rbac-route-protection.spec.ts` | 8.3s |
   | terminates and undoes a progressive loan contract | `contract-termination.spec.ts` | 8.2s |
   | refunds the remaining balance of a collected loan charge | `charge-refund.spec.ts` | 6.2s |
   | Client form date pickers β€Ί stay usable on a second visit to the create form | `client-date-picker-revisit.spec.ts` | 6.2s |
   | empty account tabs offer the matching creation action | `client-accounts.spec.ts` | 6.1s |
   
   </details>
   
   
   πŸ“Ό [Download the HTML report, videos and traces](https://github.com/apache/fineract-backoffice-ui/actions/runs/39254700392) β€” see the `playwright-report-mocked` artifact.
   
   <sub>Generated by [run 39254700392](https://github.com/apache/fineract-backoffice-ui/actions/runs/39254700392) from `71fd35a`. The run executed a fork branch, so treat its contents as unverified.</sub>


-- 
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: issues-unsubscribe@fineract.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat(auth): sign in through an OpenID Connect identity provider [fineract-backoffice-ui]

Posted by "Aman-Mittal (via GitHub)" <gi...@apache.org>.
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: issues-unsubscribe@fineract.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org