From 5404dd0d8f6eba34f8cc67519af6004aa532fabf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:22:44 +0900 Subject: [PATCH 01/20] feat(realm): scoped Direct Access Grants exception for naruon-web only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit naruon's product owner rejected a Keycloak-theme-reskin approach outright — even a visually-identical theme is still Keycloak's server rendering the page. The re-confirmed requirement is naruon's own frontend rendering 100% of the login form with zero Keycloak-rendered HTML, backed by naruon's own server calling Keyverse purely as an API. WebAuthn login as a headless API was investigated and ruled out: Keycloak's authentication ceremony (unlike registration) runs inside its own login-actions flow bound to a server-side AuthenticationSessionModel, with no public REST pair for the ceremony itself. Direct Access Grants (ROPC) is the only Keycloak-native mechanism that fits the zero-Keycloak-HTML requirement, so this flips `directAccessGrantsEnabled: true` for `naruon-web` only — a scoped, reviewed exception to ADR-0002's passwordless-first default, recorded in ADR-0014. Every other/future RP remains hard-blocked by account-unification's dynamic-registration validator (`directAccessGrantsEnabled` must be false). This does not make login functional yet: no account in the `cwl` realm has a password credential today (registration explicitly refuses to create one), so every Direct Access Grants attempt still fails closed. A credential-issuance path is flagged as separately-reviewable follow-up work, not implemented here. Co-Authored-By: Claude Sonnet 5 --- deploy/keycloak/README.md | 10 ++ deploy/keycloak/realm-cwl.json | 2 +- docs/adr/0014-naruon-owned-password-form.md | 154 ++++++++++++++++++++ docs/adr/README.md | 1 + 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0014-naruon-owned-password-form.md diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index feaf209..bb1cc6c 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -70,6 +70,16 @@ 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` also has `directAccessGrantsEnabled: true` — a scoped, reviewed +exception ([ADR-0014](../../docs/adr/0014-naruon-owned-password-form.md)) so +naruon can render its own login form with zero Keycloak-rendered HTML in the +loop. This does **not** make Direct Access Grants usable today: no account in +`cwl` has a password credential (`docs/passwordless-policy.md`), so every +attempt fails closed with `invalid_grant` until a separate, separately-reviewed +credential-issuance path exists. No other RP gets this exception; the +account-unification dynamic-registration validator still hard-rejects +`directAccessGrantsEnabled: true` for everyone else. + ## Bootstrap ```bash diff --git a/deploy/keycloak/realm-cwl.json b/deploy/keycloak/realm-cwl.json index 8d2018f..62d768e 100644 --- a/deploy/keycloak/realm-cwl.json +++ b/deploy/keycloak/realm-cwl.json @@ -302,7 +302,7 @@ "publicClient": true, "standardFlowEnabled": true, "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, + "directAccessGrantsEnabled": true, "serviceAccountsEnabled": false, "redirectUris": [ "https://naruon.example/auth/callback", diff --git a/docs/adr/0014-naruon-owned-password-form.md b/docs/adr/0014-naruon-owned-password-form.md new file mode 100644 index 0000000..ac2bda3 --- /dev/null +++ b/docs/adr/0014-naruon-owned-password-form.md @@ -0,0 +1,154 @@ +# ADR-0014: Scoped Direct Access Grants exception so naruon can render its own login form + +**Status:** Accepted +**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`). + +## 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. + +## Decision + +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. + +## What this does *not* yet 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. + +## Consequences + +- 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 + +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/README.md b/docs/adr/README.md index aad6a6a..877ad9f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,6 +18,7 @@ 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 | +| [0014](0014-naruon-owned-password-form.md) | Scoped Direct Access Grants exception for `naruon-web` only, so naruon can render its own login form with zero Keycloak-rendered HTML; every other RP still hard-blocked | Accepted | 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 From 5661b3d7c1d4bc4505b455086d2feafba4d832d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:51:48 +0900 Subject: [PATCH 02/20] feat(registration): scoped password-credential issuance for naruon signup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0014 enabled Direct Access Grants for naruon-web but left login non-functional: no account in the cwl realm has a password credential, since POST /registration/accounts deliberately never creates one. This closes that gap with the minimum reviewable change, recorded in ADR-0015. Adds POST /registration/accounts/password (app/password_registration.py), mirroring POST /registration/accounts' shape (rate limiting, email validation, create-then-rollback) but ending in an immediately usable, non-temporary password credential via a new ProductAdminApi.reset_password method. Gated by a third, independent bearer token (password_registration_api_token) that account-unification's config loader requires to differ from both operator_api_token and registration_api_token — naruon is the only intended holder. The new Admin REST path is added to the existing allow-list (_ADMIN_PATH_PATTERNS) rather than opening a new credential surface. The account-unification dynamic-RP-registration validator is unchanged: it still hard-rejects directAccessGrantsEnabled=true for every RP except the hand-authored naruon-web client, so this exception stays scoped to naruon exactly as ADR-0014 established. Also adds a realm-level passwordPolicy (length(12) and notUsername and notEmail) as a second, server-side enforcement layer independent of the endpoint's own validation, and a --password-registration-token flag to tools/seed_config_store.py for local bring-up. Explicitly deferred (see ADR-0015): email verification, CAPTCHA-equivalent abuse hardening beyond the existing per-peer rate limit, self-service password reset, and merging password/passwordless identities for the same person. Co-Authored-By: Claude Sonnet 5 --- deploy/keycloak/README.md | 19 +- deploy/keycloak/realm-cwl.json | 1 + docs/adr/0014-naruon-owned-password-form.md | 6 + ...015-naruon-password-credential-issuance.md | 192 ++++++++ docs/adr/README.md | 1 + services/account_unification/app/config.py | 18 + services/account_unification/app/main.py | 11 + .../app/password_registration.py | 210 +++++++++ .../app/product_keycloak_client.py | 24 + .../tests/mock_product_keycloak.py | 11 + .../account_unification/tests/test_config.py | 32 ++ .../tests/test_full_coverage_core.py | 2 + .../tests/test_keycloak_client.py | 12 + .../tests/test_password_registration.py | 410 ++++++++++++++++++ .../tools/seed_config_store.py | 18 + 15 files changed, 961 insertions(+), 6 deletions(-) create mode 100644 docs/adr/0015-naruon-password-credential-issuance.md create mode 100644 services/account_unification/app/password_registration.py create mode 100644 services/account_unification/tests/test_password_registration.py diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index bb1cc6c..1bff673 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -73,12 +73,19 @@ through normal token refresh/reissue rather than a twelve-hour bearer token. `naruon-web` also has `directAccessGrantsEnabled: true` — a scoped, reviewed exception ([ADR-0014](../../docs/adr/0014-naruon-owned-password-form.md)) so naruon can render its own login form with zero Keycloak-rendered HTML in the -loop. This does **not** make Direct Access Grants usable today: no account in -`cwl` has a password credential (`docs/passwordless-policy.md`), so every -attempt fails closed with `invalid_grant` until a separate, separately-reviewed -credential-issuance path exists. No other RP gets this exception; the -account-unification dynamic-registration validator still hard-rejects -`directAccessGrantsEnabled: true` for everyone else. +loop. No other RP 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-0015](../../docs/adr/0015-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 diff --git a/deploy/keycloak/realm-cwl.json b/deploy/keycloak/realm-cwl.json index 62d768e..40d76b8 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/adr/0014-naruon-owned-password-form.md b/docs/adr/0014-naruon-owned-password-form.md index ac2bda3..cd4f21d 100644 --- a/docs/adr/0014-naruon-owned-password-form.md +++ b/docs/adr/0014-naruon-owned-password-form.md @@ -95,6 +95,12 @@ provided it is never logged, cached, or persisted. ## What this does *not* yet deliver +**Update (2026-09-02):** the credential-issuance gap this section describes +is now closed by [ADR-0015](0015-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-0014 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 diff --git a/docs/adr/0015-naruon-password-credential-issuance.md b/docs/adr/0015-naruon-password-credential-issuance.md new file mode 100644 index 0000000..7b4c07f --- /dev/null +++ b/docs/adr/0015-naruon-password-credential-issuance.md @@ -0,0 +1,192 @@ +# ADR-0015: Scoped password-credential issuance so naruon's signup form actually logs in + +**Status:** Accepted +**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." + +## Context + +[ADR-0014](0014-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-0014 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. + +### Accepted: extend account-unification, keyverse's existing narrow-scope 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"). + +## Decision + +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. + +## Consequences + +- naruon's login (ADR-0014) 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 + +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/README.md b/docs/adr/README.md index 877ad9f..f5fe7bf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,6 +19,7 @@ authorization boundary and is not rewritten by that expansion. | [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 | | [0014](0014-naruon-owned-password-form.md) | Scoped Direct Access Grants exception for `naruon-web` only, so naruon can render its own login form with zero Keycloak-rendered HTML; every other RP still hard-blocked | Accepted | +| [0015](0015-naruon-password-credential-issuance.md) | `POST /registration/accounts/password`: scoped, third-token-gated account-unification endpoint that gives naruon signups an immediately usable password credential, closing ADR-0014's "nothing can log in yet" gap | Accepted | 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 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/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/password_registration.py b/services/account_unification/app/password_registration.py new file mode 100644 index 0000000..4208f5c --- /dev/null +++ b/services/account_unification/app/password_registration.py @@ -0,0 +1,210 @@ +"""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]] = {} + + +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), + ) + ) + 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, +) +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.""" + _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..1fd6669 100644 --- a/services/account_unification/app/product_keycloak_client.py +++ b/services/account_unification/app/product_keycloak_client.py @@ -36,6 +36,7 @@ ("users", None, "groups"), ("users", None, "groups", None), ("users", None, "execute-actions-email"), + ("users", None, "reset-password"), ("identity-provider", "instances"), ("identity-provider", "instances", None), ("components",), @@ -95,6 +96,10 @@ def delete_user(self, user_id: str) -> None: """Delete one user during failed registration rollback.""" ... + def reset_password(self, user_id: str, password: str) -> None: + """Set an immediately usable (non-temporary) password credential.""" + ... + def get_identity_provider(self, provider_alias: str) -> dict | None: """Return one identity-provider instance or ``None`` when absent.""" ... @@ -445,6 +450,25 @@ def delete_user(self, user_id: str) -> None: safe_user_id = self._safe_segment(user_id, "user_id") self._delete(f"/admin/realms/{self._realm}/users/{safe_user_id}") + def reset_password(self, user_id: str, password: str) -> None: + """Set an immediately usable (non-temporary) password credential. + + ``password`` reaches Keycloak only as this call's JSON body over the + existing authenticated HTTPS transport; it is never logged, retried + with a captured copy, or included in any exception message here. + """ + safe_user_id = self._safe_segment(user_id, "user_id") + path = self._guard_path( + f"/admin/realms/{self._realm}/users/{safe_user_id}/reset-password" + ) + self._send_with_reauth( + lambda: self._client.put( + path, + json={"type": "password", "value": password, "temporary": False}, + headers=self._auth_header(), + ) + ) + def get_identity_provider(self, provider_alias: str) -> dict | None: """Return an identity provider or ``None`` for a Keycloak 404.""" safe_alias = self._safe_segment(provider_alias, "provider_alias") 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_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_keycloak_client.py b/services/account_unification/tests/test_keycloak_client.py index ccce08b..f70979b 100644 --- a/services/account_unification/tests/test_keycloak_client.py +++ b/services/account_unification/tests/test_keycloak_client.py @@ -227,6 +227,7 @@ def handler(request: httpx.Request) -> httpx.Response: redirect_uri="https://naruon.example/auth/passkey-complete", lifespan_seconds=900, ) + api.reset_password("u1", "correct horse battery staple 1!") api.create_identity_provider({"alias": "employer-adfs"}) api.update_identity_provider( "employer-adfs", @@ -255,6 +256,17 @@ def handler(request: httpx.Request) -> httpx.Response: "VERIFY_EMAIL", "webauthn-register-passwordless", ] + reset_password_request = next( + call + for call in calls + if call.url.path.endswith("/users/u1/reset-password") + ) + assert reset_password_request.method == "PUT" + assert json.loads(reset_password_request.content) == { + "type": "password", + "value": "correct horse battery staple 1!", + "temporary": False, + } def test_product_adapter_reauthenticates_get_once() -> None: 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..ccb1923 --- /dev/null +++ b/services/account_unification/tests/test_password_registration.py @@ -0,0 +1,410 @@ +"""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): + """Return a password-registration-authenticated test client.""" + 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 _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 + + +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() -> None: + """An upstream create response without an ID becomes a bounded 502.""" + password_registration_module.reset_rate_limit_state() + + 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() -> None: + """Only a Keycloak 409 is translated to an email conflict.""" + password_registration_module.reset_rate_limit_state() + + 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/tools/seed_config_store.py b/services/account_unification/tools/seed_config_store.py index 1412e01..6cdf092 100644 --- a/services/account_unification/tools/seed_config_store.py +++ b/services/account_unification/tools/seed_config_store.py @@ -22,6 +22,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 +68,15 @@ def _build_parser() -> argparse.ArgumentParser: "--registration-action-lifespan-seconds", default="900", ) + parser.add_argument( + "--password-registration-token", + default="", + help=( + "Enable naruon's own password-signup form (scoped Direct Access " + "Grants exception, naruon-web only) only when a dedicated token, " + "distinct from --registration-token, is supplied." + ), + ) parser.add_argument( "--audit-database-path", default="../../deploy/bootstrap/account_unification_audit.sqlite3", @@ -88,6 +98,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,6 +120,7 @@ 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) From 7c1939d9ed28f70aa38eaf5f7e830284efce7a95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 10:01:35 +0900 Subject: [PATCH 03/20] fix(registration): clear required actions on new password accounts New accounts created via /registration/accounts/password inherited the realm's default required action (WebAuthn passwordless enrollment) because _to_keycloak_user never set requiredActions in the Keycloak create-user payload. That default is an interactive browser step Direct Access Grants cannot complete, so every immediate post-signup login failed even though the account already has a usable password credential -- confirmed independently by Devin Review on both this PR and naruon#1532's paired frontend PR. UserAccount gains an optional required_actions field (None preserves the existing realm-default behavior for every other caller, e.g. the passwordless registration flow, which still wants that action). register_account_with_password now passes required_actions=[] explicitly. Co-Authored-By: Claude Sonnet 5 --- .../app/keycloak_client.py | 2 ++ services/account_unification/app/models.py | 4 +++ .../app/password_registration.py | 7 +++++ .../tests/test_keycloak_client.py | 31 ++++++++++++++++++- .../tests/test_password_registration.py | 5 +++ 5 files changed, 48 insertions(+), 1 deletion(-) 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/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 index 4208f5c..a7fd5c9 100644 --- a/services/account_unification/app/password_registration.py +++ b/services/account_unification/app/password_registration.py @@ -157,6 +157,13 @@ def _create_account_with_password( 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: diff --git a/services/account_unification/tests/test_keycloak_client.py b/services/account_unification/tests/test_keycloak_client.py index f70979b..9cd779a 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, @@ -411,3 +411,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 index ccb1923..920e4ec 100644 --- a/services/account_unification/tests/test_password_registration.py +++ b/services/account_unification/tests/test_password_registration.py @@ -71,6 +71,11 @@ def test_registration_creates_account_with_immediately_usable_password( 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): From c97f3333b3075c794dd555fac4b02a6688de4fde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 17:30:52 +0900 Subject: [PATCH 04/20] docs(adr): correct ADR-0014 with the RFC 9700/10017 ROPC prohibition finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9700 §2.4 (BCP 240, Jan 2025) states the Resource Owner Password Credentials grant MUST NOT be used; RFC 10017 §7.3 (OAuth 2.0 for Browser-Based Applications, BCP, Aug 2026) independently repeats that prohibition for browser-based apps specifically. Neither RFC existed when this ADR was accepted, and the product-owner risk acceptance it relies on (satisfying ADR-0002's amendment clause) cannot make grant_type=password standards-compliant -- these are current IETF security guidance, not a local preference a risk memo can override. Discovered when naruon#1532 (the companion PR implementing this ADR's point 4) was returned to Draft over this exact finding rather than merged. Adds a Correction section documenting the finding in full, with citations, while explicitly preserving the ADR's still-valid Context and ruled-out-alternatives sections -- the product requirement (naruon-owned login UI, Keyverse as backend, zero Keycloak-rendered HTML) is not in question, only the ROPC mechanism needs a successor. Status intentionally left Accepted, not Rejected, per this org's repair-not-close convention for findings against an already-accepted decision with real, still-valid product intent behind it. Co-Authored-By: Claude Sonnet 5 --- docs/adr/0014-naruon-owned-password-form.md | 55 ++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/adr/0014-naruon-owned-password-form.md b/docs/adr/0014-naruon-owned-password-form.md index cd4f21d..c0ead3d 100644 --- a/docs/adr/0014-naruon-owned-password-form.md +++ b/docs/adr/0014-naruon-owned-password-form.md @@ -1,6 +1,6 @@ # ADR-0014: Scoped Direct Access Grants exception so naruon can render its own login form -**Status:** Accepted +**Status:** Accepted, mechanism superseded pending RFC-compliant redesign — see **Correction (2026-09-03)** below before implementing anything against this ADR. **Date:** 2026-09-02 **Decision owner:** Keyverse maintainers, with explicit product direction from the naruon product owner (see Context). @@ -10,6 +10,59 @@ 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, unchanged:** every section below this one — the Context, what was ruled out and +why (Keycloak-theme reskin fails by construction; a naruon-rendered WebAuthn ceremony against Keycloak +is currently unachievable without a custom Keycloak REST resource provider) — is still accurate. Read +the rest of this ADR as the record of *why the product requirement exists and what does not solve it*, +not as license to ship the specific mechanism in point 1 of the Decision below. + +**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. + +**Status intentionally left as Accepted, not Rejected/Superseded**, because the product goal stands and +the Context/ruled-out-alternatives sections remain load-bearing evidence — only the grant-type mechanism +in the Decision needs a successor. Per this org's repair-not-close convention for findings against an +already-Accepted decision with real, still-valid product intent behind it: open a new ADR once a +replacement mechanism is chosen and cross-reference it here, rather than silently rewriting this one's +history or treating this correction as grounds to abandon the underlying naruon-owned-login-form goal. + ## Context Product direction for naruon's login/signup surface was clarified twice. From 0ddcad617adaafb2e6a4d41ceb1ad64cc76c7719 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 17:36:49 +0900 Subject: [PATCH 05/20] docs(relying-party): expand validate_relying_party_registration's docstring The one-line docstring ("Validate one client registration without persistence or network access.") didn't meet this org's own standing requirement (docs/product-goal-directive.md: docstrings must be sufficient for a beginner to understand without separate code analysis) -- a reader had to trace every _client_error() call to learn what the function actually enforces and why, including the non-obvious RFC 9700/10017 reasoning behind rejecting directAccessGrantsEnabled that this same PR just added to ADR-0014. Expands the docstring to name every enforced property of the fixed client-security profile (client-id/name identity, enabled+OIDC only, authenticator type matching client type, Authorization Code only with implicit/ROPC/service-accounts/full-scope all rejected and why, redirect/web-origin/logout-URI consistency), and what it raises vs. returns. No behavior change -- docstring only, verified via ast.parse. Co-Authored-By: Claude Sonnet 5 --- .../account_unification/app/relying_party.py | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) 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", From 79fe43d695e47c4b0faf62e453eb4349aad25713 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 17:37:35 +0900 Subject: [PATCH 06/20] fix(adr-0014): disable directAccessGrantsEnabled, fix stale index status Devin review on #128 found the ADR's own Correction (2026-09-03) rejects grant_type=password (RFC 9700 SS2.4 / RFC 10017 SS7.3), but two artifacts still approved the invalidated mechanism: the ADR index (docs/adr/README.md) still showed a bare "Accepted" with no caveat, and naruon-web's directAccessGrantsEnabled stayed true in the committed realm export. Set the flag back to false as a fail-closed measure -- this PR is unmerged and nothing live depended on it staying true -- and updated the ADR index and deploy/keycloak/README.md to match the ADR's own status line. ADR-0014's Decision section is left unedited as the historical record of what was originally decided, per this repo's repair-not-rewrite-history convention; a new note in the Correction section points out it no longer matches the live config value. No test asserts naruon-web's directAccessGrantsEnabled must be true; services/account_unification/tests/test_realm_policy.py and scripts/validate_realm.py both pass unchanged. Co-Authored-By: Claude Sonnet 5 --- deploy/keycloak/README.md | 15 +++++++++------ deploy/keycloak/realm-cwl.json | 2 +- docs/adr/0014-naruon-owned-password-form.md | 9 +++++++++ docs/adr/README.md | 2 +- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index 1bff673..64eb451 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -70,12 +70,15 @@ 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` also has `directAccessGrantsEnabled: true` — a scoped, reviewed -exception ([ADR-0014](../../docs/adr/0014-naruon-owned-password-form.md)) so -naruon can render its own login form with zero Keycloak-rendered HTML in the -loop. No other RP gets this exception; the account-unification dynamic- -registration validator still hard-rejects `directAccessGrantsEnabled: true` -for everyone else. +`naruon-web`'s `directAccessGrantsEnabled` is currently `false`. It was +briefly `true` as a scoped, reviewed exception +([ADR-0014](../../docs/adr/0014-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` diff --git a/deploy/keycloak/realm-cwl.json b/deploy/keycloak/realm-cwl.json index 40d76b8..b210068 100644 --- a/deploy/keycloak/realm-cwl.json +++ b/deploy/keycloak/realm-cwl.json @@ -303,7 +303,7 @@ "publicClient": true, "standardFlowEnabled": true, "implicitFlowEnabled": false, - "directAccessGrantsEnabled": true, + "directAccessGrantsEnabled": false, "serviceAccountsEnabled": false, "redirectUris": [ "https://naruon.example/auth/callback", diff --git a/docs/adr/0014-naruon-owned-password-form.md b/docs/adr/0014-naruon-owned-password-form.md index c0ead3d..65182b9 100644 --- a/docs/adr/0014-naruon-owned-password-form.md +++ b/docs/adr/0014-naruon-owned-password-form.md @@ -56,6 +56,15 @@ session/challenge API for password and/or passkey/WebAuthn authentication — th 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. Re-enable only alongside a +standards-compliant replacement mechanism (see the candidates above), tracked in the new ADR +called for below. + **Status intentionally left as Accepted, not Rejected/Superseded**, because the product goal stands and the Context/ruled-out-alternatives sections remain load-bearing evidence — only the grant-type mechanism in the Decision needs a successor. Per this org's repair-not-close convention for findings against an diff --git a/docs/adr/README.md b/docs/adr/README.md index f5fe7bf..22a9728 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,7 +18,7 @@ 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 | -| [0014](0014-naruon-owned-password-form.md) | Scoped Direct Access Grants exception for `naruon-web` only, so naruon can render its own login form with zero Keycloak-rendered HTML; every other RP still hard-blocked | Accepted | +| [0014](0014-naruon-owned-password-form.md) | Scoped Direct Access Grants exception for `naruon-web` only, so naruon can render its own login form with zero Keycloak-rendered HTML; every other RP still hard-blocked | Accepted, mechanism blocked — see the ADR's Correction (2026-09-03) | | [0015](0015-naruon-password-credential-issuance.md) | `POST /registration/accounts/password`: scoped, third-token-gated account-unification endpoint that gives naruon signups an immediately usable password credential, closing ADR-0014's "nothing can log in yet" gap | Accepted | ADR numbering note: protected `main` currently ends at ADR-0008. ADR-0009 is From 44f0cb9cfcf94d92601a38ce9d1f917b47eabe17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 17:50:57 +0900 Subject: [PATCH 07/20] fix(adr-0015): fail closed password registration, guard realm regression Devin's follow-up review on #128 (after 79fe43d disabled directAccessGrants) found the actual cascading bug: POST /registration/accounts/password still created password-only accounts with required_actions=[], but with ROPC disabled there is no way to log into them -- the bound browser-passwordless flow accepts only passkeys. Confirmed by reading the endpoint directly: its own docstring said "so a Direct Access Grants login right after signup succeeds", which is no longer true. Fixed by gating the endpoint behind a module constant (PASSWORD_CREDENTIAL_LOGIN_AVAILABLE = False), mirroring the same fail-closed pattern used for the realm flag -- a single flippable point, not a rewrite of the account-creation/rollback/rate-limit logic underneath, which stays intact and fully covered via monkeypatch-enabled tests for whenever a standards-compliant replacement ships. Added test_registration_fails_closed_by_default for the new default branch. Also addressed two more Devin findings on the same review pass: - scripts/validate_realm.py never asserted directAccessGrantsEnabled must stay false for naruon-web, so a later realm edit could silently restore the blocked grant while CI still passed. Added the check (test_naruon_direct_access_grants_stays_disabled covers it). - ADR-0015 and its docs/adr/README.md index row still promised immediate Direct Access Grants login. Added a Correction section mirroring ADR-0014's, status line updated to match. Verified: services/account_unification full suite (100% pass), coverage --branch --source=app --fail-under=100 (100%), interrogate (100%), ruff (clean), scripts/validate_realm.py, make test, make validate-realm, tests/test_documentation_contract.py -- all pass. Co-Authored-By: Claude Sonnet 5 --- ...015-naruon-password-credential-issuance.md | 21 ++++++++++- docs/adr/README.md | 2 +- scripts/validate_realm.py | 11 +++++- .../app/password_registration.py | 21 ++++++++++- .../tests/test_password_registration.py | 37 +++++++++++++++++-- .../tests/test_realm_policy.py | 11 ++++++ 6 files changed, 95 insertions(+), 8 deletions(-) diff --git a/docs/adr/0015-naruon-password-credential-issuance.md b/docs/adr/0015-naruon-password-credential-issuance.md index 7b4c07f..a5c9ebf 100644 --- a/docs/adr/0015-naruon-password-credential-issuance.md +++ b/docs/adr/0015-naruon-password-credential-issuance.md @@ -1,6 +1,7 @@ # ADR-0015: Scoped password-credential issuance so naruon's signup form actually logs in -**Status:** Accepted +**Status:** Accepted, endpoint fails closed pending RFC-compliant redesign — see +**Correction (2026-09-03)** below before implementing anything against this ADR. **Date:** 2026-09-02 **Decision owner:** Keyverse maintainers **Scope:** A new, narrowly scoped account-unification endpoint that creates a @@ -9,6 +10,24 @@ 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-0014](0014-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 rest of +this ADR's Decision, Security tradeoffs, and Deferred sections are kept as the +historical record of what was built and why; flip the constant back to `True` +only alongside the same standards-compliant login replacement ADR-0014 calls for. + ## Context [ADR-0014](0014-naruon-owned-password-form.md) enabled `directAccessGrantsEnabled` diff --git a/docs/adr/README.md b/docs/adr/README.md index 22a9728..a562f82 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,7 +19,7 @@ authorization boundary and is not rewritten by that expansion. | [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 | | [0014](0014-naruon-owned-password-form.md) | Scoped Direct Access Grants exception for `naruon-web` only, so naruon can render its own login form with zero Keycloak-rendered HTML; every other RP still hard-blocked | Accepted, mechanism blocked — see the ADR's Correction (2026-09-03) | -| [0015](0015-naruon-password-credential-issuance.md) | `POST /registration/accounts/password`: scoped, third-token-gated account-unification endpoint that gives naruon signups an immediately usable password credential, closing ADR-0014's "nothing can log in yet" gap | Accepted | +| [0015](0015-naruon-password-credential-issuance.md) | `POST /registration/accounts/password`: scoped, third-token-gated account-unification endpoint that gives naruon signups an immediately usable password credential, closing ADR-0014's "nothing can log in yet" gap | Accepted, endpoint fails closed — see the ADR's Correction (2026-09-03) | 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 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/password_registration.py b/services/account_unification/app/password_registration.py index a7fd5c9..fbe5c38 100644 --- a/services/account_unification/app/password_registration.py +++ b/services/account_unification/app/password_registration.py @@ -39,6 +39,19 @@ _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.""" @@ -202,7 +215,13 @@ def register_account_with_password( request: Request, api: ProductAdminApi = Depends(get_admin_api), ) -> PasswordRegistrationResult: - """Create an account with an immediately usable password credential.""" + """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) diff --git a/services/account_unification/tests/test_password_registration.py b/services/account_unification/tests/test_password_registration.py index 920e4ec..cee4501 100644 --- a/services/account_unification/tests/test_password_registration.py +++ b/services/account_unification/tests/test_password_registration.py @@ -38,14 +38,37 @@ def _wire_password_registration_app(api): @pytest.fixture -def client(api): - """Return a password-registration-authenticated test client.""" +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]: @@ -386,9 +409,12 @@ def test_password_registration_window_resets_after_expiry(monkeypatch) -> None: password_registration_module._record_registration_attempt("caller") -def test_password_registration_reports_empty_upstream_identifier() -> None: +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( @@ -401,9 +427,12 @@ def test_password_registration_reports_empty_upstream_identifier() -> None: assert error.value.detail == "account_creation_failed" -def test_password_registration_preserves_non_conflict_keycloak_errors() -> None: +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( 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") From 86de97078ed4a16ab522e2ee26fc5d1225ee4a57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 18:04:54 +0900 Subject: [PATCH 08/20] docs(adr-0014/0015): OpenAPI accuracy, changelog, operability, doctoring record Devin's third review pass on #128 found two more real gaps: the generated OpenAPI docs for POST /registration/accounts/password still promised 201 account creation with no mention it now always returns 503, and disabling the endpoint had no CHANGELOG entry, operations runbook note, or APA 7th doctoring record -- required by this repo's own documentation-traceability convention. Fixed: - password_registration.py: added a `responses={503: {...}}` entry to the route decorator so the generated OpenAPI schema documents the current fail-closed behavior (confirmed via app.openapi() that '503' now appears alongside '201'/'422'). - CHANGELOG.md: new Fixed entry summarizing the two-pass ROPC correction. - docs/OPERABILITY.md: new "naruon password-signup 503 (expected, not an incident)" runbook section so on-call doesn't treat the deliberate 503 as a live-dependency failure. - docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md: new record following this repo's established doctoring format (Scope / Interpretation / Evidence / References), covering both fix passes. - ADR-0014 and ADR-0015: added formal APA 7th References entries for RFC 9700 and RFC 10017 (previously only cited inline in the Correction prose). Cited by issuing organization rather than named individual editors -- RFC 10017 is dated after any available verification cutoff, so inventing an author list for it (or asserting unverified authors for RFC 9700) would risk misattribution; a note says to confirm editors directly from the RFC Editor page before citing either with named authors elsewhere. Verified: full test suite, coverage --branch --source=app --fail-under=100 (100%), interrogate (100%), ruff (clean), tests/test_documentation_contract.py, and a direct app.openapi() check confirming the 503 response is documented. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 11 ++ docs/OPERABILITY.md | 21 ++++ docs/adr/0014-naruon-owned-password-form.md | 9 ++ ...015-naruon-password-credential-issuance.md | 9 ++ ...ruon-password-ropc-standards-correction.md | 119 ++++++++++++++++++ .../app/password_registration.py | 11 ++ 6 files changed, 180 insertions(+) create mode 100644 docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4639f1a..6f28556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,17 @@ Keep a Changelog, and releases use semantic versioning. ### Fixed +- Disabled `naruon-web`'s Direct Access Grants (ADR-0014) and the + `POST /registration/accounts/password` signup endpoint that depended on it + (ADR-0015): 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`) behind a single flippable module constant rather than + create dead accounts; `scripts/validate_realm.py` now 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/docs/OPERABILITY.md b/docs/OPERABILITY.md index 9e5528a..224fa29 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-0015) 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-0014'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-0014; 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/0014-naruon-owned-password-form.md b/docs/adr/0014-naruon-owned-password-form.md index 65182b9..936b16b 100644 --- a/docs/adr/0014-naruon-owned-password-form.md +++ b/docs/adr/0014-naruon-owned-password-form.md @@ -218,5 +218,14 @@ 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/0015-naruon-password-credential-issuance.md b/docs/adr/0015-naruon-password-credential-issuance.md index a5c9ebf..146de7f 100644 --- a/docs/adr/0015-naruon-password-credential-issuance.md +++ b/docs/adr/0015-naruon-password-credential-issuance.md @@ -206,6 +206,15 @@ 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/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..af9f7bc --- /dev/null +++ b/docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md @@ -0,0 +1,119 @@ +# 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-0014) and the `POST /registration/accounts/password` signup endpoint +that depended on it (ADR-0015), 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-0014, 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-0014'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-0014 + 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-0014'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-0015 + gained a Correction section mirroring ADR-0014's. +- **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 + 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-0014'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/services/account_unification/app/password_registration.py b/services/account_unification/app/password_registration.py index fbe5c38..275932d 100644 --- a/services/account_unification/app/password_registration.py +++ b/services/account_unification/app/password_registration.py @@ -209,6 +209,17 @@ def _create_account_with_password( "/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, From 31a21ca80f80a9f1c8998b032afe5bc016c6d92a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 18:12:03 +0900 Subject: [PATCH 09/20] test(auth): reject cleartext Keycloak admin URLs --- .../tests/test_identifiers.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/services/account_unification/tests/test_identifiers.py b/services/account_unification/tests/test_identifiers.py index e6572d1..064126c 100644 --- a/services/account_unification/tests/test_identifiers.py +++ b/services/account_unification/tests/test_identifiers.py @@ -42,6 +42,20 @@ def test_validate_path_segment_accepts_uuid_and_slug(): assert validate_path_segment("employer-adfs") == "employer-adfs" +def test_product_admin_client_rejects_cleartext_server_url(): + """Product credentials cannot be bound to a cleartext Keycloak origin.""" + with pytest.raises(ValueError, match="server_url must be an absolute HTTPS URI"): + ProductHttpAdminApi( + server_url="http://keycloak.test", + 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 +67,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 +92,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", From c2a5013dea99152c4fa07711639811e5791c8926 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 18:13:07 +0900 Subject: [PATCH 10/20] test(auth): use TLS origins in product transport fixtures --- .../account_unification/tests/test_keycloak_client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/services/account_unification/tests/test_keycloak_client.py b/services/account_unification/tests/test_keycloak_client.py index 9cd779a..61dbe85 100644 --- a/services/account_unification/tests/test_keycloak_client.py +++ b/services/account_unification/tests/test_keycloak_client.py @@ -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", @@ -291,7 +291,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) api = ProductHttpAdminApi( - "http://keycloak.test", + "https://keycloak.test", "cwl", "svc", "secret", @@ -324,7 +324,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) api = ProductHttpAdminApi( - "http://keycloak.test", + "https://keycloak.test", "cwl", "svc", "secret", @@ -362,7 +362,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", @@ -395,7 +395,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", From 7deedf02bab4b745eb2a42df4692a35c3e48ae5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 18:14:45 +0900 Subject: [PATCH 11/20] test(auth): cover Keycloak origin validation edges --- .../tests/test_identifiers.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/services/account_unification/tests/test_identifiers.py b/services/account_unification/tests/test_identifiers.py index 064126c..af09cb6 100644 --- a/services/account_unification/tests/test_identifiers.py +++ b/services/account_unification/tests/test_identifiers.py @@ -42,11 +42,21 @@ def test_validate_path_segment_accepts_uuid_and_slug(): assert validate_path_segment("employer-adfs") == "employer-adfs" -def test_product_admin_client_rejects_cleartext_server_url(): - """Product credentials cannot be bound to a cleartext Keycloak origin.""" +@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="http://keycloak.test", + server_url=server_url, realm="cwl", client_id="account-unification-svc", client_secret="secret", From cd0e4358b638fe2daefc72f5e44f0c2cbade5bf5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 18:16:01 +0900 Subject: [PATCH 12/20] fix(auth): enforce TLS on product Keycloak transport --- .../app/product_keycloak_client.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/services/account_unification/app/product_keycloak_client.py b/services/account_unification/app/product_keycloak_client.py index 1fd6669..6a6f4bd 100644 --- a/services/account_unification/app/product_keycloak_client.py +++ b/services/account_unification/app/product_keycloak_client.py @@ -150,13 +150,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, @@ -510,7 +520,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)}" From 70c800410ea5df9475463f8820cb607fccd27e95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 18:20:29 +0900 Subject: [PATCH 13/20] test(auth): require seed-time password token revocation --- .../tests/test_deployment_contracts.py | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/services/account_unification/tests/test_deployment_contracts.py b/services/account_unification/tests/test_deployment_contracts.py index b346d29..ce4a62c 100644 --- a/services/account_unification/tests/test_deployment_contracts.py +++ b/services/account_unification/tests/test_deployment_contracts.py @@ -1,11 +1,16 @@ -"""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 yaml +from app.config import KEY_PASSWORD_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 +107,53 @@ 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 + + +def test_local_reseed_revokes_omitted_password_registration_token( + tmp_path: Path, monkeypatch +) -> None: + """Omitting the password token on a later seed revokes stale signup access.""" + database_path = tmp_path / "idp_config_store.db" + namespace = "account_unification" + + monkeypatch.setattr( + sys, + "argv", + [ + "seed_config_store.py", + "--db", + str(database_path), + "--namespace", + namespace, + "--password-registration-token", + "old-password-signup-token", + ], + ) + assert seed_config_store.main() == 0 + + store = SqliteKvStore(str(database_path)) + try: + assert store.get(namespace, KEY_PASSWORD_REGISTRATION_API_TOKEN) == ( + "old-password-signup-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, KEY_PASSWORD_REGISTRATION_API_TOKEN) is None + finally: + store.close() From f6912b1d6227223aecaa238f7fa138e0e99171aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 18:20:53 +0900 Subject: [PATCH 14/20] fix(auth): revoke omitted password registration token --- services/account_unification/tools/seed_config_store.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/account_unification/tools/seed_config_store.py b/services/account_unification/tools/seed_config_store.py index 6cdf092..2724402 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 a later +seed without the password-registration token revokes any stale stored value. """ from __future__ import annotations @@ -124,6 +125,8 @@ def main() -> int: } for entry_key, entry_value in entries.items(): store.put(args.namespace, entry_key, entry_value) + 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}") From 10f28e713cf5d2ecf4d78845a7c3cbffe2e18d60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 18:21:32 +0900 Subject: [PATCH 15/20] test(auth): require seed-time signup token revocation --- .../tests/test_deployment_contracts.py | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/services/account_unification/tests/test_deployment_contracts.py b/services/account_unification/tests/test_deployment_contracts.py index ce4a62c..ccc6346 100644 --- a/services/account_unification/tests/test_deployment_contracts.py +++ b/services/account_unification/tests/test_deployment_contracts.py @@ -5,9 +5,13 @@ import tomllib from pathlib import Path +import pytest import yaml -from app.config import KEY_PASSWORD_REGISTRATION_API_TOKEN +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 @@ -109,12 +113,24 @@ def test_local_seed_keeps_registration_disabled_by_default() -> None: assert "if not args.registration_token" in seed_tool -def test_local_reseed_revokes_omitted_password_registration_token( - tmp_path: Path, monkeypatch +@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 the password token on a later seed revokes stale signup access.""" - database_path = tmp_path / "idp_config_store.db" + """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, @@ -125,17 +141,15 @@ def test_local_reseed_revokes_omitted_password_registration_token( str(database_path), "--namespace", namespace, - "--password-registration-token", - "old-password-signup-token", + token_option, + stale_token, ], ) assert seed_config_store.main() == 0 store = SqliteKvStore(str(database_path)) try: - assert store.get(namespace, KEY_PASSWORD_REGISTRATION_API_TOKEN) == ( - "old-password-signup-token" - ) + assert store.get(namespace, entry_key) == stale_token finally: store.close() @@ -154,6 +168,6 @@ def test_local_reseed_revokes_omitted_password_registration_token( store = SqliteKvStore(str(database_path)) try: - assert store.get(namespace, KEY_PASSWORD_REGISTRATION_API_TOKEN) is None + assert store.get(namespace, entry_key) is None finally: store.close() From 5f61cab26d2943b50667d32f241ddcead688f85b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 18:21:59 +0900 Subject: [PATCH 16/20] fix(auth): revoke omitted registration token --- .../account_unification/tools/seed_config_store.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/services/account_unification/tools/seed_config_store.py b/services/account_unification/tools/seed_config_store.py index 2724402..b9f4be8 100644 --- a/services/account_unification/tools/seed_config_store.py +++ b/services/account_unification/tools/seed_config_store.py @@ -3,8 +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, and a later -seed without the password-registration token revokes any stale stored value. +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 @@ -73,9 +73,9 @@ def _build_parser() -> argparse.ArgumentParser: "--password-registration-token", default="", help=( - "Enable naruon's own password-signup form (scoped Direct Access " - "Grants exception, naruon-web only) only when a dedicated token, " - "distinct from --registration-token, is supplied." + "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( @@ -125,6 +125,8 @@ def main() -> int: } 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: From 6ffef105546c29e44ec23a608e8d108acaa3b665 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 18:30:40 +0900 Subject: [PATCH 17/20] docs(product-keycloak-client): flag reset_password as dormant capability Devin's review on #128 (third pass) found that PASSWORD_CREDENTIAL_LOGIN_AVAILABLE blocks every request to the endpoint that used to call ProductAdminApi.reset_password, but the shared admin client's capability to call Keycloak's reset-password endpoint is unaffected -- an unused-but-present authority surface on a client used broadly across the whole account_unification service. Not removing the method or its _ADMIN_PATH_PATTERNS entry outright: doing so would need either verifying no other current or near-term caller needs it, or accepting the endpoint can never be re-enabled without restoring it -- neither verified in this pass. Documented it as dormant instead, on both the Protocol declaration and the HttpAdminApi implementation, pointing at ADR-0014's Correction and this finding, so it gets re-scoped deliberately alongside whatever replacement login mechanism lands rather than assumed still load-bearing. Co-Authored-By: Claude Sonnet 5 --- .../app/product_keycloak_client.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/services/account_unification/app/product_keycloak_client.py b/services/account_unification/app/product_keycloak_client.py index 6a6f4bd..56ecdf5 100644 --- a/services/account_unification/app/product_keycloak_client.py +++ b/services/account_unification/app/product_keycloak_client.py @@ -97,7 +97,20 @@ def delete_user(self, user_id: str) -> None: ... def reset_password(self, user_id: str, password: str) -> None: - """Set an immediately usable (non-temporary) password credential.""" + """Set an immediately usable (non-temporary) password credential. + + Dormant as of docs/adr/0014-naruon-owned-password-form.md's + Correction (2026-09-03): its only caller, + ``password_registration.register_account_with_password``, fails + closed before ever reaching this method while + ``PASSWORD_CREDENTIAL_LOGIN_AVAILABLE`` is ``False``. This shared + admin client's authority to call Keycloak's reset-password endpoint + is therefore currently unused capability, not currently-exercised + behavior (Devin Review, keyverse#128, 2026-09-03) -- re-scope + alongside whatever replacement login mechanism the ADR's Correction + calls for, rather than assuming this method's mere presence on the + shared client is still load-bearing. + """ ... def get_identity_provider(self, provider_alias: str) -> dict | None: @@ -466,6 +479,10 @@ def reset_password(self, user_id: str, password: str) -> None: ``password`` reaches Keycloak only as this call's JSON body over the existing authenticated HTTPS transport; it is never logged, retried with a captured copy, or included in any exception message here. + + Dormant as of docs/adr/0014-naruon-owned-password-form.md's + Correction (2026-09-03) -- see the ``ProductAdminApi`` Protocol + declaration of this same method above for why. """ safe_user_id = self._safe_segment(user_id, "user_id") path = self._guard_path( From bad8635f2d1aa94023fc6482dad35f6a1b688176 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 17:56:23 +0900 Subject: [PATCH 18/20] fix(auth): remove dormant password reset authority --- CHANGELOG.md | 6 +-- ...015-naruon-password-credential-issuance.md | 9 ++-- ...ruon-password-ropc-standards-correction.md | 9 +++- .../app/product_keycloak_client.py | 41 ------------------- .../tests/test_keycloak_client.py | 24 ++++++----- 5 files changed, 28 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f28556..2f33642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,9 +117,9 @@ Keep a Changelog, and releases use semantic versioning. 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`) behind a single flippable module constant rather than - create dead accounts; `scripts/validate_realm.py` now rejects a silent - re-enable of the grant. See + 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 diff --git a/docs/adr/0015-naruon-password-credential-issuance.md b/docs/adr/0015-naruon-password-credential-issuance.md index 146de7f..f9a39e9 100644 --- a/docs/adr/0015-naruon-password-credential-issuance.md +++ b/docs/adr/0015-naruon-password-credential-issuance.md @@ -23,10 +23,11 @@ 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 rest of -this ADR's Decision, Security tradeoffs, and Deferred sections are kept as the -historical record of what was built and why; flip the constant back to `True` -only alongside the same standards-compliant login replacement ADR-0014 calls for. += 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 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 index af9f7bc..07ca3ab 100644 --- a/docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md +++ b/docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md @@ -80,6 +80,11 @@ resolved here. `test_naruon_direct_access_grants_stays_disabled` (`services/account_unification/tests/test_realm_policy.py`); ADR-0015 gained a Correction section mirroring ADR-0014'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` @@ -87,8 +92,8 @@ resolved here. 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 - mechanism -- naruon's password-signup surface stays unavailable (`503`) +- **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 diff --git a/services/account_unification/app/product_keycloak_client.py b/services/account_unification/app/product_keycloak_client.py index 56ecdf5..b157338 100644 --- a/services/account_unification/app/product_keycloak_client.py +++ b/services/account_unification/app/product_keycloak_client.py @@ -36,7 +36,6 @@ ("users", None, "groups"), ("users", None, "groups", None), ("users", None, "execute-actions-email"), - ("users", None, "reset-password"), ("identity-provider", "instances"), ("identity-provider", "instances", None), ("components",), @@ -96,23 +95,6 @@ def delete_user(self, user_id: str) -> None: """Delete one user during failed registration rollback.""" ... - def reset_password(self, user_id: str, password: str) -> None: - """Set an immediately usable (non-temporary) password credential. - - Dormant as of docs/adr/0014-naruon-owned-password-form.md's - Correction (2026-09-03): its only caller, - ``password_registration.register_account_with_password``, fails - closed before ever reaching this method while - ``PASSWORD_CREDENTIAL_LOGIN_AVAILABLE`` is ``False``. This shared - admin client's authority to call Keycloak's reset-password endpoint - is therefore currently unused capability, not currently-exercised - behavior (Devin Review, keyverse#128, 2026-09-03) -- re-scope - alongside whatever replacement login mechanism the ADR's Correction - calls for, rather than assuming this method's mere presence on the - shared client is still load-bearing. - """ - ... - def get_identity_provider(self, provider_alias: str) -> dict | None: """Return one identity-provider instance or ``None`` when absent.""" ... @@ -473,29 +455,6 @@ def delete_user(self, user_id: str) -> None: safe_user_id = self._safe_segment(user_id, "user_id") self._delete(f"/admin/realms/{self._realm}/users/{safe_user_id}") - def reset_password(self, user_id: str, password: str) -> None: - """Set an immediately usable (non-temporary) password credential. - - ``password`` reaches Keycloak only as this call's JSON body over the - existing authenticated HTTPS transport; it is never logged, retried - with a captured copy, or included in any exception message here. - - Dormant as of docs/adr/0014-naruon-owned-password-form.md's - Correction (2026-09-03) -- see the ``ProductAdminApi`` Protocol - declaration of this same method above for why. - """ - safe_user_id = self._safe_segment(user_id, "user_id") - path = self._guard_path( - f"/admin/realms/{self._realm}/users/{safe_user_id}/reset-password" - ) - self._send_with_reauth( - lambda: self._client.put( - path, - json={"type": "password", "value": password, "temporary": False}, - headers=self._auth_header(), - ) - ) - def get_identity_provider(self, provider_alias: str) -> dict | None: """Return an identity provider or ``None`` for a Keycloak 404.""" safe_alias = self._safe_segment(provider_alias, "provider_alias") diff --git a/services/account_unification/tests/test_keycloak_client.py b/services/account_unification/tests/test_keycloak_client.py index 61dbe85..07f3a55 100644 --- a/services/account_unification/tests/test_keycloak_client.py +++ b/services/account_unification/tests/test_keycloak_client.py @@ -227,7 +227,6 @@ def handler(request: httpx.Request) -> httpx.Response: redirect_uri="https://naruon.example/auth/passkey-complete", lifespan_seconds=900, ) - api.reset_password("u1", "correct horse battery staple 1!") api.create_identity_provider({"alias": "employer-adfs"}) api.update_identity_provider( "employer-adfs", @@ -256,17 +255,20 @@ def handler(request: httpx.Request) -> httpx.Response: "VERIFY_EMAIL", "webauthn-register-passwordless", ] - reset_password_request = next( - call - for call in calls - if call.url.path.endswith("/users/u1/reset-password") + + +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={})), ) - assert reset_password_request.method == "PUT" - assert json.loads(reset_password_request.content) == { - "type": "password", - "value": "correct horse battery staple 1!", - "temporary": False, - } + 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: From 239e362c95d48894a10841ec8a087f9107f3f90c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:21:50 +0900 Subject: [PATCH 19/20] test(actions): remove retired steward contract --- .../tests/test_hourly_pr_steward.py | 78 ------------------- 1 file changed, 78 deletions(-) delete mode 100644 services/account_unification/tests/test_hourly_pr_steward.py diff --git a/services/account_unification/tests/test_hourly_pr_steward.py b/services/account_unification/tests/test_hourly_pr_steward.py deleted file mode 100644 index 910133e..0000000 --- a/services/account_unification/tests/test_hourly_pr_steward.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Static contract tests for the hourly protected PR steward.""" -from __future__ import annotations - -from pathlib import Path - - -def _workflow_source() -> str: - """Return the repository's hourly PR stewardship workflow source.""" - repository_root = Path(__file__).resolve().parents[3] - return ( - repository_root / ".github" / "workflows" / "hourly-pr-steward.yml" - ).read_text(encoding="utf-8") - - -def _permissions_block(source: str, marker: str, terminator: str) -> str: - """Return one indentation-sensitive workflow permissions block.""" - block_start = source.index(marker) - block_end = source.index(terminator, block_start) - return source[block_start:block_end] - - -def test_hourly_steward_runs_once_per_hour_with_bounded_concurrency() -> None: - """The schedule is hourly and overlapping steward runs are serialized.""" - workflow = _workflow_source() - assert 'cron: "17 * * * *"' in workflow - assert "group: hourly-pr-steward" in workflow - assert "cancel-in-progress: false" in workflow - assert "timeout-minutes: 10" in workflow - - -def test_hourly_steward_uses_read_only_workflow_token_defaults() -> None: - """Only the steward job receives its narrowly required write scopes.""" - workflow = _workflow_source() - top_level_permissions = _permissions_block( - workflow, - "permissions:\n", - "\nconcurrency:", - ) - job_permissions = _permissions_block( - workflow, - " permissions:\n", - " steps:", - ) - - assert "contents: read" in top_level_permissions - assert "write" not in top_level_permissions - assert "contents: write" in job_permissions - assert "pull-requests: write" in job_permissions - assert "checks: read" in job_permissions - assert "security-events: write" not in workflow - assert "actions: write" not in workflow - - -def test_hourly_steward_is_fail_closed_on_trust_review_and_checks() -> None: - """Untrusted, unapproved, pending, or failed pull requests remain untouched.""" - workflow = _workflow_source() - assert 'head_owner" != "ContextualWisdomLab"' in workflow - assert 'trusted_author" != "true"' in workflow - assert 'review_decision" != "APPROVED"' in workflow - assert 'gh pr checks "$number" --repo "$REPOSITORY" --required' in workflow - assert "--admin" not in workflow - - -def test_hourly_steward_invalidates_old_evidence_after_branch_update() -> None: - """A branch update exits the current iteration before merging stale evidence.""" - workflow = _workflow_source() - update_position = workflow.index("gh pr update-branch") - continue_position = workflow.index("continue", update_position) - approval_position = workflow.index('review_decision" != "APPROVED"') - assert update_position < continue_position < approval_position - - -def test_hourly_steward_binds_auto_merge_to_the_checked_head() -> None: - """GitHub auto-merge is armed only for the enumerated exact head SHA.""" - workflow = _workflow_source() - assert '--auto \\' in workflow - assert '--squash \\' in workflow - assert '--match-head-commit "$head_sha"' in workflow From f893ec6f5ecf1a324365b684505f8dff0cbc468c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:37:38 +0900 Subject: [PATCH 20/20] docs(auth): propose product-rendered passkey ceremonies Preserve the disabled password proposals as history, renumber them to avoid published PR129 reservations, and correct their premature Accepted status. ADR0019 records engine verifier reuse, origin/RP-ID migration, transaction and replay binding, signup/recovery proofs, actual browser acceptance, and release/rollback gates. Validation: seven repository documentation tests passed on pinned CI Python 3.12; 26 local document links resolve; diff check passes. Production code, tests, workflows and runtime configuration are unchanged. This proposal does not implement authentication or claim protected acceptance. --- ARCHITECTURE.md | 8 + CHANGELOG.md | 8 +- deploy/keycloak/README.md | 4 +- docs/OPERABILITY.md | 6 +- ....md => 0017-naruon-owned-password-form.md} | 44 ++-- ...18-naruon-password-credential-issuance.md} | 26 ++- .../0019-product-owned-passkey-ceremonies.md | 208 ++++++++++++++++++ docs/adr/README.md | 13 +- ...ruon-password-ropc-standards-correction.md | 18 +- .../2026-09-07-product-passkey-ceremonies.md | 97 ++++++++ docs/product-technical-gap-baseline.md | 12 + 11 files changed, 393 insertions(+), 51 deletions(-) rename docs/adr/{0014-naruon-owned-password-form.md => 0017-naruon-owned-password-form.md} (88%) rename docs/adr/{0015-naruon-password-credential-issuance.md => 0018-naruon-password-credential-issuance.md} (93%) create mode 100644 docs/adr/0019-product-owned-passkey-ceremonies.md create mode 100644 docs/doctoring/2026-09-07-product-passkey-ceremonies.md 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 8b71f62..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,9 +115,9 @@ Keep a Changelog, and releases use semantic versioning. ### Fixed -- Disabled `naruon-web`'s Direct Access Grants (ADR-0014) and the +- Disabled `naruon-web`'s Direct Access Grants (ADR-0017) and the `POST /registration/accounts/password` signup endpoint that depended on it - (ADR-0015): RFC 9700 §2.4 (BCP 240) and RFC 10017 §7.3 prohibit the OAuth + (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 diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index 64eb451..cff8b40 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -72,7 +72,7 @@ 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-0014](../../docs/adr/0014-naruon-owned-password-form.md)) so naruon +([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 @@ -82,7 +82,7 @@ still hard-rejects `directAccessGrantsEnabled: true` for everyone else. A real password credential to authenticate with comes from `POST /registration/accounts/password` -([ADR-0015](../../docs/adr/0015-naruon-password-credential-issuance.md)), +([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) diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 224fa29..719bcff 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -68,10 +68,10 @@ Mapper unit tests alone do not prove Naruon product authorization readiness. ## naruon password-signup 503 (expected, not an incident) -`POST /registration/accounts/password` (ADR-0015) currently returns `503` on +`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-0014's Correction, RFC 9700 §2.4 / RFC 10017 §7.3), +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 @@ -83,7 +83,7 @@ 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-0014; flipping the constant back to `True` without that replacement +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`. diff --git a/docs/adr/0014-naruon-owned-password-form.md b/docs/adr/0017-naruon-owned-password-form.md similarity index 88% rename from docs/adr/0014-naruon-owned-password-form.md rename to docs/adr/0017-naruon-owned-password-form.md index 936b16b..b1f8856 100644 --- a/docs/adr/0014-naruon-owned-password-form.md +++ b/docs/adr/0017-naruon-owned-password-form.md @@ -1,6 +1,11 @@ -# ADR-0014: Scoped Direct Access Grants exception so naruon can render its own login form - -**Status:** Accepted, mechanism superseded pending RFC-compliant redesign — see **Correction (2026-09-03)** below before implementing anything against this ADR. +# 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). @@ -38,11 +43,9 @@ comment thread (2026-09-03T02:59:17Z) for the original citation. Primary referen [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, unchanged:** every section below this one — the Context, what was ruled out and -why (Keycloak-theme reskin fails by construction; a naruon-rendered WebAuthn ceremony against Keycloak -is currently unachievable without a custom Keycloak REST resource provider) — is still accurate. Read -the rest of this ADR as the record of *why the product requirement exists and what does not solve it*, -not as license to ship the specific mechanism in point 1 of the Decision below. +**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 @@ -61,16 +64,13 @@ ceremony, so this investment may resolve both gaps (password AND passkey) at onc 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. Re-enable only alongside a -standards-compliant replacement mechanism (see the candidates above), tracked in the new ADR -called for below. +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 intentionally left as Accepted, not Rejected/Superseded**, because the product goal stands and -the Context/ruled-out-alternatives sections remain load-bearing evidence — only the grant-type mechanism -in the Decision needs a successor. Per this org's repair-not-close convention for findings against an -already-Accepted decision with real, still-valid product intent behind it: open a new ADR once a -replacement mechanism is chosen and cross-reference it here, rather than silently rewriting this one's -history or treating this correction as grounds to abandon the underlying naruon-owned-login-form goal. +**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 @@ -129,7 +129,7 @@ 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. -## Decision +## Historical mechanism proposal (disabled) 1. `naruon-web`'s `directAccessGrantsEnabled` is `true` in `deploy/keycloak/realm-cwl.json`. This is *this* ADR's "explicit @@ -155,13 +155,13 @@ provided it is never logged, cached, or persisted. reason string, never with the credential) and never written to a cookie, session, or datastore naruon controls. -## What this does *not* yet deliver +## Historical delivery discussion (superseded by the correction above) **Update (2026-09-02):** the credential-issuance gap this section describes -is now closed by [ADR-0015](0015-naruon-password-credential-issuance.md) +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-0014 alone did and did not deliver. +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.** @@ -182,7 +182,7 @@ 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. -## Consequences +## 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 diff --git a/docs/adr/0015-naruon-password-credential-issuance.md b/docs/adr/0018-naruon-password-credential-issuance.md similarity index 93% rename from docs/adr/0015-naruon-password-credential-issuance.md rename to docs/adr/0018-naruon-password-credential-issuance.md index f9a39e9..656ec4a 100644 --- a/docs/adr/0015-naruon-password-credential-issuance.md +++ b/docs/adr/0018-naruon-password-credential-issuance.md @@ -1,7 +1,11 @@ -# ADR-0015: Scoped password-credential issuance so naruon's signup form actually logs in - -**Status:** Accepted, endpoint fails closed pending RFC-compliant redesign — see -**Correction (2026-09-03)** below before implementing anything against this ADR. +# 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 @@ -12,7 +16,7 @@ verification, or CAPTCHA-equivalent abuse hardening — see "Deferred." ## Correction (2026-09-03) -[ADR-0014](0014-naruon-owned-password-form.md)'s Correction disabled `naruon-web`'s +[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 @@ -31,7 +35,7 @@ and review its own least-privilege owner contract rather than flip this gate. ## Context -[ADR-0014](0014-naruon-owned-password-form.md) enabled `directAccessGrantsEnabled` +[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 @@ -47,7 +51,7 @@ original product ask. 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-0014 already established for login. The two realistic +constraint ADR-0017 already established for login. The two realistic mechanisms: ### Rejected as naruon's own integration: raw Keycloak Admin REST from naruon @@ -61,7 +65,7 @@ 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. -### Accepted: extend account-unification, keyverse's existing narrow-scope admin proxy +### 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 @@ -93,7 +97,7 @@ 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"). -## Decision +## Historical mechanism proposal (disabled) 1. `POST /registration/accounts/password` (`app/password_registration.py`), authenticated by a **third**, independent bearer token @@ -186,9 +190,9 @@ extending several other accepted decisions (self-service password reset, verified-email merge policy) without the review those decisions themselves require. -## Consequences +## Historical expected consequences (not delivered) -- naruon's login (ADR-0014) and signup (this ADR) are now both real and +- 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. 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 a562f82..0cfff48 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,8 +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 | -| [0014](0014-naruon-owned-password-form.md) | Scoped Direct Access Grants exception for `naruon-web` only, so naruon can render its own login form with zero Keycloak-rendered HTML; every other RP still hard-blocked | Accepted, mechanism blocked — see the ADR's Correction (2026-09-03) | -| [0015](0015-naruon-password-credential-issuance.md) | `POST /registration/accounts/password`: scoped, third-token-gated account-unification endpoint that gives naruon signups an immediately usable password credential, closing ADR-0014's "nothing can log in yet" gap | Accepted, endpoint fails closed — see the ADR's Correction (2026-09-03) | +| [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 @@ -28,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 index 07ca3ab..18749d4 100644 --- a/docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md +++ b/docs/doctoring/2026-09-03-naruon-password-ropc-standards-correction.md @@ -7,14 +7,14 @@ protected-main or live Keycloak acceptance ## Scope This record documents disabling `naruon-web`'s Direct Access Grants -(ADR-0014) and the `POST /registration/accounts/password` signup endpoint -that depended on it (ADR-0015), and the cascading fix once disabling the +(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-0014, not +provider for headless passkey/WebAuthn), tracked against ADR-0017, not resolved here. ## Interpretation @@ -26,7 +26,7 @@ resolved here. 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-0014's original +- **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 @@ -62,12 +62,12 @@ resolved here. ## Evidence - **RED (conceptual, pre-fix state):** `directAccessGrantsEnabled: true` in - the committed realm export, `docs/adr/README.md`'s index showing ADR-0014 + 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-0014's index row and `deploy/keycloak/README.md` updated to match the + 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 @@ -78,8 +78,8 @@ resolved here. `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-0015 - gained a Correction section mirroring ADR-0014's. + (`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 @@ -98,7 +98,7 @@ resolved here. 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-0014's own Context section) is a real, separately-scoped design + per ADR-0017's own Context section) is a real, separately-scoped design question this record does not resolve. ## References 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`