From 20e957aeb6ce31bbc797f3c0d4ac276ed9e2e6b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 15:52:06 +0000 Subject: [PATCH] Add ADR 0026: OAuth2/OIDC Provider (Keystone as IdP) Documents Keystone acting as an OAuth2 Authorization Server / OpenID Connect Provider for external relying parties, complementing the existing OIDC/SAML consumer-side federation stack (ADR 0006/0007/ 0008/0013/0020). Covers issuer model, a new signed-JWT token track, OAuth2Client registration, claims mapping via the existing Unified Mapping Engine interpolation primitive, grant types, and the login/consent flow. --- doc/src/SUMMARY.md | 1 + doc/src/adr/0026-oauth2-oidc-provider.md | 502 +++++++++++++++++++++++ 2 files changed, 503 insertions(+) create mode 100644 doc/src/adr/0026-oauth2-oidc-provider.md diff --git a/doc/src/SUMMARY.md b/doc/src/SUMMARY.md index 11729cde9..f0812bd0d 100644 --- a/doc/src/SUMMARY.md +++ b/doc/src/SUMMARY.md @@ -36,6 +36,7 @@ - [Audit](adr/0023-audit.md) - [SCIM v2 Resource Provisioning](adr/0024-scim-v2-provisioning.md) - [Dynamic Auth Plugins](adr/0025-dynamic-auth-plugins.md) + - [OAuth2 / OIDC Provider](adr/0026-oauth2-oidc-provider.md) - [Distributed Encrypted Storage](raft_storage.md) - [Policy enforcement](policy.md) - [Security model](security.md) diff --git a/doc/src/adr/0026-oauth2-oidc-provider.md b/doc/src/adr/0026-oauth2-oidc-provider.md new file mode 100644 index 000000000..af33c3caa --- /dev/null +++ b/doc/src/adr/0026-oauth2-oidc-provider.md @@ -0,0 +1,502 @@ +# 26. OAuth2 / OpenID Connect Provider (Keystone as Identity Provider) + +**Date:** 2026-07-04 + +## Status + +Proposed + +## Reference + +Extends ADR 0006 (Federation IDP), ADR 0007 (Federation Mapping), ADR 0008 +(Workload Federation), ADR 0013 (Expiring Group Membership), ADR 0017 +(SecurityContext), ADR 0020 (Unified Mapping Engine), ADR 0021 (API-Key/SCIM +Ingress). Reuses the key-rotation mechanics of `key-repository` (ADR 0019 §4) +for a new asymmetric key material class. Notes and resolves a discrepancy in +ADR 0020 §8 (see "Resolving the ADR 0020 `oauth2:client` index"). + +--- + +## 1. Context & Motivation + +Every existing federation ADR in this codebase (0006, 0007, 0008, 0013, 0020) +describes Keystone as a **Relying Party (RP) / Service Provider**: an external +IdP (Okta, Keycloak, Dex, a GitHub Actions OIDC issuer, ...) asserts an +identity, and Keystone's Unified Mapping Engine translates that assertion into +a Keystone user, group memberships, and role assignments. `crates/keystone/src/federation/` +implements exactly this direction using the `openidconnect` crate as an +OIDC *client* library. There is no code, ADR, or design document anywhere in +this repository for the reverse role: **Keystone as an OAuth2 Authorization +Server / OpenID Connect Provider (OP)**, issuing tokens that a third-party +relying party can independently verify. + +This gap is a real product gap, not just a documentation one. Operators +increasingly want a single OpenStack cloud identity to work as "Login with +OpenStack" against: + +- Dashboards and observability tools that already speak OIDC natively + (Grafana, ArgoCD, Backstage, internal SaaS-style tools) instead of needing a + bespoke Keystone-token integration. +- `kubectl`/CLI tooling that expects a standard OIDC device or authorization + code flow rather than a Keystone-specific token exchange. +- Third-party services a CSP's customers run in their own tenancy, where + "trust my cloud's IdP" via a standard protocol is the only integration path + those services support. + +Today none of this is possible without a bolt-on reverse-proxy or a manual +translation layer outside Keystone, because Keystone has never issued a +signed, third-party-verifiable token: every existing token type +(`crates/token-driver-fernet/`) is opaque symmetric Fernet, decryptable only by +holders of the shared Fernet key, and OAuth1 delegation (the closest prior +precedent for "issuing a credential to a delegated consumer") was never +ported from Python Keystone at all (ADR 0019 §1, `access_token_id` handling). + +### Non-goals + +- Replacing or modifying the existing Keystone-as-RP federation stack (ADR + 0006/0007/0008/0013/0020). Both directions coexist; a single Keystone + deployment can simultaneously delegate *to* Okta and issue tokens *for* + Grafana. +- Migrating native OpenStack API authentication (`/v3/auth/tokens`) off + Fernet. That token type is untouched by this ADR. +- Multi-tenant, customer-installable OAuth2 client self-registration UI. + Client registration in v1 is an admin/`DomainManager` CRUD operation (§4), + the same trust tier as Federation IdP and Mapping management. +- Dynamic Client Registration (RFC 7591). Clients are provisioned out of band + by an administrator in v1; automatic registration is a candidate follow-up + once the core protocol surface is proven. +- SAML-based Keystone-as-IdP (SAML metadata/assertions out). This ADR is + scoped to OAuth2/OIDC only. + +### Threat model + +Keystone becomes, for the first time, an endpoint that mints credentials +**for consumption outside its own trust boundary** — a relying party is by +definition a different service, frequently operated by a different team or +even a different organization (a CSP customer's own application). The +threat model this ADR must hold against: + +1. **A malicious or compromised relying party.** An RP that receives a valid + `id_token`/`access_token` must not be able to leverage it to reach + anything beyond what its registered scopes/claims template explicitly + grants — in particular, never a native OpenStack API call using Keystone's + own Fernet-token trust (§3, §5). +2. **A network attacker at the authorization endpoint.** Authorization Code + interception, CSRF against `/authorize`, and open-redirect via + `redirect_uri` are the classic OAuth2 attack surface (RFC 9700) and must + be closed by construction (mandatory PKCE, exact-match redirect URI + allowlist, `state` binding) rather than left to relying-party discipline. +3. **A compromised signing key.** Because id_tokens/access_tokens are + verified by parties Keystone does not operate, key compromise is not + contained the way a Fernet key compromise is (rotatable, symmetric, no + external verifier to notify) — this ADR requires a `kid`-addressed JWKS + and rotation story from day one (§3). + +--- + +## 2. Decision Summary + +| Axis | Decision | +| --- | --- | +| Role | Keystone becomes an OAuth2 Authorization Server / OIDC Provider, additive to (not replacing) its existing OIDC-RP federation stack | +| Issuer model | Per-domain issuer, with an optional cluster-global issuer; mirrors the global-vs-private IdP split already decided in ADR 0006 | +| Token crypto | New signed-JWT track (RS256 default, ES256 configurable) for `id_token`/`access_token`, issued only through the new OP endpoints; native OpenStack Fernet tokens are untouched | +| Client model | New `OAuth2Client` resource, domain-scoped, admin/`DomainManager`-managed | +| Grant types (v1) | Authorization Code + PKCE, Client Credentials, Refresh Token (rotating); Device Authorization Grant | +| Login/consent | Minimal built-in server-rendered login + consent page served by Keystone itself | +| Claims mapping | Per-`OAuth2Client` claims template, reusing ADR 0020's interpolation engine in the reverse direction | +| Scope model | OAuth2 scope strings reference existing role/project/domain assignments (`project::`, `domain::`), staying inside existing RBAC rather than a parallel permission system | +| ADR 0020 `oauth2:client` index | Not reused — flagged as a stale forward-reference in ADR 0020; this ADR defines its own keyspace/table coordinate (§4) | +| Revocation | Short-lived `access_token`/`id_token` (default 5 min) + revocable, rotating `refresh_token`, reusing the ADR 0009 revocation-event mechanism | + +--- + +## 3. Token Crypto: A New Signed-JWT Track + +### Why not Fernet + +An `id_token` is required by the OIDC Core spec to be a JWT verifiable by the +relying party without a call back to Keystone. A symmetric Fernet token +cannot satisfy this — verification would require handing the RP the shared +Fernet key (making every RP a first-class Keystone-token holder, unacceptable +given the threat model in §1) or a bespoke introspection round trip for every +verification (rejected in the earlier interview round: adds a network +dependency per-request and is non-standard for `id_token`, which the spec +requires to be a JWT). + +### What's new + +A new crate-level concern, `oidc-token` (naming to be finalized at +implementation time, alongside `token-driver-fernet`), introduces: + +- **Asymmetric signing keys**, generated and rotated using the *mechanics* + already implemented in `crates/key-repository/` (`KeyRepository`/ + `CachedKeyRepository`/`KeySource`), but for RSA-2048 (default) or P-256 (if + `[oauth2] signing_algorithm = ES256`) keypairs instead of Fernet secrets. + `key-repository`'s `KeySource` abstraction (filesystem today, pluggable + KV-store backend later per its own doc comment) is reused as-is; only the + key *material* generated by `setup()`/`rotate()` changes for this consumer. + Each key file gets a stable `kid` (the SHA-256 of the DER-encoded public + key, truncated to 16 hex chars — deterministic, so `kid` survives a + key-repository reload without a separate ID-assignment table). +- **A JWKS endpoint**: `GET /v4/oauth2/{domain_id}/jwks` (and + `GET /v4/oauth2/jwks` for the optional global issuer, §4) publishing the + public half of every *active* key (staged key `0` excluded — it is not yet + used for signing, matching the existing `key-repository` staged/primary + split), each tagged with its `kid`. +- **A discovery document**: `GET /v4/oauth2/{domain_id}/.well-known/openid-configuration` + (RFC 8414 / OIDC Discovery 1.0), advertising `issuer`, `authorization_endpoint`, + `token_endpoint`, `jwks_uri`, `device_authorization_endpoint`, + `grant_types_supported`, `response_types_supported`, `scopes_supported`, + `claims_supported`, `code_challenge_methods_supported: ["S256"]`. +- **Rotation**: the same staged→primary→pruned lifecycle as + `KeyRepository::rotate()`, with one difference from Fernet: because + `id_token`s carry a `kid` and RPs cache JWKS per their own TTL, a pruned + signing key must remain published in JWKS for at least one full + `id_token`/`access_token` max-lifetime *after* it stops signing new tokens, + not deleted the instant it's superseded. `max_active_keys` for this + repository must therefore default higher than the Fernet default (3) — + proposed default 4, configurable via + `[oauth2] signing_key_repository_max_active_keys`. + +### Token shapes + +```rust +/// Claims embedded in every id_token this OP issues. +struct IdTokenClaims { + iss: String, // per-domain (or global) issuer URL + sub: String, // Keystone user_id + aud: String, // OAuth2Client.client_id + exp: i64, + iat: i64, + auth_time: i64, + nonce: Option, // echoed verbatim from the /authorize request + // Per-OAuth2Client claims template output (§5) merged in here — + // e.g. email, preferred_username, groups, project_id, roles. + #[serde(flatten)] + extra_claims: serde_json::Map, +} + +/// access_token is also a signed JWT in this design (a deliberate deviation +/// from treating it as opaque per RFC 6749 §5.1, chosen so a resource server +/// can verify it locally against the same JWKS without an introspection +/// round trip — the same reasoning that ruled out Fernet for id_token above). +struct AccessTokenClaims { + iss: String, + sub: String, + aud: String, // resource-server identifier (§6) + client_id: String, + exp: i64, + iat: i64, + scope: String, // space-separated, RFC 6749 §5.1 +} +``` + +Lifetimes: `access_token`/`id_token` default to 5 minutes (short, because +neither is individually revocable once issued — revocation acts on the +refresh token and on the underlying Keystone user/role state, per §7). +`refresh_token` is **not** a JWT — it is an opaque, high-entropy token, stored +hashed (Argon2id, mirroring ADR 0021 §2.C's `secret_hash` treatment) and is +the one artifact in this flow that participates in the revocation and rotation +model (§7). + +--- + +## 4. Client & Issuer Model + +### `OAuth2Client` resource + +```rust +pub struct OAuth2Client { + pub client_id: String, // public, UUID + pub domain_id: Option, // None = global/cluster-wide client + pub client_secret_hash: Option, // Argon2id PHC; None for public clients (PKCE-only) + pub name: String, + pub redirect_uris: Vec, // exact-match allowlist, no wildcard matching + pub grant_types: Vec, // subset of {authorization_code, client_credentials, refresh_token, device_code} + pub token_endpoint_auth_method: TokenEndpointAuthMethod, // none | client_secret_basic | client_secret_post + pub require_pkce: bool, // MUST be true when client_secret_hash is None + pub claims_template_id: String, // see §5 + pub allowed_scopes: Vec, // allowlist this client may request (§6) + pub enabled: bool, + pub created_at: i64, +} +``` + +This is deliberately closer to ADR 0006's `IdentityProvider` (domain-scoped, +optional global, admin-managed, secret write-only) and ADR 0021's +`ApiClientResource` (Argon2id-hashed secret, `client_id` never embedded +inside the credential it authenticates) than to Application Credentials +(ADR 0014), which are user-owned and roles-inherited rather than +administratively provisioned with their own capability boundary. + +### Resolving the ADR 0020 `oauth2:client` index + +ADR 0020 §8's keyspace table lists `index:oauth2:client:` → +`{domain_id, provider_id}` with no backing struct anywhere in the codebase. +Per interview with the ADR author, this ADR does **not** claim that index — +it is flagged here as a stale forward-reference in ADR 0020 that should be +removed or annotated as superseded the next time ADR 0020 is revised. This +ADR's own storage coordinate (SQL table `oauth2_client`, or the FjallDB key +`data:oauth2_client::` if the Raft/FjallDB backend is +targeted — implementation detail, not fixed by this ADR) is independent of +that entry. + +### Issuer model + +Per-domain issuer, matching ADR 0006's global-vs-private IdP split: + +- `https:///v4/oauth2/{domain_id}` — a domain-scoped issuer. + `OAuth2Client`s with `domain_id: Some(...)` are only reachable under their + own domain's issuer path; their tokens carry that issuer in `iss`. +- `https:///v4/oauth2` — the optional global issuer, serving + `OAuth2Client`s with `domain_id: None`. + +A relying party configures exactly one issuer URL (per its own OIDC library's +normal discovery flow), so a CSP hosting multiple customer domains can give +each customer a distinct, independently-rotatable issuer identity without +customers being able to observe or interfere with each other's client +registrations, signing keys, or claims templates. + +### Administrative API (v1) + +- `POST /v4/oauth2/{domain_id}/clients` — create (secret returned once, per + the same pattern as ADR 0014/0021). +- `GET /v4/oauth2/{domain_id}/clients` / `GET .../clients/{client_id}` — list/show. +- `PATCH /v4/oauth2/{domain_id}/clients/{client_id}` — update (redirect URIs, + scopes, enabled flag; `client_id` and `domain_id` immutable). +- `DELETE /v4/oauth2/{domain_id}/clients/{client_id}` — revokes all + outstanding refresh tokens for the client as part of deletion (§7). +- RBAC: `identity:oauth2_client:{create,list,show,update,delete}`, gated the + same way ADR 0021 gates API-key management — `DomainManager` scoped to the + client's own domain, or `SystemAdmin`. + +--- + +## 5. Claims Mapping: Reusing the Unified Mapping Engine, Reversed + +ADR 0020 built a claims-*matching* engine (external claims in, Keystone +identity + authorization out). This ADR needs the mirror operation: Keystone +identity + authorization in, external-facing OIDC claims out. Rather than +inventing a second templating/interpolation system, each `OAuth2Client` +references a `claims_template_id` resolving to: + +```rust +pub struct OAuth2ClaimsTemplate { + pub id: String, + pub domain_id: Option, + /// Maps an output claim name to a template string, reusing the *same* + /// single-pass `${...}` interpolation primitive as ADR 0020 §5.4 — + /// e.g. "email" -> "${user.email}", "groups" -> "${user.groups}", + /// "project_id" -> "${scope.project_id}". + pub claim_templates: HashMap, +} +``` + +The interpolation source values (`${user.*}`, `${scope.*}`, `${roles.*}`) are +read from the already-`ValidatedSecurityContext` that authenticated the +end-user during the `/authorize` login step (§6) — no new data-access path, +just a new set of template variables feeding the same interpolation function +ADR 0020 §5.4 already defines (single-pass, no recursive substitution, +256-character overflow handling identical to that section). This keeps +exactly one reviewed templating engine in the codebase instead of two. + +A fixed, non-templated baseline is always included regardless of the +template (`sub`, `iss`, `aud`, `exp`, `iat`) — the template only controls the +*additional* claims layered on top, so a misconfigured or empty template +still produces a spec-valid minimal `id_token`. + +--- + +## 6. Scopes, Grant Types, and Resource Servers + +### Scope model + +OAuth2 `scope` strings are structured to name existing Keystone +authorization, not a parallel permission namespace: + +- `openid`, `profile`, `email` — standard OIDC scopes, controlling which + claim groups the claims template is allowed to emit. +- `project::` — grants an `access_token` whose + effective authorization is exactly that role on that project, computed via + the same `calculate_effective_roles()` path (ADR 0017) used everywhere else + in this codebase, not a separately maintained scope→permission table. +- `domain::` — same, at domain scope. + +An `OAuth2Client.allowed_scopes` allowlist bounds which of these a given +client may ever request, checked both at `/authorize` time and again at +token issuance (defense in depth against a scope smuggled in in a step where +the two checks disagree). + +### Grant types (v1) + +- **Authorization Code + PKCE** — the primary browser/end-user flow (§7). + PKCE (`S256` only, per RFC 9700's deprecation of `plain`) is mandatory for + public clients and recommended (configurable, `require_pkce`) for + confidential clients too. +- **Client Credentials** — machine-to-machine, no end user. Resolves to a + `SecurityContext` analogous to the ADR 0021 API-Key ephemeral-context + model: the `OAuth2Client` itself is the principal, scoped per its + `allowed_scopes`, never a human user's identity. +- **Refresh Token** — rotating (§7), one-time-use, reuse-detected. +- **Device Authorization Grant** (RFC 8628) — `POST /v4/oauth2/{domain_id}/device_authorization` + returns a `device_code`/`user_code` pair; the user completes the same + built-in login+consent page (§7) on a second device, and the polling client + redeems `device_code` at the token endpoint. This is the flow that makes + "Login with OpenStack" viable for CLI tools with no local redirect + listener (`openstack` CLI, `kubectl` OIDC plugins). + +### Resource servers / `aud` + +v1 keeps this deliberately simple: `aud` on an `access_token` is the +requesting `OAuth2Client`'s own `client_id` (i.e., first-party resource +server per client, matching the "client == resource server" shape most +OIDC-consuming dashboards already expect). Multi-audience tokens or binding +`aud` to existing catalog `service`/`endpoint` entries are noted as an open +question (§9) rather than decided here — nothing in this design blocks +adding it later, since `aud` is already a first-class field on +`AccessTokenClaims`. + +--- + +## 7. Login, Consent, and the Authorization Code Flow + +Keystone has no web UI today (`doc/src/architecture.md` — it is a pure API +service; Horizon is a separate project). The Authorization Code flow +requires an interactive login + consent step, so this ADR adds one, scoped +narrowly to this feature: + +1. `GET /v4/oauth2/{domain_id}/authorize?client_id=...&redirect_uri=...&response_type=code&scope=...&code_challenge=...&code_challenge_method=S256&state=...&nonce=...` + — validates `client_id`/`redirect_uri` (exact match against the + registered allowlist — no partial/prefix matching, closing the + open-redirect class named in §1) and `scope` (against `allowed_scopes`), + then serves a minimal, server-rendered HTML login form (username/password, + with the existing MFA/TOTP/passkey flows composable exactly as they are + for native `/v3/auth/tokens` login — this endpoint authenticates through + the same `SecurityContext` construction path, not a parallel login + mechanism). +2. On successful login, a consent page lists the requesting client's `name` + and the human-readable meaning of each requested scope, with Allow/Deny. + `OAuth2Client`s may be marked `pre_authorized: bool` (first-party/trusted, + e.g. a CSP's own dashboard) to skip this step — consent is always shown + for any client not so marked; there is no cluster-wide "skip consent for + everyone" switch in v1. +3. On Allow, Keystone issues a short-lived authorization `code` bound to + `(client_id, redirect_uri, code_challenge, scope, resolved user_id + + scope)` and redirects to `redirect_uri` with `code` and `state`. +4. The client calls `POST /v4/oauth2/{domain_id}/token` with + `grant_type=authorization_code`, `code`, `code_verifier` (PKCE), + `redirect_uri` (must match exactly what was sent to `/authorize`, per RFC + 9700 mix-up prevention), and client authentication + (`client_secret_basic`/`post`, or none for a public client, per + `token_endpoint_auth_method`). Keystone verifies the PKCE + `code_verifier` against the stored `code_challenge`, single-use-consumes + the code (replay of an already-redeemed code is a hard failure and + revokes any tokens already issued from it, per RFC 9700 guidance on code + replay as a compromise signal), and returns `id_token` + `access_token` + + `refresh_token`. + +This mirrors, at the transport level, the same two-phase +authenticate-then-authorize discipline ADR 0017 already enforces for every +other entry point (`SecurityContext` → `ValidatedSecurityContext` → +`fully_resolved()`) — the login step inside `/authorize` produces a fully +validated context exactly as `/v3/auth/tokens` does today; this ADR does not +introduce a second, weaker authentication path. + +### Refresh token rotation & revocation + +- Every `refresh_token` redemption issues a **new** `refresh_token` and + invalidates the old one (rotation). A refresh token presented twice + (reuse) is treated as a compromise signal: the entire token family (every + descendant refresh token issued from the original grant) is revoked + immediately, and a CADF audit event is emitted (ADR 0023). +- Revocation reuses the ADR 0009 `revocation_event` mechanism: an entry + keyed by the granting `client_id` + `user_id` (or just `client_id` for + Client Credentials grants, which have no end user) makes every + outstanding `access_token`/`id_token` derived from that grant rejected on + next verification, the same `issued_before`-comparison logic ADR 0009 + already implements — no new revocation data structure, just a new + populating path into the existing table. +- Deleting an `OAuth2Client` (§4) or disabling the end user (existing + disable path) both trigger the same pipeline. + +--- + +## 8. Interaction with Existing Systems + +- **`SecurityContext`/`ValidatedSecurityContext` (ADR 0017)**: the login step + inside `/authorize` and the Client Credentials grant both terminate in an + ordinary `ValidatedSecurityContext`, constructed exactly as every other + auth method does. No new context variant is required for the *human* + Authorization Code path (it's a `password`/`webauthn`/etc. context, same as + today). Client Credentials introduces one new `AuthenticationContext` + variant, `AuthenticationContext::OAuth2Client { client_id, scope }`, + following the established pattern from ADR 0025 §4 ("Adding a new + authentication method"). +- **Rate limiting (ADR 0022)**: `/authorize`, `/token`, and + `/device_authorization` are subject to the same per-source-IP and + per-client token-bucket limiting already applied to other public, + pre-authentication endpoints (ADR 0021 §6.A's model). +- **Audit (ADR 0023)**: every grant, refresh, and revocation is a CADF event + carrying `client_id` and (when present) `user_id`, following the existing + convention (e.g. ADR 0021 §5.C's `revoke` action, ADR 0025 §6.E's + mandatory audit wrapping). +- **OPA (ADR 0002)**: `OAuth2Client` and `OAuth2ClaimsTemplate` CRUD are + gated by Rego policies under `policy/oauth2/client/` and + `policy/oauth2/claims_template/`, following the existing per-resource + directory convention (`policy/identity/api_key/`, `policy/federation/identity_provider/`). +- **Dynamic Auth Plugins (ADR 0025)**: out of scope for v1, but the design + does not preclude a future `mapping`-mode-style extension point for + claims-template logic too complex for the interpolation primitive (a + parallel to how ADR 0025 §4 lets a WASM plugin feed the Mapping Engine + rather than replace it). + +--- + +## 9. Consequences + +### Positive + +- Keystone-issued identity becomes usable by any standard OIDC relying + party without a bespoke integration, closing a real, frequently-requested + gap relative to Python Keystone's Apache/`mod_auth_openidc`-only story + (ADR 0006's stated motivation for building federation natively applies + symmetrically here: relying on external software for the OP role would + again block self-service and native lifecycle management). +- Reuses three already-reviewed subsystems (`key-repository`'s rotation + mechanics, ADR 0020's interpolation engine, ADR 0009's revocation-event + table) instead of building three new ones, keeping the net-new surface + limited to the OAuth2/OIDC protocol endpoints and the `OAuth2Client`/ + `OAuth2ClaimsTemplate` resources themselves. + +### Negative / risks + +- This is the first Keystone token type verifiable by a party outside + Keystone's own trust boundary. A signing-key compromise has a materially + different blast radius and remediation path (every RP must refresh JWKS + and reject the old `kid`) than a Fernet key compromise, which this ADR's + key-repository reuse does not eliminate, only bound with rotation + + overlap (§3). +- A new interactive login+consent surface (§7) is new attack surface + (CSRF, open redirect, code replay) that the rest of Keystone's API-only + design has never had to defend. RFC 9700 mitigations (exact redirect-URI + match, mandatory PKCE, single-use codes, `state`/`nonce` binding) are + specified in this ADR precisely because this surface has no precedent + elsewhere in the codebase to inherit hardening from. +- Operators must budget for a second key-rotation runbook (asymmetric OP + signing keys) alongside the existing Fernet and credential-provider key + repositories (ADR 0019 §4) — three independently-rotated key repositories + per cluster after this ADR, not two. + +### Follow-up ADRs anticipated + +- Dynamic Client Registration (RFC 7591) if self-service client onboarding + proves necessary. +- Multi-audience / catalog-integrated resource servers (§6), if a single + `access_token` needs to be valid across more than one first-party + service. +- Back-channel logout (RP-initiated or Keystone-initiated) — explicitly out + of scope for v1; noted here so it isn't silently dropped, since ADR 0006 + already flags back-channel logout as a flow the Service side must be able + to trigger, for the RP direction. The OP-direction equivalent is left for + a dedicated future ADR.