diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f68965d..84476eb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,6 +71,14 @@ Product extensions are isolated behind `ProductAdminApi`; relying-party client CRUD is further narrowed behind `RelyingPartyAdminApi`. Deterministic preflight modules require neither protocol nor any network client. +### Proposed product-rendered ceremonies + +Product-rendered login, signup, and recovery remain a proposed owner extension +in [ADR 0019](docs/adr/0019-product-owned-passkey-ceremonies.md), not a current +service capability. It keeps credential verification and token issuance in +Keyverse and requires a pinned-engine experiment and origin/RP-ID migration +before any product consumes the released ceremony contract. + ### Deployment controller - resolves every `{{placeholder}}` from KV or a secret manager; diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3a582..caefe61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Keep a Changelog, and releases use semantic versioning. ## [Unreleased] +- Documented the work required for products to present their own passkey + login, signup, and recovery forms. These forms remain proposed, and the + existing password-signup route remains unavailable. + ### Added - ADR-0008 and the non-fork RP authorization matrix, requiring explicit @@ -111,6 +115,17 @@ Keep a Changelog, and releases use semantic versioning. ### Fixed +- Disabled `naruon-web`'s Direct Access Grants (ADR-0017) and the + `POST /registration/accounts/password` signup endpoint that depended on it + (ADR-0018): RFC 9700 §2.4 (BCP 240) and RFC 10017 §7.3 prohibit the OAuth + 2.0 Resource Owner Password Credentials grant, and disabling it in isolation + had left password-only signups with no way to authenticate at all -- the + bound `browser-passwordless` flow accepts only passkeys. The endpoint now + fails closed (`503`) rather than create dead accounts, the shared runtime + adapter no longer carries dormant password-reset authority, and + `scripts/validate_realm.py` rejects a silent re-enable of the grant. See + `docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md` + for the full evidence trail. - Prevented relying-party inventory from silently accepting a KV key/body identity mismatch, rejected unsafe live or `Location`-derived client UUIDs, and aligned exact client discovery with Keycloak's documented diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index feaf209..cff8b40 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -70,6 +70,26 @@ and `role`/`org`/`workspace` claims required by the current Naruon session contract. Its access tokens last 300 seconds; the longer SSO session is serviced through normal token refresh/reissue rather than a twelve-hour bearer token. +`naruon-web`'s `directAccessGrantsEnabled` is currently `false`. It was +briefly `true` as a scoped, reviewed exception +([ADR-0017](../../docs/adr/0017-naruon-owned-password-form.md)) so naruon +could render its own login form with zero Keycloak-rendered HTML in the +loop, but that ADR's Correction (2026-09-03) found the grant type itself +(OAuth2 ROPC) violates RFC 9700 §2.4 / RFC 10017 §7.3, so the flag was set +back to `false` pending a standards-compliant replacement. No other RP ever +gets this exception; the account-unification dynamic-registration validator +still hard-rejects `directAccessGrantsEnabled: true` for everyone else. + +A real password credential to authenticate with comes from +`POST /registration/accounts/password` +([ADR-0018](../../docs/adr/0018-naruon-password-credential-issuance.md)), +gated by its own `password_registration_api_token` — a third bearer +credential, distinct from `operator_api_token` and `registration_api_token`. +Without it configured, naruon's signup surface stays unavailable (503) +rather than open. The realm's `passwordPolicy` +(`"length(12) and notUsername and notEmail"`) enforces the same minimum a +second time, server-side, independent of the endpoint's own validation. + ## Bootstrap ```bash diff --git a/deploy/keycloak/realm-cwl.json b/deploy/keycloak/realm-cwl.json index 8d2018f..b210068 100644 --- a/deploy/keycloak/realm-cwl.json +++ b/deploy/keycloak/realm-cwl.json @@ -3,6 +3,7 @@ "displayName": "ContextualWisdom IdP", "enabled": true, "sslRequired": "external", + "passwordPolicy": "length(12) and notUsername and notEmail", "registrationAllowed": false, "registrationEmailAsUsername": true, "resetPasswordAllowed": false, diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 9e5528a..719bcff 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -66,6 +66,27 @@ must test the **Naruon** product login/token/authorization journey using the `naruon-web` RP client ID and verify the expected audience and bounded claims. Mapper unit tests alone do not prove Naruon product authorization readiness. +## naruon password-signup 503 (expected, not an incident) + +`POST /registration/accounts/password` (ADR-0018) currently returns `503` on +every call, unconditionally, before any Keycloak work happens. This is +deliberate, not a live-dependency failure: `naruon-web`'s Direct Access +Grants was disabled (ADR-0017's Correction, RFC 9700 §2.4 / RFC 10017 §7.3), +and a password-only account created without it has no way to authenticate -- +the bound `browser-passwordless` flow accepts only passkeys. The endpoint is +gated behind `services/account_unification/app/password_registration.py`'s +module constant `PASSWORD_CREDENTIAL_LOGIN_AVAILABLE = False`. + +On-call triage: if this 503 is the *only* symptom (health checks, other RP +clients, and the passwordless registration/login endpoints are otherwise +green), no incident response is needed -- confirm the constant is still +`False` in the deployed image and close as expected behavior. Re-enabling +requires a standards-compliant replacement login mechanism (Authorization +Code + PKCE or passkey/WebAuthn-capable headless contract), tracked against +ADR-0017; flipping the constant back to `True` without that replacement +reintroduces the original RFC violation. Full evidence trail: +`docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md`. + ## Account merge recovery The active PR implementation makes merge, SCIM full replacement (`PUT`), diff --git a/docs/adr/0017-naruon-owned-password-form.md b/docs/adr/0017-naruon-owned-password-form.md new file mode 100644 index 0000000..b1f8856 --- /dev/null +++ b/docs/adr/0017-naruon-owned-password-form.md @@ -0,0 +1,231 @@ +# ADR-0017: Scoped Direct Access Grants exception so naruon can render its own login form + +**Status:** Proposed; historical password mechanism blocked. +**Review boundary (2026-09-07):** This is unmerged PR #128 work, not +accepted protected-main policy. The product-owned forms requirement is +retained; [ADR 0019](0019-product-owned-passkey-ceremonies.md) proposes +its passwordless replacement. Historical mechanism and tradeoff text below +does not authorize enabling the password route. +**Date:** 2026-09-02 +**Decision owner:** Keyverse maintainers, with explicit product direction from +the naruon product owner (see Context). +**Scope:** `naruon-web` only. Does not change `browserFlow`, does not create +any password credential, and does not change the account-unification +dynamic-RP-registration policy that still hard-rejects +`directAccessGrantsEnabled: true` for every other/future RP +(`services/account_unification/app/relying_party.py`, `must be false`). + +## Correction (2026-09-03) + +**A newer standards finding invalidates this ADR's grant-type choice. The underlying product goal — +naruon renders 100% of its own login/signup UI with zero Keycloak-rendered HTML in the loop, Keyverse +stays the canonical identity backend — is NOT superseded and should not be abandoned.** + +RFC 9700 (*OAuth 2.0 Security Best Current Practice*, BCP 240, January 2025), §2.4, states that clients +and authorization servers **MUST NOT** use the Resource Owner Password Credentials grant. RFC 10017 +(*OAuth 2.0 for Browser-Based Applications*, BCP, August 2026), §7.3, independently repeats that +prohibition specifically for browser-based OAuth/OIDC applications and requires a redirect-based flow +such as Authorization Code (+ PKCE) instead. Both post-date RFC 6749 (cited in this ADR's own References +below), which merely *defined* the ROPC grant in 2012 without reflecting the subsequent decade of +threat-model findings that led to its later deprecation. + +This ADR's Decision section (point 1, below) treated the product owner's explicit sign-off as satisfying +[ADR-0002](0002-passwordless-local-accounts.md)'s "explicit security/product review and migration +evidence" amendment clause. **That is no longer sufficient**: a product-owner risk acceptance can +document an organizational deviation from this org's own passwordless-first preference, but it cannot +make `grant_type=password` standards-compliant for a browser-based application — RFC 9700/10017 are +current IETF security guidance, not one team's stylistic preference, and no local risk-acceptance memo +overrides a MUST-NOT. + +Discovered when `naruon#1532` (the companion PR implementing point 4 of the Decision below) was returned +to Draft over this exact finding rather than merged or landed as-is — see that PR and this PR's own +comment thread (2026-09-03T02:59:17Z) for the original citation. Primary references: +[RFC 9700 §2.4](https://www.rfc-editor.org/rfc/rfc9700.html#section-2.4), +[RFC 10017 §7.3](https://www.rfc-editor.org/rfc/rfc10017.html#section-7.3). + +**What remains valid:** the product-rendered forms requirement and the rejection +of issuer-rendered replacements. The mechanism and expected outcomes below are +historical proposals; they are not current behavior or permission to ship ROPC. + +**What needs repair before `naruon-web`'s `directAccessGrantsEnabled: true` +(`deploy/keycloak/realm-cwl.json`) and the companion naruon-side password route +(`frontend/src/app/auth/password/login/route.ts`) may ship:** the ROPC mechanism must be replaced with a +standards-compliant headless authentication contract. Candidates worth investigating, not yet decided: +(a) an Authorization Code + PKCE flow run inside an in-app browser view (popup/webview) rather than a +full top-level redirect — may satisfy "naruon renders the surrounding chrome" without a full-page +Keycloak-rendered navigation, needs verification against the "zero Keycloak-rendered HTML" requirement +before assuming it qualifies; (b) a custom Keycloak REST resource provider exposing a headless, versioned +session/challenge API for password and/or passkey/WebAuthn authentication — this ADR's own Consequences +section already anticipated a custom Keycloak SPI/REST provider as the eventual path for a full WebAuthn +ceremony, so this investment may resolve both gaps (password AND passkey) at once. + +**Config change, 2026-09-03:** `naruon-web`'s `directAccessGrantsEnabled` in +`deploy/keycloak/realm-cwl.json` has been set to `false` as a fail-closed measure — the +mechanism this ADR's Decision (point 1, below) turned on must not ship per the RFC 9700/10017 +finding above, and this PR was not yet merged/deployed, so nothing live depended on it staying +`true`. Decision point 1 is left unedited below as the historical record of what was originally +decided; it no longer describes the current config value. Keep Direct Access Grants +disabled. The replacement in ADR 0019 must not re-enable ROPC. + +**Status correction (2026-09-07):** The earlier Accepted label was premature. +The original proposal and corrections remain here as history. The +replacement stays Proposed until protected acceptance; retaining the +product goal does not accept the disabled mechanism. + +## Context + +Product direction for naruon's login/signup surface was clarified twice. +First: only the *page/form* should be naruon's own; Keyverse remains the +identity backend. On review of a Keycloak-theme-reskin implementation, the +product owner rejected it outright: *"아니 그리고 누가 Naruon을 Theme +붙이겠대"* ("who ever said [they wanted] a Theme attached to naruon") — a +reskinned Keycloak theme is still Keycloak's own server rendering the +response, which is exactly what was not wanted, disguised or not. The +re-confirmed requirement: naruon's own frontend renders 100% of the +login/signup UI with zero Keycloak-rendered HTML anywhere in the loop, and +naruon's own backend talks to Keyverse purely as an API backend. + +### What was ruled out, and why + +**A Keycloak-hosted page, reskinned or not**, fails the requirement by +construction — it is Keycloak's server producing the HTML the user's browser +receives, regardless of how closely its CSS matches naruon's design. This is +true whether the browser is redirected to it, shown it in a popup, or shown +it in an iframe. + +**A naruon-rendered WebAuthn ceremony against Keycloak as a headless API** +was investigated and is not achievable with Keycloak's current architecture +— not because Keyverse has not built it yet, but because of how Keycloak +implements WebAuthn. The authentication ceremony (as opposed to registration) +runs inside Keycloak's own `login-actions` flow, bound to a server-side +`AuthenticationSessionModel` that generates the challenge and later verifies +the posted assertion; Keycloak does not publish a REST pair ("give me a +challenge" / "here is my assertion") for the login ceremony outside that +flow. Even Keyverse's own passwordless *registration* path +(`docs/passwordless-policy.md`) ends the same way: after +`POST /registration/accounts`, Keycloak's `execute-actions-email` sends a +link that lands the user on a Keycloak-hosted required-action page to run +`webauthn-register-passwordless`, before redirecting back to naruon's +`passkey-complete` page. Only the redirect *target* is naruon's; the +ceremony page itself is Keycloak's. A fully naruon-rendered WebAuthn +ceremony would require Keycloak to expose that as a public API, which it +does not, or a custom Keycloak REST resource provider reimplementing the +ceremony's session/challenge handling — real, separately-scoped engineering, +not something achievable by naruon or by config alone. + +**Direct Access Grants (OAuth2 Resource Owner Password Credentials)** against +`/protocol/openid-connect/token` is the one mechanism Keycloak exposes as a +plain, stateless, public REST endpoint that fits "naruon's own form, naruon's +own backend, zero Keycloak HTML." It is also, deliberately, the option this +organization has worked hardest to avoid: [ADR-0002](0002-passwordless-local-accounts.md) +keeps ecosystem-local accounts passwordless-first and requires "explicit +security/product review and migration evidence" to change that boundary; +`services/account_unification/app/relying_party.py` hard-rejects +`directAccessGrantsEnabled: true` for any RP that registers dynamically; and +`docs/CWL-MASTER-CONTEXT.md` states the ecosystem-wide direction as +"eliminate passwords." The product owner acknowledged this tension directly +when re-confirming the requirement, and accepted it explicitly for this one +integration: naruon's process may transiently hold a plaintext password in +memory for the single request that forwards it to Keycloak's token endpoint, +provided it is never logged, cached, or persisted. + +## Historical mechanism proposal (disabled) + +1. `naruon-web`'s `directAccessGrantsEnabled` is `true` in + `deploy/keycloak/realm-cwl.json`. This is *this* ADR's "explicit + security/product review" satisfying ADR-0002's own amendment clause — it + is a scoped, named exception, not a reversal of ADR-0002's default. +2. No other change is made to `browserFlow`, `browser-passwordless-forms`, + or `browser-passwordless-credentials`; `scripts/validate_realm.py` still + passes unmodified, because none of its checks concern the direct-grant + path. +3. The account-unification service's dynamic RP registration validator is + **not** changed. It continues to hard-reject + `directAccessGrantsEnabled: true` for every RP that registers through + `POST /relying-parties` — `naruon-web` is a hand-authored client in the + portable realm, not a dynamically-registered one, and this ADR does not + extend the exception to any other or future RP. +4. Companion naruon-repo work (tracked there, not here) adds naruon's own + email/password form and a backend route + (`frontend/src/app/auth/password/login/route.ts`) that POSTs + `grant_type=password` to Keycloak's token endpoint server-side, using the + same SSRF-hardened token-endpoint client already used for the + authorization-code exchange. The password exists only in that one + request's memory; it is never logged (failures are recorded by a fixed + reason string, never with the credential) and never written to a cookie, + session, or datastore naruon controls. + +## Historical delivery discussion (superseded by the correction above) + +**Update (2026-09-02):** the credential-issuance gap this section describes +is now closed by [ADR-0018](0018-naruon-password-credential-issuance.md) +(`POST /registration/accounts/password`, gated by its own third bearer +token). The rest of this section is kept as written for the historical +record of what ADR-0017 alone did and did not deliver. + +Flipping `directAccessGrantsEnabled` does not, by itself, let any real user +sign in. **No account in the `cwl` realm has a password credential today.** +`POST /registration/accounts` explicitly refuses to accept or create one +(`docs/passwordless-policy.md`: "It does not accept or create a password"), +and `resetPasswordAllowed` stays `false`. Every Direct Access Grants attempt +against the current realm fails closed with `invalid_grant` — correctly and +safely, since there is nothing to authenticate against — regardless of +whether naruon's client code is exactly right. + +Making the flow function end-to-end needs one more, separately-reviewable +keyverse change: some way for a user to obtain a password credential — a new +registration path that also sets a password, an admin-driven credential +reset, or a self-service "add a password" action on an already-passwordless +account. That change is a materially bigger, more security-relevant +decision than this one (it decides whether Keyverse issues passwords to +local accounts at all, which is the exact boundary ADR-0002 protects) and is +explicitly **out of scope for this slice**. It is recorded here as the +tracked blocker for the next iteration, not implemented. + +## Historical expected consequences (not delivered) + +- naruon's login form and backend route are real and correctly built against + the standard OAuth2 ROPC contract; they will start authenticating real + users the moment a keyverse-side credential-issuance path exists, with no + further naruon-side change required. +- Until that path exists, naruon's password login always returns a generic + "invalid credentials" error — indistinguishable, by design, from an + actually-wrong password (the token endpoint's non-2xx responses are + collapsed into one message to avoid a user-enumeration or + configuration-probing oracle). +- `naruon-web` now accepts two authentication paths with different trust + models: Direct Access Grants for naruon-native local accounts (this ADR), + and the existing `browser-passwordless`/authorization-code redirect for + federated identity (employer ADFS, external IdPs) — federation cannot use + ROPC, since a brokered identity has no local password to submit. Both + remain available in naruon's UI, serving different account types. +- If a future genuine "naruon renders 100% of a WebAuthn ceremony" capability + is built, it would most likely require a custom Keycloak REST resource + provider (a real SPI/Java development effort, plus the image-build and + provider-pinning process that implies) — a candidate for later work, not + assumed available today. + +## References + +Hodges, J., Jones, J. C., Jones, M. B., Kumar, A., & Lundberg, E. (Eds.). +(2021, April 8). *Web Authentication: An API for accessing Public Key +Credentials Level 2* (W3C Recommendation), §7 WebAuthn Relying Party +Operations — the registration and authentication ceremonies Keycloak +implements as server-orchestrated flows, not as a public headless API. +https://www.w3.org/TR/2021/REC-webauthn-2-20210408/ + +Hardt, D. (Ed.). (2012). *The OAuth 2.0 authorization framework* (RFC 6749), +§4.3 Resource Owner Password Credentials Grant. +https://doi.org/10.17487/RFC6749 + +Internet Engineering Task Force. (2025, January). *OAuth 2.0 security best +current practice* (RFC 9700, BCP 240), §2.4 Resource Owner Password +Credentials Grant — the finding behind this ADR's Correction, above. +https://www.rfc-editor.org/rfc/rfc9700.html + +Internet Engineering Task Force. (2026, August). *OAuth 2.0 for browser-based +applications* (RFC 10017), §7.3 Resource Owner Password Credentials Grant. +https://www.rfc-editor.org/rfc/rfc10017.html + +Keycloak. (n.d.). *Server administration guide* (Version 26.7.1), Direct +Access Grants. https://www.keycloak.org/docs/latest/server_admin/ diff --git a/docs/adr/0018-naruon-password-credential-issuance.md b/docs/adr/0018-naruon-password-credential-issuance.md new file mode 100644 index 0000000..656ec4a --- /dev/null +++ b/docs/adr/0018-naruon-password-credential-issuance.md @@ -0,0 +1,225 @@ +# ADR-0018: Scoped password-credential issuance so naruon's signup form actually logs in + +**Status:** Proposed; historical password mechanism blocked. +**Review boundary (2026-09-07):** This is unmerged PR #128 work, not +accepted protected-main policy. The product-owned forms requirement is +retained; [ADR 0019](0019-product-owned-passkey-ceremonies.md) proposes +its passwordless replacement. Historical mechanism and tradeoff text below +does not authorize enabling the password route. +**Date:** 2026-09-02 +**Decision owner:** Keyverse maintainers +**Scope:** A new, narrowly scoped account-unification endpoint that creates a +`cwl`-realm user with an immediately usable password credential, callable +only by naruon. Does not touch `browserFlow`, does not change any other RP's +capabilities, and does not add self-service password reset, email +verification, or CAPTCHA-equivalent abuse hardening — see "Deferred." + +## Correction (2026-09-03) + +[ADR-0017](0017-naruon-owned-password-form.md)'s Correction disabled `naruon-web`'s +`directAccessGrantsEnabled` (RFC 9700 §2.4 / RFC 10017 §7.3: the Resource Owner +Password Credentials grant this ADR's "immediately usable password credential" was +built for). That leaves the account this endpoint creates with no way to log in at +all — the bound `browser-passwordless` flow accepts only passkeys +(`services/account_unification/tests/test_realm_policy.py::test_bound_browser_flow_rejects_password_authenticator`), +and Direct Access Grants (the only mechanism that could use a password credential) +is off. + +`POST /registration/accounts/password` (`app/password_registration.py`) now fails +closed with `503` behind the module constant `PASSWORD_CREDENTIAL_LOGIN_AVAILABLE += False`, rather than create accounts nothing can authenticate into. The shared +runtime Keycloak client no longer allowlists or implements `reset-password`; +therefore the unavailable route cannot leave credential-reset authority dormant +in a reusable adapter. A future standards-compliant replacement must introduce +and review its own least-privilege owner contract rather than flip this gate. + +## Context + +[ADR-0017](0017-naruon-owned-password-form.md) enabled `directAccessGrantsEnabled` +for `naruon-web` so naruon's own login form could authenticate against +Keycloak's token endpoint without ever showing Keycloak-rendered HTML. That +ADR left a gap open deliberately: flipping the client flag does not, by +itself, let anyone log in, because **no account in the `cwl` realm has a +password credential** — `POST /registration/accounts` +(`app/registration.py`) explicitly creates accounts without one, and +`resetPasswordAllowed` stays `false`. naruon's login route was therefore +real but non-functional against the live realm. This ADR closes that gap: +naruon also needs "로그인 및 회원가입" (login *and* signup) working, per the +original product ask. + +## What the naruon signup form needs + +Naruon's own signup form must be able to create an account with a password +credential, server-side, with zero Keycloak-rendered HTML — the same +constraint ADR-0017 already established for login. The two realistic +mechanisms: + +### Rejected as naruon's own integration: raw Keycloak Admin REST from naruon + +naruon's backend could call Keycloak's Admin REST API +(`POST /admin/realms/cwl/users` + `PUT .../reset-password`) directly if it +held an admin/service-account credential. [ADR-0008](0008-keyverse-rp-authorization-boundary.md) +already settles this: "No downstream application receives Keycloak Admin +credentials to compensate for that gap." Handing naruon an admin-scoped +Keycloak client secret would let it create, modify, or delete *any* user or +realm object — a blast radius wildly out of proportion to "let a user sign +up with a password." Rejected outright, not reconsidered here. + +### Historical choice: extend the account-unification admin proxy + +`services/account_unification` already exists precisely to give product +backends narrow, purpose-built admin capabilities without an admin +credential: `POST /registration/accounts` proves the pattern for +passwordless signup, gated by its own dedicated bearer token +(`registration_api_token`, distinct from `operator_api_token`), calling +Keycloak Admin REST only through `ProductHttpAdminApi`'s allow-listed path +guard (`_ADMIN_PATH_PATTERNS`). Adding a sibling endpoint that does the +credential-bearing equivalent — create user, then set a password — reuses +every piece of existing infrastructure (bearer-token auth pattern, rate +limiter, email validation, rollback-on-failure, path allow-listing) instead +of building a second admin proxy from scratch. This is the smaller, more +reviewable diff, and it keeps every admin credential inside the one service +whose whole job is holding them. + +A **separate standalone service** was considered and rejected: it would +duplicate account-unification's Keycloak client, config loading, and +auth-dependency plumbing for no isolation benefit — the new endpoint carries +no different trust level than the existing registration surface; it is +authorized by, and shares infrastructure with, the same admin proxy. + +### Self-registration vs. invite-only + +Self-registration (any caller with the token can submit any email) was +chosen over an invite-only flow (pre-provisioned invite tokens, admin +approval) for this first slice, matching `POST /registration/accounts`'s own +existing model — naruon already builds product accounts self-service, and +introducing an invite system here would be new product surface this ADR has +no mandate to design. The tradeoff is an open signup-abuse surface, which +this ADR does not fully close (see "Deferred"). + +## Historical mechanism proposal (disabled) + +1. `POST /registration/accounts/password` (`app/password_registration.py`), + authenticated by a **third**, independent bearer token + (`password_registration_api_token`) — distinct from both + `operator_api_token` and `registration_api_token`, checked with + `hmac.compare_digest` the same way. Naruon's backend is the only holder of + this token; no other RP is provisioned one, and nothing in this service + authorizes any other RP to acquire this capability implicitly. +2. Request: `email_address`, `password` (12–128 chars), optional + `first_name`/`last_name`. Response: `account_id`, `email_address` — never + the password. +3. Implementation: `ProductAdminApi.reset_password(user_id, password)` + (`app/product_keycloak_client.py`) calls Keycloak's + `PUT /admin/realms/cwl/users/{id}/reset-password` with + `{"type": "password", "value": password, "temporary": false}` — + *non-temporary*, deliberately, so the account is usable immediately + without a forced first-login password change (there is no + password-change UI in this passwordless-first realm to force one into). + The new admin path is added to the existing `_ADMIN_PATH_PATTERNS` + allow-list, so a compromised/misconfigured caller still cannot reach any + Keycloak Admin REST route this service does not already expose. +4. `deploy/keycloak/realm-cwl.json` gains `"passwordPolicy": + "length(12) and notUsername and notEmail"` — realm-enforced, independent + of the endpoint's own length/match validation, so a bug or a future + caller of `reset_password` cannot silently skip the minimum. This policy + is inert for every other flow (the browser flow has no password + authenticator to apply it to). +5. Account creation, then credential-set, then rollback-on-failure — the + same three-step shape as `POST /registration/accounts`'s + create-then-`execute-actions-email`-then-rollback. A failure setting the + password deletes the just-created account rather than leaving an + unusable orphan. +6. A per-peer fixed-window rate limit (30 attempts / 5 minutes), duplicated + from `registration.py` rather than extracted into a shared utility — the + existing codebase already keeps this state module-local per registration + surface, and extracting it would have forced touching that module's + already-100%-covered, monkeypatch-coupled tests for no functional gain. + +## Security tradeoffs made explicitly + +- **Service-account credential scope.** `reset_password` reuses the same + confidential `account-unification-svc` Keycloak service-account credential + every other admin operation in this service already uses — no new, + wider-scoped Keycloak client was created. The blast radius of a leaked + `password_registration_api_token` is bounded to "create a `cwl` user with + an attacker-chosen password" (and, transitively, whatever RBAC the + `member` role already grants) — not "call any Admin REST endpoint," because + `_ADMIN_PATH_PATTERNS` still gates every call this client can make. +- **Password policy enforcement is two-layered.** The endpoint rejects a + too-short or email-matching password before any Keycloak call (fast, + precise error messages); the realm `passwordPolicy` rejects it again + server-side regardless of what the application layer does. Neither layer + alone is trusted as sufficient. +- **Abuse/rate-limiting surface.** The per-peer fixed-window limiter bounds + brute-force account creation from one source IP but does not stop a + distributed attempt, does not verify the submitted email is reachable + before creating the account, and has no CAPTCHA-equivalent challenge. This + is a materially weaker abuse posture than `POST /registration/accounts` + already has implicitly (that flow's account is unusable until the email + link is clicked; this flow's account works immediately on creation). + Explicitly accepted as this slice's tradeoff, not overlooked — see + Deferred. +- **No password credential exists for accounts already created via + `POST /registration/accounts`.** This endpoint's accounts and the + passwordless-enrollment endpoint's accounts remain two disjoint + populations; there is no path here for a passwordless user to add a + password, nor for a password user to add a passkey. Unifying them is out + of scope. + +## Deferred (not shipped in this slice) + +- **Email verification.** Accounts are created with `emailVerified: false` + and no `VERIFY_EMAIL` required action, so an unverified, even + non-existent, address can be signed up and immediately used. A follow-up + should send a verification link post-signup and decide whether to + require it before granting write access anywhere downstream. +- **CAPTCHA-equivalent / stronger abuse detection.** Only the existing + per-peer fixed-window limiter applies. No device fingerprinting, no + distributed rate limiting, no anomaly detection. +- **Self-service password reset or change.** `resetPasswordAllowed` stays + `false`. A user who forgets a password issued this way has no recovery + path yet. +- **Merging a password identity with an existing passwordless identity for + the same person.** [ADR-0003](0003-identity-matching.md)'s + verified-email-match precedence is not wired to this endpoint. + +Each of the above is a real, separately-reviewable piece of work, not an +oversight; shipping all of it in this slice would have meant reversing or +extending several other accepted decisions (self-service password reset, +verified-email merge policy) without the review those decisions themselves +require. + +## Historical expected consequences (not delivered) + +- naruon's login (ADR-0017) and signup (this ADR) are now both real and + connected end-to-end: an account created through + `POST /registration/accounts/password` can immediately authenticate + through `naruon-web`'s Direct Access Grants. +- Every other RP is unaffected: no new capability, client attribute, or + Admin REST route is reachable by anyone who does not hold naruon's + specific `password_registration_api_token`. +- The three account-unification bearer tokens (`operator_api_token`, + `registration_api_token`, `password_registration_api_token`) must all + remain pairwise distinct — enforced at config-load time + (`app/config.py`), so a misconfiguration that reuses a token fails closed + at startup rather than silently widening a caller's authority. + +## References + +Hardt, D. (Ed.). (2012). *The OAuth 2.0 authorization framework* (RFC 6749), +§4.3 Resource Owner Password Credentials Grant. +https://doi.org/10.17487/RFC6749 + +Internet Engineering Task Force. (2025, January). *OAuth 2.0 security best +current practice* (RFC 9700, BCP 240), §2.4 Resource Owner Password +Credentials Grant — the finding behind this ADR's Correction, above. +https://www.rfc-editor.org/rfc/rfc9700.html + +Internet Engineering Task Force. (2026, August). *OAuth 2.0 for browser-based +applications* (RFC 10017), §7.3 Resource Owner Password Credentials Grant. +https://www.rfc-editor.org/rfc/rfc10017.html + +Keycloak. (n.d.). *Server administration guide* (Version 26.7.1), Password +policies; Admin REST API — reset a user's password. +https://www.keycloak.org/docs/latest/server_admin/ diff --git a/docs/adr/0019-product-owned-passkey-ceremonies.md b/docs/adr/0019-product-owned-passkey-ceremonies.md new file mode 100644 index 0000000..e72fd03 --- /dev/null +++ b/docs/adr/0019-product-owned-passkey-ceremonies.md @@ -0,0 +1,208 @@ +--- +status: proposed +date: 2026-09-07 +decision-makers: [Keyverse maintainers] +informed: [LineageWeave maintainers, Naruon maintainers] +asr_triggers: [security, availability, maintainability, evolvability] +--- + +# ADR-0019: Product-rendered passkey ceremonies with Keyverse verification + +## Context and Problem Statement + +A person must be able to sign in, register, and recover access using the +product's own forms. Keyverse must continue to own the account, credential +verification, recovery authority, and token issuance. A themed issuer page, +popup, or iframe does not meet the rendering requirement. + +Protected `main` at `7d9151cd2da260e118020c938c7358e2ee75d541` provides +passwordless registration followed by issuer-rendered required actions. +PR #128 at `e1cf0807d6b15e8d8300eb252533aa05b20b93c9` keeps password +registration unavailable and has no product-rendered ceremony implementation. +ADRs [0017](0017-naruon-owned-password-form.md) and +[0018](0018-naruon-password-credential-issuance.md), formerly numbered +0014/0015 in this PR, preserve the rejected password mechanism's history. +This proposal neither accepts them nor changes the deployed browser flow. + +## Decision Drivers + +- Product ownership of every local-account form, including failure and recovery. +- ADR 0002's passwordless policy and ADR 0008's identity/authorization boundary. +- Credential verification by the existing engine, with no copied verifier or + consumer credential database. +- Exact origin, client, session, and credential scope; migration without lockout. +- Independently verifiable release and rollback before consumer adoption. + +## Considered Options + +1. Issuer-rendered pages in a theme, iframe, popup, or webview. +2. Password grants or consumer-held Keycloak Admin credentials. +3. A Keyverse-owned Keycloak provider exposing bounded ceremony steps while + retaining Authorization Code + PKCE and native credential verification. + +## Decision Outcome + +Choose option 3 as the implementation direction, subject to the confirmation +gates below. Extend the engine through its REST, Authentication, Required +Action, and Action Token interfaces. One version-pinned provider module is +the proposed packaging boundary; no additional authentication service is +required. Java is justified only by the Keycloak extension ABI. It does not +authorize adding a Python service or rewriting cryptographic verification in +another language. + +The extension guide documents those interfaces, not a ready-made headless +passkey API. Reusing them for product rendering is this proposal's design +inference. Keycloak 26.3.2 is the observed owner runtime and current deployment +pin; latest documentation describes 26.7.3 and cannot prove 26.3.2 compatibility. +Implementation must compile against and exercise the exact selected engine +version, including any separately reviewed security upgrade. + +### Product and owner contract + +The following are logical operations, not released endpoint names or an +existing schema. Before consumer implementation, publish a versioned JSON +schema and conformance fixtures from Keyverse. + +| Operation | Product work | Keyverse authority and completion | +|---|---|---| +| Login | Establish a backend-held OAuth transaction; render passkey instructions; call `navigator.credentials.get()` and submit the assertion. | Admit the client, suspend the original authentication session, verify the assertion with the engine, finish required actions, and resume that same authorization flow. | +| Signup | Render account and email-verification forms; call `navigator.credentials.create()` for enrollment. | Reuse registration validation and initialization compensation; verify email ownership and attestation; store the credential in the engine and complete enrollment. | +| Recovery | Render proof entry, replacement enrollment, and a clear completion or retry state. | Verify a previously bound recovery method; permit only replacement enrollment; notify the account owner, rotate recovery material, and revoke affected credentials/sessions. | + +Protocol redirects may occur without displaying issuer-rendered local-account +forms. The registered callback receives the normal authorization code. The +product backend redeems it with PKCE and validates the resulting OIDC session; +the ceremony API never returns an alternative JWT or bypasses token issuance. +Federated IdPs retain their own authentication pages and policies. + +```mermaid +sequenceDiagram + participant Person as Product browser + participant Product as Product backend + participant Owner as Keyverse provider + participant Engine as Keycloak engine + Person->>Product: Start a browser-bound OAuth transaction + Product-->>Person: Authorization Code + PKCE navigation + Person->>Engine: Registered authorization request + Engine->>Owner: Admit and suspend the same authentication session + Owner-->>Person: Return to the registered product ceremony page + Product->>Owner: Authenticate client and request bounded options + Product-->>Person: Render product form and WebAuthn options + Person->>Product: Submit authenticator proof + Product->>Owner: Submit proof with browser and transaction binding + Owner->>Engine: Verify and resume the original flow + Engine-->>Person: Registered callback with authorization code + Person->>Product: Complete the saved browser transaction + Product->>Engine: Redeem code with PKCE and validate session +``` + +### Origin and RP ID migration + +Each admitted client needs exact product origins, callback destinations, and +an explicit RP ID profile. No wildcard, caller-supplied origin, public-suffix +RP ID, or inferred production domain is allowed. A credential scoped to the +issuer does not automatically work at a product origin: WebAuthn constrains +the RP ID to the invoking origin's domain or a valid registrable suffix. + +Before enabling a product, inventory its existing credential scopes privately +and prove compatibility or reenroll while a working authenticator remains. +An unrelated product domain requires its own compatible credential scope; +never widen an existing scope or copy a credential between accounts. The +implementation must demonstrate how the pinned engine separates these scopes. +If it requires new persisted credential metadata, decide that schema and +migration before coding it. Until then, incompatible origin profiles stay +unavailable; the full product requirement remains incomplete. + +### Interaction and mutation invariants + +Every interaction binds the realm, admitted confidential client, product +origin, registered callback, authentication session, OAuth state/nonce and +PKCE challenge, browser transaction, intent, challenge, and expiry. The backend +authenticates as that client and checks its existing browser transaction before +fetching or completing a step. A leaked opaque handle alone grants no authority. +Never accept an account identifier supplied by the completion request as the +identity established by the saved interaction. + +Reuse engine session/action-token facilities for lifetime and consumption; +prove atomic single-use behavior across concurrent requests and engine nodes. +A stale, cancelled, replayed, mismatched, disabled-account, or unavailable +interaction cannot issue a code, verify email, bind a credential, or alter an +account. A timed-out completion is reconciled against the owner transaction; +it must not blindly repeat account creation or credential binding. + +No password, client secret, private key, assertion, recovery proof, or opaque +interaction handle belongs in a log, analytics event, browser storage, or +repository artifact. Product responses expose bounded steps and public +WebAuthn options only. Error copy must avoid account enumeration and preserve +an actionable retry/cancel path. Apply CSRF protection and abuse controls to +start, proof, resend, and completion operations, including distributed calls. + +### Recovery assurance + +A remaining bound authenticator may authorize replacement enrollment after +reauthentication. For loss of all authenticators, the proposed minimum is a +saved recovery code plus an issued code sent to a previously verified recovery +address. Store saved codes hashed, throttle proof attempts, consume them once, +and notify on recovery and replacement. These requirements draw on NIST's +account-recovery guidance; they do not establish an AAL certification. + +An email match, newly supplied address, ordinary product session, or operator +registration token alone cannot authorize recovery. Users without established +recovery material need an explicitly governed recovery route; do not create +a password fallback or claim this case is solved. Keycloak's backup second +factor feature is not assumed to implement this lost-only-passkey policy. + +### Consequences + +- Product forms can remain consistent while one owner verifies credentials + and issues tokens. Native engine storage and existing registration are reused. +- The provider becomes an engine-version compatibility obligation. Cross-origin + migration and recovery add real implementation and operational work. +- Availability depends on the owner ceremony service. Its failure must leave + an honest unavailable/retry state rather than an insecure fallback. + +### Confirmation + +No implementation or release is claimed by this document. Complete these +gates in order, retaining exact source, image, schema and consumer versions: + +1. **Engine experiment:** implement and exercise one login start/complete flow + against the pinned engine, with an actual product-origin WebAuthn assertion + and normal code/PKCE exchange. Demonstrate verifier reuse and required-action + handling; no scraped HTML or copied authentication engine. +2. **Authority negatives:** wrong client/origin/RP ID/session/intent/PKCE; + tampered assertion or attestation; expired/replayed/cancelled interaction; + missing browser binding; disabled or merged account; concurrent double + completion across two engine instances. Each must produce zero unauthorized + account, credential, verification, or token side effects. +3. **Enrollment and recovery:** unverified email, resend/replay, mail failure, + partial initialization, lost-only-passkey proof combinations, exhausted + attempts, concurrent revocation, and crash-after-commit reconciliation. + Prove no orphan account or bypassed required action. +4. **Product acceptance:** real authorized account login/signup/recovery, + keyboard and accessible error states, all eight requested locales and narrow + layouts in the consumer. Record real browser evidence without credentials or + identifiable records. Owner tests cannot replace these rendered journeys. +5. **Release/rollback:** protected approval and full security/test gates, + immutable provider and engine image, SBOM/provenance and schema release; + rehearse disabling new ceremonies without deleting accounts or credentials, + draining/invalidation of interactions, and restoring the previous engine. + Adopt only the released owner contract in each product. + +## Pros and Cons of the Options + +- Issuer pages reuse the most existing code, but fail the explicit rendering + requirement, even inside a product frame. +- Password grants are simple to call, but conflict with current OAuth browser + guidance and the passwordless policy. Giving a consumer Admin credentials + expands authority beyond login and is rejected independently. +- The provider adds deployment and compatibility work, but is the smallest + identified owner extension that can preserve both rendering and credential + authority. Its feasibility still needs the engine experiment above. + +## More Information + +The dated sources and observed runtime boundary are recorded in +[the ceremony doctoring record](../doctoring/2026-09-07-product-passkey-ceremonies.md). +ADRs 0017/0018 remain Proposed historical alternatives; accepting this proposal +requires an explicit subsequent status/lineage change, never a silent promotion. diff --git a/docs/adr/README.md b/docs/adr/README.md index aad6a6a..0cfff48 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,6 +18,9 @@ authorization boundary and is not rewritten by that expansion. | [0007](0007-automation-authority.md) | Autonomous development remains separate from review/merge/release authority | Accepted | | [0008](0008-keyverse-rp-authorization-boundary.md) | Every non-fork RP explicitly validates Keyverse identity and manages ABAC/RBAC at its own boundary | Accepted | | [0013](0013-mcp-oauth-client-authorization.md) | Use Keycloak-backed authorization code plus PKCE and exact resource binding for MCP clients | Proposed | +| [0017](0017-naruon-owned-password-form.md) | Historical naruon password-form proposal; product-owned rendering requirement retained | Proposed; password mechanism blocked | +| [0018](0018-naruon-password-credential-issuance.md) | Historical naruon password-issuance proposal; endpoint remains unavailable | Proposed; no issuance authority enabled | +| [0019](0019-product-owned-passkey-ceremonies.md) | Product-rendered passkey login, signup and recovery with Keyverse verification | Proposed; pinned-engine experiment and release required | ADR numbering note: protected `main` currently ends at ADR-0008. ADR-0009 is proposed in the open LineageWeave claim-profile PR, and ADR-0010 through @@ -26,6 +29,14 @@ the next intended number without renumbering parallel work; it must be reconciled after those PRs land, and none of the absent records is accepted architecture on protected `main` yet. +On 2026-09-07, the complete open-PR file inventory also showed 0014–0016 +reserved by PR #129 and another 0014 in PR #130. This PR's original 0014/0015 +records are therefore renumbered to 0017/0018; their histories remain in those +files and in PR #128's immutable prior commits. ADR 0019 uses the next observed +unclaimed number. The separate #129/#130 collision still needs owner repair. +Recheck live reservations before integration; no open proposal is promoted +to Accepted by this numbering correction. + ## ADR triggers Create or update an ADR for changes to authenticator policy, federation hub ownership, identity matching evidence, merge/tombstone semantics, SCIM authority, directory write/trust policy, RP credential/claim ownership, desired-state mutation order, persistent state, secret handling, or autonomous/release authority. diff --git a/docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md b/docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md new file mode 100644 index 0000000..18749d4 --- /dev/null +++ b/docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md @@ -0,0 +1,124 @@ +# naruon password-login ROPC standards correction + +**Date:** 2026-09-03 +**Status:** Implementation evidence for the active PR (`keyverse#128`); not +protected-main or live Keycloak acceptance + +## Scope + +This record documents disabling `naruon-web`'s Direct Access Grants +(ADR-0017) and the `POST /registration/accounts/password` signup endpoint +that depended on it (ADR-0018), and the cascading fix once disabling the +grant alone left password-only signups unable to authenticate. It adds no +new authentication mechanism -- the underlying "naruon renders its own +login/signup UI, Keyverse stays the identity backend" product requirement is +unchanged and remains a real, open successor-design question (Authorization +Code + PKCE in an in-app browser view, or a custom Keycloak REST resource +provider for headless passkey/WebAuthn), tracked against ADR-0017, not +resolved here. + +## Interpretation + +- **Standards requirement:** RFC 9700 §2.4 states clients and authorization + servers MUST NOT use the OAuth 2.0 Resource Owner Password Credentials + (ROPC) grant. RFC 10017 §7.3 independently repeats that prohibition for + browser-based OAuth/OIDC applications specifically and requires a + redirect-based flow such as Authorization Code + PKCE instead. Both + post-date RFC 6749 (which merely defined the ROPC grant in 2012, before the + subsequent decade of threat-model findings that led to its deprecation). +- **Why the earlier acceptance didn't settle it:** ADR-0017's original + Decision treated the naruon product owner's explicit risk acceptance as + satisfying ADR-0002's "explicit security/product review" amendment clause. + A documented risk acceptance can record an organizational deviation from a + stylistic or architectural preference; it cannot make a MUST-NOT-prohibited + grant type standards-compliant. `keyverse#128` was still a mutable, + unreleased contract when this was found -- nothing live depended on the + grant staying enabled -- so the correct move was to repair the boundary + before release rather than accept the debt permanently by merging it. +- **The cascading bug this record's fix addresses:** disabling + `directAccessGrantsEnabled` alone (first pass) is standards-correct but + incomplete on its own -- `POST /registration/accounts/password` still + created accounts with `required_actions=[]`, which was only safe when an + immediately usable password credential could actually authenticate via + ROPC. With the grant off, those accounts had no path in: not ROPC (blocked), + not the bound `browser-passwordless` flow (accepts only passkeys, + `services/account_unification/tests/test_realm_policy.py::test_bound_browser_flow_rejects_password_authenticator`). + A standards-compliance fix is not complete until every artifact that + depended on the disabled mechanism being live is re-examined, not only the + artifact the original finding named. +- **Policy choice:** fail closed. The endpoint now returns `503` before any + Keycloak work, rather than create an account nothing can authenticate into. + `scripts/validate_realm.py` independently rejects a silent future + re-enable of the realm flag, so the two artifacts (code gate, realm config) + cannot drift back out of sync with each other or with the ADR's own status. +- **Implementation behavior:** both gates are single, deliberately flippable + points (`PASSWORD_CREDENTIAL_LOGIN_AVAILABLE` in + `services/account_unification/app/password_registration.py`, + `directAccessGrantsEnabled` in `deploy/keycloak/realm-cwl.json`) rather than + a rewrite of the account-creation, rollback, or rate-limiting logic + underneath, which stays intact and fully tested via monkeypatch for when a + replacement mechanism ships. + +## Evidence + +- **RED (conceptual, pre-fix state):** `directAccessGrantsEnabled: true` in + the committed realm export, `docs/adr/README.md`'s index showing ADR-0017 + as a bare "Accepted", and `POST /registration/accounts/password` creating + `required_actions=[]` accounts -- all present simultaneously, together + describing a live ROPC grant plus a signup path that assumed it worked. +- **GREEN, pass 1 (`79fe43d`):** `directAccessGrantsEnabled` set to `false`; + ADR-0017's index row and `deploy/keycloak/README.md` updated to match the + ADR's own status line. +- **GREEN, pass 2 (`44f0cb9`):** `PASSWORD_CREDENTIAL_LOGIN_AVAILABLE = False` + added, gating `register_account_with_password` before any account-creation + work; `test_registration_fails_closed_by_default` added + (`services/account_unification/tests/test_password_registration.py`) + covering the new default branch, with the existing happy-path/rollback/ + rate-limit tests preserved by monkeypatching the constant `True`; + `scripts/validate_realm.py` gained a `directAccessGrantsEnabled` check for + `naruon-web`, covered by + `test_naruon_direct_access_grants_stays_disabled` + (`services/account_unification/tests/test_realm_policy.py`); ADR-0018 + gained a Correction section mirroring ADR-0017's. +- **GREEN, owner-boundary repair:** the shared production adapter no longer + allowlists or implements Keycloak's `reset-password` Admin REST path. A focused + regression proves that path is rejected, while the unavailable registration + route remains fail closed and the existing Authorization Code + PKCE and + passwordless enrollment paths are unchanged. +- **Measured boundary:** `coverage run --branch --source=app -m pytest -q` + followed by `coverage report --show-missing --fail-under=100` reported 100% + statement and branch coverage (2,873 statements, 772 branches); `interrogate` + reported 100% docstring coverage; `ruff check app tests tools` passed + clean; `python scripts/validate_realm.py deploy/keycloak/realm-cwl.json`, + `make test`, `make validate-realm`, and + `tests/test_documentation_contract.py` all passed. +- **Not claimed:** this is not a standards-compliant replacement login or + account-recovery mechanism -- naruon's password-signup surface stays unavailable (`503`) + until one ships. Whether an Authorization Code + PKCE in-app-browser-view + flow or a custom Keycloak REST resource provider for headless + passkey/WebAuthn is buildable against Keycloak's `login-actions`-bound + ceremony (which has no public REST pair for the login ceremony specifically, + per ADR-0017's own Context section) is a real, separately-scoped design + question this record does not resolve. + +## References + +Hardt, D. (Ed.). (2012). *The OAuth 2.0 authorization framework* (RFC 6749), +§4.3 Resource Owner Password Credentials Grant. Internet Engineering Task +Force. https://doi.org/10.17487/RFC6749 + +Internet Engineering Task Force. (2025, January). *OAuth 2.0 security best +current practice* (RFC 9700, BCP 240), §2.4 Resource Owner Password +Credentials Grant. https://www.rfc-editor.org/rfc/rfc9700.html + +Internet Engineering Task Force. (2026, August). *OAuth 2.0 for browser-based +applications* (RFC 10017), §7.3 Resource Owner Password Credentials Grant. +https://www.rfc-editor.org/rfc/rfc10017.html + +Individual author attribution for both RFCs is intentionally omitted above: +this record cannot independently verify the exact editor list for either +document (RFC 10017 in particular predates no available training/knowledge +cutoff verification), so both are cited by issuing organization rather than +risk misattributing named individuals. Confirm the editor list directly from +the RFC Editor page before citing either document with named authors +elsewhere. diff --git a/docs/doctoring/2026-09-07-product-passkey-ceremonies.md b/docs/doctoring/2026-09-07-product-passkey-ceremonies.md new file mode 100644 index 0000000..a55d6cb --- /dev/null +++ b/docs/doctoring/2026-09-07-product-passkey-ceremonies.md @@ -0,0 +1,97 @@ +# Product-rendered passkey ceremonies: proposal evidence + +## Observed boundary, 2026-09-07 + +Protected Keyverse `main` was +`7d9151cd2da260e118020c938c7358e2ee75d541`. PR #128 was a draft at +`e1cf0807d6b15e8d8300eb252533aa05b20b93c9`, stacked on +`codex/keyverse-orchestrator-free-development` at +`e6da5dd3762b45acf4e0a70b672327f38f4ba04b`. Its unavailable password route +does not implement product-rendered authentication. The existing passwordless +registration initializes issuer-rendered email verification and WebAuthn +required actions. No product ceremony API or provider module was found in +that source tree. + +Read-only `kc.sh --version` inside the existing Colima services reported +Keyverse's owner engine as **26.3.2** and the separate LineageWeave engine as +**26.0.8**. No service was restarted or reconfigured for this research. A +running version is not immutable release, authenticated acceptance, or proof +that the two deployments may be consolidated safely. Docker could not inspect +the image at the running owner's recorded image ID; a rebuild or restart +therefore also needs a reproducible image/rollback receipt first. + +## Numbering and acceptance correction + +A complete open-PR file inventory found that PR #129 proposes ADRs +0014–0016 and PR #130 separately proposes another ADR 0014. PR #128's +password proposals therefore move from 0014/0015 to **0017/0018**, with +cross-references updated and original history retained. ADR **0019** records +the product-rendered passkey direction. These numbers were unused in the +observed open-PR inventory; recheck before integration. The separate +#129/#130 numbering conflict remains outside this PR's correction. + +The former Accepted labels did not prove owner acceptance: all these PR #128 +records remain **Proposed**. The historical proposals and their standards +corrections are retained; no password grant or credential-issuance capability +is enabled by changing documentation. + +## Standards and vendor evidence + +| Source | What it establishes | What it does not establish | +|---|---|---| +| RFC 9700 §2.4 and RFC 10017 §7.3 | Password grants must not be used for this browser OAuth path; a redirect-based code flow is the selected alternative. | That a product's custom ceremony extension is secure or implemented. | +| WebAuthn Level 2 §4 and §7 | Credential scope and origin/RP-ID checks constrain where a product can invoke a credential. | Automatic portability of issuer-scoped credentials to unrelated product domains. | +| Keycloak server development guide | REST, Authentication, Required Action, and Action Token extension points exist; extensions are version-coupled. | A released JSON challenge/completion API or compatibility between the current guide and 26.3.2. | +| Keycloak 26.3.2 source | `WebAuthnAuthenticator` prepares authentication input and delegates validation to the credential manager; `WebAuthnRegister` performs registration validation and storage. | A supported way to replace the expected issuer origin merely by wrapping the HTML flow. | +| NIST SP 800-63B-4 §4.2 | Recovery methods, proof combinations, throttling, consumption, and notifications must be designed explicitly. | Keyverse certification, an existing lost-only-passkey recovery service, or approval to recover from email matching alone. | + +The new provider is an engineering inference from those interfaces. Its first +acceptance experiment must prove native-verifier reuse at the actual product +origin and normal authorization-code issuance. If that experiment requires +new credential-scope persistence or a missing engine extension point, record +that owner decision before implementation instead of copying a verifier or +weakening origin checks. + +Context7 returned a quota error; it supplied no documentation evidence. +Primary sources and the versioned engine source were used instead. No live +credential, account body, or private source record was captured. + +## Verification status and next executable work + +This change only renumbers/corrects proposals and records the contract in +[ADR 0019](../adr/0019-product-owned-passkey-ceremonies.md). The authoritative +negative cases and release sequence live in its Confirmation section. +There is no ceremony implementation, runtime login, recovery, consumer UI, +coverage improvement, protected merge, or release result to report here. + +The next owner implementation is a version-pinned provider with one real +login start/complete experiment, followed by signup and recovery. Keep every +existing runtime and disabled password route unchanged until those gates pass. +Consumer delivery remains required in LineageWeave and Naruon after owner +release; a proposal or a successful issuer-page redirect is insufficient. + +## References (APA 7th) + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current practice for +OAuth 2.0 security* (RFC 9700). https://www.rfc-editor.org/rfc/rfc9700.html + +Parecki, A., De Ryck, P., & Waite, D. (2026). *OAuth 2.0 for browser-based +applications* (RFC 10017). https://www.rfc-editor.org/rfc/rfc10017.html + +Hodges, J., Jones, J. C., Jones, M. B., Kumar, A., & Lundberg, E. (Eds.). +(2021). *Web Authentication: An API for accessing Public Key Credentials +Level 2*. World Wide Web Consortium. https://www.w3.org/TR/webauthn-2/ + +Keycloak. (n.d.). *Server developer guide* (26.7.3; retrieved September 7, +2026). https://www.keycloak.org/docs/latest/server_development/index.html + +Keycloak. (n.d.). *WebAuthnAuthenticator* [Source code, version 26.3.2]. +https://github.com/keycloak/keycloak/blob/26.3.2/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java + +Keycloak. (n.d.). *WebAuthnRegister* [Source code, version 26.3.2]. +https://github.com/keycloak/keycloak/blob/26.3.2/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java + +Temoshok, D., Fenton, J. L., Choong, Y.-Y., Lefkovitz, N., Regenscheid, A., +Galluzzo, R., & Richer, J. P. (2025). *Digital identity guidelines: +Authentication and authenticator management* (NIST SP 800-63B-4). +https://doi.org/10.6028/NIST.SP.800-63B-4 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9604326..8b5f507 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,17 @@ # Keyverse product and technical gap baseline +> Owner ceremony proposal snapshot, 2026-09-07: protected `main` was +> `7d9151cd2da260e118020c938c7358e2ee75d541`; draft PR #128 was +> `e1cf0807d6b15e8d8300eb252533aa05b20b93c9`. Login/signup/recovery rendered +> by the product remain unimplemented. [ADR 0019](adr/0019-product-owned-passkey-ceremonies.md) +> specifies the next engine experiment, credential-scope migration, recovery +> proofs, negative tests, and release boundary. PR #128's conflicting 0014/0015 +> proposal numbers move to 0017/0018 and remain Proposed. The dated evidence +> [record](doctoring/2026-09-07-product-passkey-ceremonies.md) distinguishes +> the observed 26.3.2 owner engine from the separate 26.0.8 LineageWeave engine. +> No consolidation, authentication, release, or protected-merge acceptance is +> claimed. The older inventory below remains historical. + **Evidence snapshot:** 2026-08-21T16:47:10Z (UTC) **Repository:** `ContextualWisdomLab/keyverse` **Protected-main head observed:** `ce207dfd42975db61c82a5963e206fc1db14ac2b` diff --git a/scripts/validate_realm.py b/scripts/validate_realm.py index db6e6c5..260f86c 100644 --- a/scripts/validate_realm.py +++ b/scripts/validate_realm.py @@ -11,7 +11,9 @@ * RP and service-account clients exist without committed real secrets; * Keycloak 26 import compatibility excludes ``$`` annotation keys; * the ``basic`` scope provides ``sub`` and is a realm default; -* ``naruon-web`` is a bounded-token public PKCE client with required claims. +* ``naruon-web`` is a bounded-token public PKCE client with required claims; +* ``naruon-web`` does not enable Direct Access Grants (blocked pending a + standards-compliant replacement -- docs/adr/0014-naruon-owned-password-form.md). Usage: python scripts/validate_realm.py [path-to-realm.json] Exit 0 = valid, 1 = invalid (prints the failing checks). @@ -186,6 +188,13 @@ def validate(realm: dict) -> list[str]: errors.append("naruon-web must be a public (PKCE) client") if naruon.get("implicitFlowEnabled", False): errors.append("naruon-web must not enable the implicit flow") + if naruon.get("directAccessGrantsEnabled", False): + errors.append( + "naruon-web must not enable Direct Access Grants -- blocked by " + "docs/adr/0014-naruon-owned-password-form.md's Correction " + "(RFC 9700 SS2.4 / RFC 10017 SS7.3) pending a standards-compliant " + "replacement" + ) if naruon.get("attributes", {}).get("pkce.code.challenge.method") != "S256": errors.append("naruon-web must require PKCE S256") token_lifespan = _public_token_lifespan(naruon) diff --git a/services/account_unification/app/config.py b/services/account_unification/app/config.py index ecf9fe6..54064e4 100644 --- a/services/account_unification/app/config.py +++ b/services/account_unification/app/config.py @@ -28,6 +28,7 @@ KEY_REGISTRATION_ACTION_LIFESPAN_SECONDS = ( "registration_action_lifespan_seconds" ) +KEY_PASSWORD_REGISTRATION_API_TOKEN = "password_registration_api_token" KEY_AUDIT_DATABASE_PATH = "audit_database_path" MAX_REGISTRATION_ACTION_LIFESPAN_SECONDS = 3600 @@ -50,6 +51,11 @@ class ServiceConfig: registration_client_id: str | None = None registration_redirect_uri: str | None = None registration_action_lifespan_seconds: int = 900 + # A third, independent bearer credential: naruon's own signup form calls + # POST /registration/accounts/password through this token only. It grants + # no other capability and is never accepted as an operator or passwordless- + # registration token (see docs/adr/0015-naruon-password-credential-issuance.md). + password_registration_api_token: str | None = None audit_database_path: str = "/var/lib/account-unification/audit.db" merge_conflict_policy: str = "survivor_wins" # This is an invariant, not a deployer-selectable feature. The field remains @@ -189,6 +195,17 @@ def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: "config 'registration_api_token' must differ from " "'operator_api_token'" ) + password_registration_api_token = ( + store.get(namespace, KEY_PASSWORD_REGISTRATION_API_TOKEN) or None + ) + if password_registration_api_token is not None and password_registration_api_token in ( + operator_api_token, + registration_api_token, + ): + raise RuntimeError( + "config 'password_registration_api_token' must differ from " + "'operator_api_token' and 'registration_api_token'" + ) ( registration_client_id, registration_redirect_uri, @@ -231,6 +248,7 @@ def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: registration_action_lifespan_seconds=( registration_action_lifespan_seconds ), + password_registration_api_token=password_registration_api_token, audit_database_path=( store.get(namespace, KEY_AUDIT_DATABASE_PATH) or "/var/lib/account-unification/audit.db" diff --git a/services/account_unification/app/keycloak_client.py b/services/account_unification/app/keycloak_client.py index 4ab2d57..029ce92 100644 --- a/services/account_unification/app/keycloak_client.py +++ b/services/account_unification/app/keycloak_client.py @@ -417,4 +417,6 @@ def _to_keycloak_user(user: UserAccount) -> dict: payload["lastName"] = user.last_name if user.external_id is not None: payload["attributes"] = {"scim_external_id": [user.external_id]} + if user.required_actions is not None: + payload["requiredActions"] = user.required_actions return payload diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index 76ee5ea..263e322 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -21,6 +21,10 @@ from .config import load_service_config from .directory_federation import directory_federation_router from .federation import FederationService, federation_router +from .password_registration import ( + password_registration_auth_dependency, + password_registration_router, +) from .path_security import ( ScimPathValidationError, admin_path_security_dependency, @@ -102,6 +106,9 @@ def build_service(app: FastAPI) -> None: app.state.registration_action_lifespan_seconds = ( config.registration_action_lifespan_seconds ) + app.state.password_registration_api_token = ( + config.password_registration_api_token + ) app.state.ready = True @@ -212,6 +219,10 @@ def healthz() -> dict: registration_router, dependencies=[registration_auth_dependency], ) + app.include_router( + password_registration_router, + dependencies=[password_registration_auth_dependency], + ) return app diff --git a/services/account_unification/app/models.py b/services/account_unification/app/models.py index 2167fd2..5e98983 100644 --- a/services/account_unification/app/models.py +++ b/services/account_unification/app/models.py @@ -67,6 +67,10 @@ class UserAccount(BaseModel): last_name: str | None = None # SCIM provisioning source id, kept as a Keycloak user attribute. external_id: str | None = None + # ``None`` omits the field so Keycloak applies the realm's configured + # default required actions (e.g. passwordless enrollment). An explicit + # list, including ``[]``, overrides that default for this user. + required_actions: list[str] | None = None federated_identities: list[FederatedIdentity] = Field(default_factory=list) diff --git a/services/account_unification/app/password_registration.py b/services/account_unification/app/password_registration.py new file mode 100644 index 0000000..275932d --- /dev/null +++ b/services/account_unification/app/password_registration.py @@ -0,0 +1,247 @@ +"""Headless password-credential registration for naruon's own signup form. + +naruon renders its own email/password signup form and calls this endpoint +server-side; no Keycloak page is ever shown to the user, matching the same +zero-Keycloak-HTML constraint as its login route +(see docs/adr/0015-naruon-password-credential-issuance.md). Scoped to naruon +only by possession of a dedicated bearer token — distinct from the operator +token and from :mod:`app.registration`'s passwordless-enrollment token, so no +other capability transfers between them. + +Unlike ``POST /registration/accounts`` (passwordless, email-verification and +WebAuthn-enrollment link), this creates the account with an immediately +usable, non-temporary password credential — no email round-trip — so a +Direct Access Grants login right after signup succeeds. Email verification, +abuse detection beyond a per-peer rate limit, and CAPTCHA-equivalent +hardening are explicitly deferred; see the ADR's "not yet done" section. +""" +from __future__ import annotations + +import threading +import time +import hmac + +import httpx +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from pydantic import BaseModel, ConfigDict, Field + +from .models import UserAccount +from .product_keycloak_client import ProductAdminApi +from .registration import CONTROL_CHARACTER_PATTERN, _validated_email, _validated_name + +password_registration_router = APIRouter(prefix="/registration", tags=["registration"]) + +MIN_PASSWORD_LENGTH = 12 +MAX_PASSWORD_LENGTH = 128 + +PASSWORD_REGISTRATION_RATE_LIMIT_WINDOW_SECONDS = 300.0 +PASSWORD_REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS = 30 +_password_registration_attempt_lock = threading.Lock() +_password_registration_attempt_windows: dict[str, tuple[float, int]] = {} + +# docs/adr/0014-naruon-owned-password-form.md's Correction (2026-09-03, RFC 9700 +# SS2.4 / RFC 10017 SS7.3) disabled Direct Access Grants. An account created by +# this endpoint would carry a password credential nothing can use to log in -- +# the bound browser flow accepts only passkeys (services/account_unification/ +# tests/test_realm_policy.py::test_bound_browser_flow_rejects_password_authenticator). +# Flip back to True only alongside a standards-compliant replacement mechanism. +PASSWORD_CREDENTIAL_LOGIN_AVAILABLE = False +PASSWORD_LOGIN_BLOCKED_DETAIL = ( + "password registration temporarily unavailable: Direct Access Grants login " + "is blocked pending a standards-compliant replacement -- see " + "docs/adr/0014-naruon-owned-password-form.md's Correction" +) + + +class PasswordRegistrationRequest(BaseModel): + """One password-credential registration submission from naruon's signup form.""" + + model_config = ConfigDict(extra="forbid") + + email_address: str = Field(min_length=3, max_length=254) + password: str = Field(min_length=MIN_PASSWORD_LENGTH, max_length=MAX_PASSWORD_LENGTH) + first_name: str | None = Field(default=None, max_length=100) + last_name: str | None = Field(default=None, max_length=100) + + +class PasswordRegistrationResult(BaseModel): + """Public outcome after a password-credential account is created.""" + + account_id: str + email_address: str + + +def require_password_registration_token( + request: Request, + authorization: str | None = Header(default=None), +) -> None: + """Authenticate the dedicated password-registration bearer token.""" + expected = getattr(request.app.state, "password_registration_api_token", None) + if not expected: + raise HTTPException( + status_code=503, + detail="password registration authentication unavailable", + ) + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="password registration bearer token required", + headers={"WWW-Authenticate": "Bearer"}, + ) + presented = authorization[len("Bearer ") :].strip() + if not hmac.compare_digest(presented, expected): + raise HTTPException( + status_code=403, detail="invalid password registration token" + ) + + +password_registration_auth_dependency = Depends(require_password_registration_token) + + +def get_admin_api(request: Request) -> ProductAdminApi: + """Return the wired product Keycloak API from application state.""" + api = getattr(request.app.state, "keycloak_api", None) + if api is None: + raise HTTPException(status_code=503, detail="keycloak api unavailable") + return api + + +def reset_rate_limit_state() -> None: + """Clear process-local password-registration counters for deterministic tests.""" + with _password_registration_attempt_lock: + _password_registration_attempt_windows.clear() + + +def _registration_client_key(request: Request) -> str: + """Return the direct peer address used for process-local throttling.""" + return request.client.host if request.client is not None else "unknown-client" + + +def _record_registration_attempt(client_key: str) -> None: + """Enforce an independent fixed-window registration limit per caller.""" + now = time.monotonic() + with _password_registration_attempt_lock: + window_start, attempt_count = _password_registration_attempt_windows.get( + client_key, (now, 0) + ) + if now - window_start > PASSWORD_REGISTRATION_RATE_LIMIT_WINDOW_SECONDS: + window_start, attempt_count = now, 0 + attempt_count += 1 + _password_registration_attempt_windows[client_key] = ( + window_start, + attempt_count, + ) + if attempt_count > PASSWORD_REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS: + raise HTTPException( + status_code=429, + detail="registration temporarily rate limited", + ) + + +def _validated_password(password: str, email_address: str) -> str: + """Reject a password that is malformed or trivially guessable. + + Length is already bounded by ``PasswordRegistrationRequest``'s Field + constraints; Keycloak's realm ``passwordPolicy`` is the second, server- + side enforcement layer for the same minimum. + """ + if password.strip() != password or not password: + raise HTTPException(status_code=422, detail="invalid_password") + if CONTROL_CHARACTER_PATTERN.search(password): + raise HTTPException(status_code=422, detail="invalid_password") + if password.lower() == email_address.lower(): + raise HTTPException( + status_code=422, detail="password_must_not_match_email" + ) + return password + + +def _create_account_with_password( + api: ProductAdminApi, email_address: str, password: str, request_body: PasswordRegistrationRequest +) -> str: + """Create the user, then roll it back if the credential cannot be set.""" + try: + account_id = api.create_user( + UserAccount( + user_id="", + user_name=email_address, + email=email_address, + is_email_verified=False, + state="active", + first_name=_validated_name(request_body.first_name), + last_name=_validated_name(request_body.last_name), + # The realm's default required action (WebAuthn passwordless + # enrollment) is an interactive browser step that Direct + # Access Grants cannot complete. This account already gets + # an immediately usable password credential below, so it + # carries no required action instead of silently inheriting + # one that would make every post-signup login fail. + required_actions=[], + ) + ) + except httpx.HTTPStatusError as error: + if error.response.status_code == 409: + raise HTTPException( + status_code=409, detail="email_already_registered" + ) from error + raise + if not account_id: + raise HTTPException(status_code=502, detail="account_creation_failed") + + try: + api.reset_password(account_id, password) + except Exception as credential_error: + try: + api.delete_user(account_id) + except Exception as rollback_error: + raise HTTPException( + status_code=502, + detail="account_credential_rollback_failed", + ) from rollback_error + raise HTTPException( + status_code=502, + detail="account_credential_failed", + ) from credential_error + return account_id + + +@password_registration_router.post( + "/accounts/password", + response_model=PasswordRegistrationResult, + status_code=201, + responses={ + 503: { + "description": ( + "Currently always returned: Direct Access Grants login is " + "disabled pending a standards-compliant replacement (see " + "docs/adr/0014-naruon-owned-password-form.md's Correction), " + "so this endpoint fails closed rather than create an account " + "nothing can authenticate into." + ), + }, + }, +) +def register_account_with_password( + request_body: PasswordRegistrationRequest, + request: Request, + api: ProductAdminApi = Depends(get_admin_api), +) -> PasswordRegistrationResult: + """Create an account with an immediately usable password credential. + + Fails closed while ``PASSWORD_CREDENTIAL_LOGIN_AVAILABLE`` is False -- see + the module-level comment above it. + """ + if not PASSWORD_CREDENTIAL_LOGIN_AVAILABLE: + raise HTTPException(status_code=503, detail=PASSWORD_LOGIN_BLOCKED_DETAIL) + _record_registration_attempt(_registration_client_key(request)) + email_address = _validated_email(request_body.email_address) + password = _validated_password(request_body.password, email_address) + if api.find_users_by_email(email_address): + raise HTTPException(status_code=409, detail="email_already_registered") + + account_id = _create_account_with_password( + api, email_address, password, request_body + ) + return PasswordRegistrationResult( + account_id=account_id, email_address=email_address + ) diff --git a/services/account_unification/app/product_keycloak_client.py b/services/account_unification/app/product_keycloak_client.py index a1db0cd..b157338 100644 --- a/services/account_unification/app/product_keycloak_client.py +++ b/services/account_unification/app/product_keycloak_client.py @@ -145,13 +145,23 @@ def __init__( timeout_seconds: float = 10.0, transport=None, ) -> None: - """Create a product adapter after validating all configured realms.""" + """Create a product adapter bound to one credential-free TLS origin.""" + candidate_server_url = server_url.strip() + parsed_server_url = urlsplit(candidate_server_url) + if ( + parsed_server_url.scheme != "https" + or not parsed_server_url.hostname + or parsed_server_url.username is not None + or parsed_server_url.password is not None + or parsed_server_url.fragment + ): + raise ValueError("server_url must be an absolute HTTPS URI") validate_path_segment(realm, field_name="keycloak_realm") - self._server_url = server_url.rstrip("/") + self._server_url = candidate_server_url.rstrip("/") if token_realm is not None: validate_path_segment(token_realm, field_name="token_realm") super().__init__( - server_url=server_url, + server_url=candidate_server_url, realm=realm, client_id=client_id, client_secret=client_secret, @@ -486,7 +496,6 @@ def delete_identity_provider(self, provider_alias: str) -> None: f"{safe_alias}" ) - def _absolute_admin_url(self, path: str) -> str: """Return one guarded absolute Admin REST URL for direct response access.""" return f"{self._server_url}{self._guard_path(path)}" diff --git a/services/account_unification/app/relying_party.py b/services/account_unification/app/relying_party.py index 1f83ef3..b789dea 100644 --- a/services/account_unification/app/relying_party.py +++ b/services/account_unification/app/relying_party.py @@ -507,7 +507,52 @@ def _validate_protocol_mappers(registration: RelyingPartyRegistration) -> None: def validate_relying_party_registration( registration: RelyingPartyRegistration, ) -> RelyingPartyValidationResult: - """Validate one client registration without persistence or network access.""" + """Validate one dynamically-registered OIDC relying party (RP) against this + org's fixed client-security profile before Keyverse creates or updates the + corresponding Keycloak client. This function is pure and side-effect-free + -- it neither persists anything nor makes a network call; a caller may + apply the change only after this returns without raising. + + Every check below enforces one property of that fixed profile, and none + of them are configurable per-caller -- a client that needs a different + property does not qualify for dynamic registration through this path: + + - `clientId` must be a clean, lowercase ASCII slug, and `name` must + exactly match it -- dynamically-registered clients get no separate + free-text display identity. + - The client must be `enabled` and speak the `openid-connect` protocol. + - A public client (SPA/native, no client secret) must set + `clientAuthenticatorType` to `none`; a confidential client must set it + to `client-secret` -- the two client types cannot be conflated. + - Only the OAuth2 Authorization Code flow (`standardFlowEnabled`) may be + used. The OAuth2 Implicit flow (`implicitFlowEnabled`) is rejected as + deprecated. The Resource Owner Password Credentials grant, Keycloak's + "Direct Access Grants" (`directAccessGrantsEnabled`), is rejected + because RFC 9700 §2.4 (BCP 240, Jan 2025) and RFC 10017 §7.3 (OAuth 2.0 + for Browser-Based Applications, BCP, Aug 2026) both state it MUST NOT + be used by browser-based OAuth/OIDC clients -- see + docs/adr/0014-naruon-owned-password-form.md's Correction section for + the one hand-authored (not dynamically-registered) exception this + function deliberately does not need to know about, since it never + sees `naruon-web`'s registration. + - `serviceAccountsEnabled` (the client-credentials machine-to-machine + grant) and `fullScopeAllowed` (implicit access to every realm role and + scope) are both rejected, because a dynamically-registered RP is + expected to act only on behalf of an interactive human user with an + explicitly-granted scope -- never as a standing machine identity with + unrestricted realm access. + - `redirectUris` and `webOrigins` must resolve to exactly the same set of + origins (an RP cannot receive callbacks at an origin it never declared + as trusted for CORS), and the client's post-logout redirect URI must + itself be one of those registered web origins. + + Raises `HTTPException(400)` via `_client_error` on the first violation + found, with a message naming only the violated field and requirement -- + never the submitted value, so a rejected registration attempt cannot be + used to probe or leak configuration. Returns a `RelyingPartyValidationResult` + wrapping the validated registration, with `ready_to_apply=True`, once every + check above has passed. + """ _require_clean_text( registration.client_id, "clientId", diff --git a/services/account_unification/tests/mock_product_keycloak.py b/services/account_unification/tests/mock_product_keycloak.py index d47215f..42d1dee 100644 --- a/services/account_unification/tests/mock_product_keycloak.py +++ b/services/account_unification/tests/mock_product_keycloak.py @@ -16,6 +16,9 @@ def __init__(self) -> None: self._directory_component_sequence = 0 self._relying_party_sequence = 0 self.action_emails: dict[str, dict] = {} + # Test-only credential store, mirroring the shape of a real + # Keycloak reset — never printed or otherwise surfaced by any test. + self.password_credentials: dict[str, str] = {} @staticmethod def _clone_component(component: dict) -> dict: @@ -62,6 +65,13 @@ def send_execute_actions_email( "lifespan_seconds": lifespan_seconds, } + def reset_password(self, user_id: str, password: str) -> None: + """Record one non-temporary password credential set.""" + self.calls.append(f"reset_password:{user_id}") + if user_id not in self.users: + raise KeyError(user_id) + self.password_credentials[user_id] = password + def delete_user(self, user_id: str) -> None: """Delete a newly created account during rollback.""" self.calls.append(f"delete_user:{user_id}") @@ -70,6 +80,7 @@ def delete_user(self, user_id: str) -> None: self.roles.pop(user_id, None) self.groups.pop(user_id, None) self.action_emails.pop(user_id, None) + self.password_credentials.pop(user_id, None) self.deactivated.discard(user_id) for attribute in [ key for key in self.attributes if key[0] == user_id diff --git a/services/account_unification/tests/test_config.py b/services/account_unification/tests/test_config.py index 0f71c21..77dbd8f 100644 --- a/services/account_unification/tests/test_config.py +++ b/services/account_unification/tests/test_config.py @@ -128,6 +128,38 @@ def test_registration_redirect_uri_requires_absolute_https( load_service_config(store, "account_unification") +def test_password_registration_token_defaults_to_none() -> None: + """Naruon's password-signup surface is disabled unless explicitly set.""" + config = load_service_config(_config_store(), "account_unification") + assert config.password_registration_api_token is None + + +def test_password_registration_token_must_not_equal_operator_token() -> None: + """Naruon's signup credential cannot acquire operator authority.""" + store = _config_store(password_registration_api_token="operator-token") + with pytest.raises(RuntimeError, match="password_registration_api_token"): + load_service_config(store, "account_unification") + + +def test_password_registration_token_must_not_equal_registration_token() -> None: + """The password-signup and passwordless-enrollment tokens stay independent.""" + store = _config_store( + registration_api_token="registration-token", + registration_client_id="naruon-web", + registration_redirect_uri="https://naruon.example/auth/passkey-complete", + password_registration_api_token="registration-token", + ) + with pytest.raises(RuntimeError, match="password_registration_api_token"): + load_service_config(store, "account_unification") + + +def test_password_registration_token_loads_when_distinct() -> None: + """A distinct password-signup token loads and is usable independently.""" + store = _config_store(password_registration_api_token="password-registration-token") + config = load_service_config(store, "account_unification") + assert config.password_registration_api_token == "password-registration-token" + + @pytest.mark.parametrize( "raw_value", ["0", "-1", "1.5", "nan", "inf", "not-a-number"], diff --git a/services/account_unification/tests/test_deployment_contracts.py b/services/account_unification/tests/test_deployment_contracts.py index b346d29..ccc6346 100644 --- a/services/account_unification/tests/test_deployment_contracts.py +++ b/services/account_unification/tests/test_deployment_contracts.py @@ -1,11 +1,20 @@ -"""Static deployment contract tests for Compose and Helm packaging.""" +"""Static and executable deployment contract tests for Compose and Helm packaging.""" from __future__ import annotations +import sys import tomllib from pathlib import Path +import pytest import yaml +from app.config import ( + KEY_PASSWORD_REGISTRATION_API_TOKEN, + KEY_REGISTRATION_API_TOKEN, +) +from app.kv_store import SqliteKvStore +from tools import seed_config_store + def _repository_root() -> Path: """Return the repository root from the account-unification tests.""" @@ -102,3 +111,63 @@ def test_local_seed_keeps_registration_disabled_by_default() -> None: assert '"--registration-token"' in seed_tool assert 'default=""' in seed_tool assert "if not args.registration_token" in seed_tool + + +@pytest.mark.parametrize( + ("token_option", "entry_key"), + [ + ("--registration-token", KEY_REGISTRATION_API_TOKEN), + ("--password-registration-token", KEY_PASSWORD_REGISTRATION_API_TOKEN), + ], + ids=["passwordless-registration", "password-registration"], +) +def test_local_reseed_revokes_omitted_signup_token( + tmp_path: Path, + monkeypatch, + token_option: str, + entry_key: str, +) -> None: + """Omitting a signup token on a later seed revokes stale endpoint authority.""" + database_path = tmp_path / f"{entry_key}.db" + namespace = "account_unification" + stale_token = f"old-{entry_key}-token" + + monkeypatch.setattr( + sys, + "argv", + [ + "seed_config_store.py", + "--db", + str(database_path), + "--namespace", + namespace, + token_option, + stale_token, + ], + ) + assert seed_config_store.main() == 0 + + store = SqliteKvStore(str(database_path)) + try: + assert store.get(namespace, entry_key) == stale_token + finally: + store.close() + + monkeypatch.setattr( + sys, + "argv", + [ + "seed_config_store.py", + "--db", + str(database_path), + "--namespace", + namespace, + ], + ) + assert seed_config_store.main() == 0 + + store = SqliteKvStore(str(database_path)) + try: + assert store.get(namespace, entry_key) is None + finally: + store.close() diff --git a/services/account_unification/tests/test_full_coverage_core.py b/services/account_unification/tests/test_full_coverage_core.py index 6e42b06..8387c55 100644 --- a/services/account_unification/tests/test_full_coverage_core.py +++ b/services/account_unification/tests/test_full_coverage_core.py @@ -358,6 +358,7 @@ def build_unification( registration_client_id="naruon-web", registration_redirect_uri="https://naruon.example/auth/callback", registration_action_lifespan_seconds=900, + password_registration_api_token="password-registration", ) lock_path = str(Path.cwd() / "coverage-lock.sqlite3") @@ -400,6 +401,7 @@ def build_unification( assert app.state.federation_service is federation assert app.state.operator_api_token == "operator" assert app.state.registration_api_token == "registration" + assert app.state.password_registration_api_token == "password-registration" assert app.state.ready is True assert app.state.temporary_user_operation_lock_database is True diff --git a/services/account_unification/tests/test_identifiers.py b/services/account_unification/tests/test_identifiers.py index e6572d1..af09cb6 100644 --- a/services/account_unification/tests/test_identifiers.py +++ b/services/account_unification/tests/test_identifiers.py @@ -42,6 +42,30 @@ def test_validate_path_segment_accepts_uuid_and_slug(): assert validate_path_segment("employer-adfs") == "employer-adfs" +@pytest.mark.parametrize( + "server_url", + [ + "http://keycloak.test", + "https://", + "https://admin:secret@keycloak.test", + "https://keycloak.test/#fragment", + ], + ids=["cleartext", "missing-host", "userinfo", "fragment"], +) +def test_product_admin_client_rejects_unsafe_server_url(server_url: str): + """Product credentials are bound only to an absolute credential-free TLS origin.""" + with pytest.raises(ValueError, match="server_url must be an absolute HTTPS URI"): + ProductHttpAdminApi( + server_url=server_url, + realm="cwl", + client_id="account-unification-svc", + client_secret="secret", + transport=httpx.MockTransport( + lambda request: pytest.fail(f"unexpected request: {request.url}") + ), + ) + + def test_product_admin_client_rejects_route_confusion_before_request(): """An extra path segment cannot change the intended Admin REST operation.""" seen: list[str] = [] @@ -53,7 +77,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"id": "x"}) api = ProductHttpAdminApi( - server_url="http://keycloak.test", + server_url="https://keycloak.test", realm="cwl", client_id="account-unification-svc", client_secret="secret", @@ -78,7 +102,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) api = ProductHttpAdminApi( - server_url="http://keycloak.test", + server_url="https://keycloak.test", realm="cwl", client_id="account-unification-svc", client_secret="secret", diff --git a/services/account_unification/tests/test_keycloak_client.py b/services/account_unification/tests/test_keycloak_client.py index ccce08b..07f3a55 100644 --- a/services/account_unification/tests/test_keycloak_client.py +++ b/services/account_unification/tests/test_keycloak_client.py @@ -7,7 +7,7 @@ import pytest from app.identifiers import InvalidIdentifierError -from app.keycloak_client import AdminApi, HttpAdminApi +from app.keycloak_client import AdminApi, HttpAdminApi, _to_keycloak_user from app.models import ( FederatedIdentity, GroupMembership, @@ -137,7 +137,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(204) api = ProductHttpAdminApi( - "http://keycloak.test", + "https://keycloak.test", "cwl", "account-unification-svc", "secret", @@ -257,6 +257,20 @@ def handler(request: httpx.Request) -> httpx.Response: ] +def test_product_adapter_rejects_password_reset_admin_path() -> None: + """A dormant signup implementation cannot widen the shared runtime client.""" + api = ProductHttpAdminApi( + "https://keycloak.test", + "cwl", + "account-unification-svc", + "secret", + transport=httpx.MockTransport(lambda request: httpx.Response(200, json={})), + ) + with pytest.raises(InvalidIdentifierError, match="allowed Keycloak Admin REST route"): + api._guard_path("/admin/realms/cwl/users/u1/reset-password") + api.close() + + def test_product_adapter_reauthenticates_get_once() -> None: """An expired token is refreshed once before a GET succeeds.""" token_requests = 0 @@ -279,7 +293,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) api = ProductHttpAdminApi( - "http://keycloak.test", + "https://keycloak.test", "cwl", "svc", "secret", @@ -312,7 +326,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) api = ProductHttpAdminApi( - "http://keycloak.test", + "https://keycloak.test", "cwl", "svc", "secret", @@ -350,7 +364,7 @@ def fail_handler(request: httpx.Request) -> httpx.Response: raise AssertionError("unsafe path must not reach the transport") api = ProductHttpAdminApi( - "http://keycloak.test", + "https://keycloak.test", "cwl", "svc", "secret", @@ -383,7 +397,7 @@ def fail_handler(request: httpx.Request) -> httpx.Response: raise AssertionError("invalid enrollment input reached transport") api = ProductHttpAdminApi( - "http://keycloak.test", + "https://keycloak.test", "cwl", "svc", "secret", @@ -399,3 +413,32 @@ def fail_handler(request: httpx.Request) -> httpx.Response: redirect_uri=redirect_uri, lifespan_seconds=lifespan_seconds, ) + + +def test_to_keycloak_user_omits_required_actions_by_default() -> None: + """``required_actions=None`` leaves Keycloak's realm default untouched.""" + payload = _to_keycloak_user(UserAccount(user_id="", user_name="jane")) + + assert "requiredActions" not in payload + + +def test_to_keycloak_user_sends_empty_required_actions_list() -> None: + """An explicit empty list overrides the realm default with no action.""" + payload = _to_keycloak_user( + UserAccount(user_id="", user_name="jane", required_actions=[]) + ) + + assert payload["requiredActions"] == [] + + +def test_to_keycloak_user_sends_explicit_required_actions() -> None: + """A non-empty override list is forwarded to Keycloak verbatim.""" + payload = _to_keycloak_user( + UserAccount( + user_id="", + user_name="jane", + required_actions=["UPDATE_PASSWORD"], + ) + ) + + assert payload["requiredActions"] == ["UPDATE_PASSWORD"] diff --git a/services/account_unification/tests/test_password_registration.py b/services/account_unification/tests/test_password_registration.py new file mode 100644 index 0000000..cee4501 --- /dev/null +++ b/services/account_unification/tests/test_password_registration.py @@ -0,0 +1,444 @@ +"""Headless password-credential self-registration tests.""" +from __future__ import annotations + +from types import SimpleNamespace + +import httpx +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app import password_registration as password_registration_module +from app.main import create_app +from app.models import UserAccount +from app.password_registration import reset_rate_limit_state + +PASSWORD_REGISTRATION_TOKEN = "password-registration-token-for-tests" +REGISTRATION_TOKEN = "registration-token-for-tests" +OPERATOR_TOKEN = "operator-token-for-tests" +VALID_PASSWORD = "correct horse battery staple 1!" + + +@pytest.fixture(autouse=True) +def _reset_rate_limit() -> None: + """Reset caller-keyed registration limits between tests.""" + reset_rate_limit_state() + yield + reset_rate_limit_state() + + +def _wire_password_registration_app(api): + """Return an app with the password-registration contract configured.""" + app = create_app(wire=False) + app.state.keycloak_api = api + app.state.password_registration_api_token = PASSWORD_REGISTRATION_TOKEN + app.state.registration_api_token = REGISTRATION_TOKEN + app.state.operator_api_token = OPERATOR_TOKEN + return app + + +@pytest.fixture +def client(api, monkeypatch): + """Return a password-registration-authenticated test client. + + Patches the module's fail-closed gate open so these tests exercise the + account-creation logic itself; the gate's own default-closed behavior is + covered separately by ``test_registration_fails_closed_by_default``. + """ + monkeypatch.setattr( + password_registration_module, "PASSWORD_CREDENTIAL_LOGIN_AVAILABLE", True + ) + app = _wire_password_registration_app(api) + headers = {"Authorization": f"Bearer {PASSWORD_REGISTRATION_TOKEN}"} + with TestClient(app, headers=headers) as test_client: + yield test_client + + +def test_registration_fails_closed_by_default(api): + """Direct Access Grants is disabled, so signup must not create dead accounts.""" + app = _wire_password_registration_app(api) + headers = {"Authorization": f"Bearer {PASSWORD_REGISTRATION_TOKEN}"} + with TestClient(app, headers=headers) as test_client: + response = test_client.post( + "/registration/accounts/password", json=_registration() + ) + + assert response.status_code == 503 + assert response.json()["detail"] == password_registration_module.PASSWORD_LOGIN_BLOCKED_DETAIL + assert api.find_users_by_email("new.user@example.com") == [] + assert api.calls == [] + + +def _registration( + email: str = "new.user@example.com", password: str = VALID_PASSWORD +) -> dict[str, object]: + """Build one valid password-registration payload.""" + return { + "email_address": email, + "password": password, + "first_name": "New", + "last_name": "User", + } + + +def test_registration_creates_account_with_immediately_usable_password( + client, api +): + """Signup creates a password credential, not a WebAuthn enrollment email.""" + response = client.post("/registration/accounts/password", json=_registration()) + + assert response.status_code == 201 + body = response.json() + account_id = body["account_id"] + assert body["email_address"] == "new.user@example.com" + assert api.users[account_id].is_email_verified is False + assert api.password_credentials[account_id] == VALID_PASSWORD + assert account_id not in api.action_emails + # Regression: the realm's passwordless-enrollment default required + # action is an interactive step Direct Access Grants cannot complete. + # Without an explicit override, every immediate post-signup login + # failed even though a usable password credential exists. + assert api.users[account_id].required_actions == [] + + +def test_registration_rolls_back_when_credential_set_fails(client, api, monkeypatch): + """A failed credential set deletes the newly created account.""" + + def fail_reset_password(*args, **kwargs) -> None: + """Simulate an unavailable Keycloak credential transport.""" + raise RuntimeError("simulated Keycloak credential failure") + + monkeypatch.setattr(api, "reset_password", fail_reset_password) + + response = client.post("/registration/accounts/password", json=_registration()) + + assert response.status_code == 502 + assert response.json()["detail"] == "account_credential_failed" + assert api.find_users_by_email("new.user@example.com") == [] + assert any(call.startswith("delete_user:") for call in api.calls) + + +def test_registration_reports_rollback_failure(client, api, monkeypatch): + """A failed cleanup is distinguishable from the credential failure.""" + + def fail_reset_password(*args, **kwargs) -> None: + """Simulate the original credential-set failure.""" + raise RuntimeError("simulated credential failure") + + def fail_delete(*args, **kwargs) -> None: + """Simulate rollback failure after user creation.""" + raise RuntimeError("simulated rollback failure") + + monkeypatch.setattr(api, "reset_password", fail_reset_password) + monkeypatch.setattr(api, "delete_user", fail_delete) + + response = client.post("/registration/accounts/password", json=_registration()) + + assert response.status_code == 502 + assert response.json()["detail"] == "account_credential_rollback_failed" + + +def test_registration_normalizes_email_case(client): + """Email addresses are normalized before account creation.""" + response = client.post( + "/registration/accounts/password", + json=_registration(email="Mixed.Case@Example.COM"), + ) + assert response.status_code == 201 + assert response.json()["email_address"] == "mixed.case@example.com" + + +def test_registration_rejects_duplicate_email(client): + """Duplicate normalized email addresses are rejected before creation.""" + assert client.post( + "/registration/accounts/password", json=_registration() + ).status_code == 201 + + duplicate = client.post("/registration/accounts/password", json=_registration()) + + assert duplicate.status_code == 409 + assert duplicate.json()["detail"] == "email_already_registered" + + +def test_concurrent_keycloak_duplicate_maps_to_registration_conflict( + client, api, monkeypatch +): + """A Keycloak create-user 409 remains an idempotent product conflict.""" + request = httpx.Request("POST", "http://keycloak.test/admin/realms/cwl/users") + response = httpx.Response(409, request=request) + + def reject_concurrent_duplicate(*args, **kwargs): + """Model a competing request winning after the preflight lookup.""" + raise httpx.HTTPStatusError( + "duplicate user", request=request, response=response + ) + + monkeypatch.setattr(api, "create_user", reject_concurrent_duplicate) + + result = client.post("/registration/accounts/password", json=_registration()) + + assert result.status_code == 409 + assert result.json()["detail"] == "email_already_registered" + + +@pytest.mark.parametrize( + "email", + [ + "not-an-email", + "two@@example.com", + "control\x00@example.com", + "a@b", + ], +) +def test_registration_rejects_malformed_email(client, email): + """Malformed syntax is rejected deterministically.""" + response = client.post( + "/registration/accounts/password", json=_registration(email=email) + ) + assert response.status_code == 422 + + +@pytest.mark.parametrize( + "password", + [ + "short", # below MIN_PASSWORD_LENGTH + " leading-space-padded-enough", + "trailing-space-padded-enough ", + "control\x00character-padded-enough", + ], +) +def test_registration_rejects_malformed_password(client, password): + """A malformed password is rejected before any Keycloak call.""" + response = client.post( + "/registration/accounts/password", + json=_registration(password=password), + ) + assert response.status_code == 422 + + +def test_registration_rejects_password_matching_email(client): + """A password identical to the account's own email is rejected.""" + response = client.post( + "/registration/accounts/password", + json=_registration( + email="new.user@example.com", password="New.User@Example.com" + ), + ) + assert response.status_code == 422 + assert response.json()["detail"] == "password_must_not_match_email" + + +def test_registration_surface_fails_closed_without_token(api): + """The endpoint is unavailable when its credential is absent.""" + app = _wire_password_registration_app(api) + app.state.password_registration_api_token = None + with TestClient(app) as test_client: + response = test_client.post( + "/registration/accounts/password", + json=_registration(), + headers={"Authorization": f"Bearer {PASSWORD_REGISTRATION_TOKEN}"}, + ) + assert response.status_code == 503 + + +def test_registration_rejects_wrong_token(api): + """A mismatched password-registration credential is rejected.""" + app = _wire_password_registration_app(api) + with TestClient(app) as test_client: + response = test_client.post( + "/registration/accounts/password", + json=_registration(), + headers={"Authorization": "Bearer wrong-token"}, + ) + assert response.status_code == 403 + + +def test_operator_token_does_not_open_password_registration(api): + """The operator credential cannot authorize password-based signup.""" + app = _wire_password_registration_app(api) + with TestClient(app) as test_client: + response = test_client.post( + "/registration/accounts/password", + json=_registration(), + headers={"Authorization": f"Bearer {OPERATOR_TOKEN}"}, + ) + assert response.status_code == 403 + + +def test_passwordless_registration_token_does_not_open_password_registration(api): + """Naruon's passwordless-enrollment credential cannot cross into ROPC signup.""" + app = _wire_password_registration_app(api) + with TestClient(app) as test_client: + response = test_client.post( + "/registration/accounts/password", + json=_registration(), + headers={"Authorization": f"Bearer {REGISTRATION_TOKEN}"}, + ) + assert response.status_code == 403 + + +def test_registration_rate_limit_isolated_by_caller(client, monkeypatch): + """One caller cannot consume another caller's registration allowance.""" + monkeypatch.setattr( + password_registration_module, + "PASSWORD_REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS", + 1, + ) + caller_keys = iter(["caller-a", "caller-a", "caller-b"]) + + def next_caller_key(request) -> str: + """Return deterministic caller identities for consecutive requests.""" + del request + return next(caller_keys) + + monkeypatch.setattr( + password_registration_module, + "_registration_client_key", + next_caller_key, + ) + + assert client.post( + "/registration/accounts/password", + json=_registration("first@example.com"), + ).status_code == 201 + limited = client.post( + "/registration/accounts/password", + json=_registration("second@example.com"), + ) + independent = client.post( + "/registration/accounts/password", + json=_registration("third@example.com"), + ) + + assert limited.status_code == 429 + assert independent.status_code == 201 + + +def test_password_registration_router_has_no_realm_wide_janitor_endpoint(client): + """Password-registration credentials cannot invoke a destructive wildcard.""" + response = client.post("/registration/password-janitor:run") + assert response.status_code == 404 + + +class _UnavailableApi: + """Expose only methods needed by direct password-registration edge tests.""" + + def find_users_by_email(self, email: str) -> list[UserAccount]: + """Return no pre-existing accounts.""" + return [] + + def create_user(self, user: UserAccount) -> str: + """Return an empty identifier to model an unusable upstream response.""" + return "" + + +class _NonConflictCreateApi(_UnavailableApi): + """Raise a non-conflict Keycloak HTTP error during account creation.""" + + def create_user(self, user: UserAccount) -> str: + """Raise a service-unavailable response that must be preserved.""" + request = httpx.Request("POST", "https://keycloak.example/users") + response = httpx.Response(503, request=request) + raise httpx.HTTPStatusError( + "upstream unavailable", request=request, response=response + ) + + +def _password_registration_request() -> SimpleNamespace: + """Return one valid direct-call password-registration request body.""" + return SimpleNamespace( + email_address="new.user@example.com", + password=VALID_PASSWORD, + first_name=None, + last_name=None, + ) + + +def _password_registration_http_request() -> SimpleNamespace: + """Return one bare request carrying only the fields the route reads.""" + return SimpleNamespace(client=None) + + +def test_password_registration_requires_a_bearer_header() -> None: + """A configured signup surface still rejects an absent header.""" + request = SimpleNamespace( + app=SimpleNamespace( + state=SimpleNamespace( + password_registration_api_token="expected-token" + ) + ) + ) + + with pytest.raises(HTTPException) as error: + password_registration_module.require_password_registration_token( + request, authorization=None + ) + + assert error.value.status_code == 401 + assert error.value.headers == {"WWW-Authenticate": "Bearer"} + + +def test_password_registration_admin_api_dependency_fails_closed() -> None: + """Signup cannot proceed without a wired product Keycloak client.""" + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + + with pytest.raises(HTTPException) as error: + password_registration_module.get_admin_api(request) + + assert error.value.status_code == 503 + + +def test_password_registration_window_resets_after_expiry(monkeypatch) -> None: + """A fixed-window caller budget resets after the configured duration.""" + password_registration_module.reset_rate_limit_state() + times = iter([ + 0.0, + password_registration_module.PASSWORD_REGISTRATION_RATE_LIMIT_WINDOW_SECONDS + + 1.0, + ]) + monkeypatch.setattr( + password_registration_module.time, "monotonic", lambda: next(times) + ) + monkeypatch.setattr( + password_registration_module, + "PASSWORD_REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS", + 1, + ) + + password_registration_module._record_registration_attempt("caller") + password_registration_module._record_registration_attempt("caller") + + +def test_password_registration_reports_empty_upstream_identifier(monkeypatch) -> None: + """An upstream create response without an ID becomes a bounded 502.""" + password_registration_module.reset_rate_limit_state() + monkeypatch.setattr( + password_registration_module, "PASSWORD_CREDENTIAL_LOGIN_AVAILABLE", True + ) + + with pytest.raises(HTTPException) as error: + password_registration_module.register_account_with_password( + _password_registration_request(), + _password_registration_http_request(), + api=_UnavailableApi(), + ) + + assert error.value.status_code == 502 + assert error.value.detail == "account_creation_failed" + + +def test_password_registration_preserves_non_conflict_keycloak_errors(monkeypatch) -> None: + """Only a Keycloak 409 is translated to an email conflict.""" + password_registration_module.reset_rate_limit_state() + monkeypatch.setattr( + password_registration_module, "PASSWORD_CREDENTIAL_LOGIN_AVAILABLE", True + ) + + with pytest.raises(httpx.HTTPStatusError) as error: + password_registration_module.register_account_with_password( + _password_registration_request(), + _password_registration_http_request(), + api=_NonConflictCreateApi(), + ) + + assert error.value.response.status_code == 503 diff --git a/services/account_unification/tests/test_realm_policy.py b/services/account_unification/tests/test_realm_policy.py index 83d03b2..25a396c 100644 --- a/services/account_unification/tests/test_realm_policy.py +++ b/services/account_unification/tests/test_realm_policy.py @@ -81,6 +81,17 @@ def test_public_client_token_lifespan_is_bounded() -> None: assert any("access.token.lifespan" in error for error in errors) +def test_naruon_direct_access_grants_stays_disabled() -> None: + """A later realm edit cannot silently restore the blocked ROPC grant.""" + validator = _validator_module() + realm = deepcopy(_realm()) + _client(realm, "naruon-web")["directAccessGrantsEnabled"] = True + + errors = validator.validate(realm) + + assert any("Direct Access Grants" in error for error in errors) + + def test_reusable_client_template_does_not_name_naruon_host() -> None: """The generic RP template stays portable across ecosystem products.""" template = _client(_realm(), "ecosystem-rp-template") diff --git a/services/account_unification/tools/seed_config_store.py b/services/account_unification/tools/seed_config_store.py index 1412e01..b9f4be8 100644 --- a/services/account_unification/tools/seed_config_store.py +++ b/services/account_unification/tools/seed_config_store.py @@ -3,7 +3,8 @@ The tool writes the same two-word snake_case entries consumed by the service. Values are development placeholders; production deployments populate the platform KV and provide only the bootstrap pointer to the process. Registration -remains disabled unless its dedicated token is supplied explicitly. +remains disabled unless its dedicated token is supplied explicitly, and later +seeds without signup tokens revoke any stale stored endpoint authority. """ from __future__ import annotations @@ -22,6 +23,7 @@ KEY_KEYCLOAK_SERVER_URL, KEY_MERGE_CONFLICT_POLICY, KEY_OPERATOR_API_TOKEN, + KEY_PASSWORD_REGISTRATION_API_TOKEN, KEY_REGISTRATION_ACTION_LIFESPAN_SECONDS, KEY_REGISTRATION_API_TOKEN, KEY_REGISTRATION_CLIENT_ID, @@ -67,6 +69,15 @@ def _build_parser() -> argparse.ArgumentParser: "--registration-action-lifespan-seconds", default="900", ) + parser.add_argument( + "--password-registration-token", + default="", + help=( + "Seed the reserved password-registration credential for controlled " + "compatibility testing. The password-registration route remains " + "fail-closed until a standards-compliant login replacement exists." + ), + ) parser.add_argument( "--audit-database-path", default="../../deploy/bootstrap/account_unification_audit.sqlite3", @@ -88,6 +99,13 @@ def _registration_entries(args: argparse.Namespace) -> dict[str, str]: } +def _password_registration_entries(args: argparse.Namespace) -> dict[str, str]: + """Return the password-signup token entry only when it is supplied.""" + if not args.password_registration_token: + return {} + return {KEY_PASSWORD_REGISTRATION_API_TOKEN: args.password_registration_token} + + def main() -> int: """Write development Keycloak settings into a local SQLite KV store.""" args = _build_parser().parse_args() @@ -103,9 +121,14 @@ def main() -> int: KEY_OPERATOR_API_TOKEN: args.operator_token, KEY_AUDIT_DATABASE_PATH: args.audit_database_path, **_registration_entries(args), + **_password_registration_entries(args), } for entry_key, entry_value in entries.items(): store.put(args.namespace, entry_key, entry_value) + if not args.registration_token: + store.delete(args.namespace, KEY_REGISTRATION_API_TOKEN) + if not args.password_registration_token: + store.delete(args.namespace, KEY_PASSWORD_REGISTRATION_API_TOKEN) finally: store.close() print(f"seeded {args.db} namespace={args.namespace}")