From 4803f829da042fd205e9a7f94954a87bae10a9e1 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Sun, 11 Oct 2026 14:53:38 +0900 Subject: [PATCH] identity: verify RFC 9068 SSO token management Refs: #488 Assisted-by: Codex Signed-off-by: sjungwon03 --- CONTEXT.md | 4 + contracts/sso-access-tokens.md | 13 + contracts/sso-management-foundation.md | 6 +- contracts/sso-token-management.md | 15 + contracts/token-management.md | 2 + docs/PRD.md | 14 +- docs/acceptance.md | 4 + .../adr/0008-pre-registered-sso-management.md | 2 +- docs/architecture.md | 8 +- docs/openrouter-compatibility.md | 4 + docs/plans/488-sso-token-management.md | 55 ++ package-lock.json | 10 + package.json | 1 + .../postgres-sso-token-management-service.ts | 167 +++++ src/identity/rfc9068-sso-access-tokens.ts | 214 ++++++ test/sso-access-token-fixture.ts | 51 ++ test/sso-access-tokens.test.ts | 416 +++++++++++ test/sso-token-management.test.ts | 663 ++++++++++++++++++ 18 files changed, 1639 insertions(+), 10 deletions(-) create mode 100644 contracts/sso-access-tokens.md create mode 100644 contracts/sso-token-management.md create mode 100644 docs/plans/488-sso-token-management.md create mode 100644 src/gateway/postgres-sso-token-management-service.ts create mode 100644 src/identity/rfc9068-sso-access-tokens.ts create mode 100644 test/sso-access-token-fixture.ts create mode 100644 test/sso-access-tokens.test.ts create mode 100644 test/sso-token-management.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 851ea79..b0e93e0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -83,3 +83,7 @@ Per-attempt records of token usage and cost attribution, linked to the client re **SSO identity binding**: An administrator-approved association between a company SSO issuer and subject identity and an OpenGranter principal. + +**SSO management access token**: +A company SSO token intended to identify a human caller of the OpenGranter management API before policy evaluation. +_Avoid_: Proxy API token diff --git a/contracts/sso-access-tokens.md b/contracts/sso-access-tokens.md new file mode 100644 index 0000000..71d93f6 --- /dev/null +++ b/contracts/sso-access-tokens.md @@ -0,0 +1,13 @@ +# RFC 9068 SSO management access-token verifier + +Plan: [488-sso-token-management](../docs/plans/488-sso-token-management.md). + +createRfc9068SsoAccessTokenVerifier({ issuer, audience, jwks, now }) returns verifyAccessToken(token). Administrator configuration is captured once and projected into a private local public-key snapshot; request data cannot select trust configuration. Configured issuer is exact HTTPS without userinfo/query/fragment/whitespace, 1–2048 UTF-16 units. The API audience is an exact opaque 1–256-unit identifier. jwks has 1–16 own indexed public RSA keys with unique own kid values; keys have own kty/n/e/kid and only optional own alg/use/key_ops. Reject private/unknown own key fields, noncanonical base64url integers, unsupported signing metadata and RSA moduli outside 2048–4096 bits. Missing metadata projects to RS256/sig/verify. No key/header URL fetch, discovery or request-provided key import occurs. Invalid config/getters throw InvalidSsoAccessTokenConfiguration / Invalid SSO access token configuration without raw data or cause. + +Tokens are bounded nonempty opaque strings up to 16384 UTF-16 units without NUL/ill-formed Unicode. Real jose signature verification permits RS256 only and local matching public keys; ambiguous/missing key selection rejects rather than trying every key. RFC media-type semantics accept at+jwt case-insensitively with optional application/ prefix, rejecting ID/generic JWT types. Require iss/sub/aud/exp/iat/jti/client_id plus the confirmed local auth_time restriction. Exact configured issuer and audience membership apply; audience arrays have at most 64 nonempty bounded identifiers. Subject/client_id/jti are 1–256-unit well-formed non-NUL strings. Extra claims/header key URLs confer no identity or policy authority. + +NumericDates are finite nonnegative numbers; fractional dates are supported. Require exp > iat, exp-iat <= 3600 seconds and auth_time <= iat. Optional nbf must be valid and precede exp. With one captured current millisecond clock (converted to whole epoch seconds by jose), jose applies 60-second tolerance to expiry, future iat/nbf and a maximum 3600-second age since iat. Duration bound receives no extra skew. auth_time describes original end-user login and may precede refreshed issuance; no maximum login age is imposed. Clock must be a nonnegative safe integer representable as a Date. + +Valid verification returns only a frozen { issuer, subject }. Invalid/malformed/unsupported/expired tokens return undefined without exposing raw token, claims, library errors or causes. Clock/unexpected verifier failures throw SsoAccessTokenVerificationUnavailable / SSO access token verification unavailable. Local key snapshots do not refresh: rebuild a verifier to add/remove keys and overlap registered keys for manual rotation. No introspection, jti replay cache or access-token revocation mechanism is introduced. + +The contributor selected RFC 9068, local RS256 public keys, one-hour limits/60-second tolerance and required auth_time on 2026-10-11. RFC auth_time is optional, not generic human-grant proof. Administrators must trust an issuer that emits this claim only for actual end-user authentication; tokens without it reject under this local profile. Existing active-human binding and current IAM remain additional requirements. This module neither grants management policy nor exposes a public HTTP/authentication-audit contract. See [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html#section-2.2) and [JWT confusion guidance](https://www.rfc-editor.org/rfc/rfc8725.html#section-3.12). diff --git a/contracts/sso-management-foundation.md b/contracts/sso-management-foundation.md index 74973ba..8fe9f17 100644 --- a/contracts/sso-management-foundation.md +++ b/contracts/sso-management-foundation.md @@ -1,12 +1,12 @@ # SSO management foundation: planning status -SSO JWT verification and public management authentication remain pending implementation. The binding read foundation is delivered under #486, and lifetime caps are enforced at the existing internal service under the [lifetime contract](proxy-token-lifetimes.md). This is not an implemented public HTTP contract: +The internal RFC 9068 JWT verifier and token-management composition are delivered under #488; public management HTTP authentication remains pending. The binding read foundation is delivered under #486, and lifetime caps are enforced at the existing internal service under the [lifetime contract](proxy-token-lifetimes.md). This is not an implemented public HTTP contract: - Company SSO uses OIDC. - The first management API authenticates SSO human users only. Service accounts continue using the LLM proxy; service management credentials require a later decision. - Newly issued proxy tokens have a mandatory expiry and maximum lifetime of 30 days for human owners or 90 days for service owners. - Existing iam:Manage policy evaluation on the target principal and required metadata decision audit remain in force. SSO authentication supplies identity, not an authorization bypass. -Confirmed on 2026-10-11: administrator-pre-registered issuer/subject bindings and API-specific SSO JWT access tokens; no automatic first-login creation. The [binding read foundation](sso-identity-bindings.md) is implemented under #486. HTTP paths, authentication profiles, migration handling for existing credentials, and implementation error contracts remain to be specified before their implementation. Recommendations are not requirements until confirmed. +Confirmed on 2026-10-11: administrator-pre-registered issuer/subject bindings, API-specific SSO JWT access tokens and the RFC 9068 access-token profile; no automatic first-login creation. The [binding read foundation](sso-identity-bindings.md) is implemented under #486 and [internal token-management composition](sso-token-management.md) under #488. The contributor also confirmed registered RS256 public keys, maximum one-hour lifetime/age with 60-second clock tolerance, and required auth_time. Administrators must trust its end-user semantics. HTTP paths/errors/audit and credential migration handling remain separate. Recommendations are not requirements until confirmed. -The current internal facade, opaque credential primitives, and proxy HTTP endpoints remain as documented in [token management](token-management.md). JWT verification and the public management API remain pending; the binding read foundation and internal lifetime enforcement are delivered separately. +The current internal facade, opaque credential primitives, and proxy HTTP endpoints remain as documented in [token management](token-management.md). The public management HTTP API remains pending; the binding read foundation and internal lifetime enforcement are delivered separately. diff --git a/contracts/sso-token-management.md b/contracts/sso-token-management.md new file mode 100644 index 0000000..96e9344 --- /dev/null +++ b/contracts/sso-token-management.md @@ -0,0 +1,15 @@ +# Internal SSO token-management service + +Plan: [488-sso-token-management](../docs/plans/488-sso-token-management.md). + +`createPostgresSsoTokenManagementService({ client, now, verifyAccessToken })` exposes `issue({ accessToken, requestId, principalId, expiresAt })` and `revoke({ accessToken, requestId, credentialId })` to trusted internal application code. It returns existing one-time issued credentials or the existing revocation result. It exposes no HTTP route. createPostgresRfc9068SsoTokenManagementService wires the real [RFC 9068 verifier](sso-access-tokens.md) from administrator issuer/audience/public-key configuration and the same clock. The configured factory captures its five own required settings once, ignores extra authority and reports invalid/missing/getter-failed configuration through the fixed InvalidSsoAccessTokenConfiguration error. + +The trusted access-token verifier returns an already verified `{ issuer, subject }` or `undefined`. Only verified identities reach the [binding reader](sso-identity-bindings.md); exact current active human bindings derive the actor ID. The configured verifier enforces the selected RFC 9068 profile and required auth_time restriction; decoding a JWT or accepting caller-supplied claims is not verification. Injected verifiers are trusted internal deployment code, not request data. Proxy tokens and ID tokens are not management access tokens. The registered issuer must use auth_time only for actual end-user authentication; this local restriction is not universal human-grant proof. + +Capture all own required operation fields once before any verifier/SQL await. Extra actor IDs, kind/active state, policy statements/versions, owner and verification claims are ignored. Access tokens are opaque strings of 1–16384 UTF-16 units; metadata IDs are 1–256. Reject NUL/ill-formed Unicode, preserve exact spelling and require a nonnegative safe-integer expiry. Invalid inputs throw `InvalidSsoTokenManagementInput` / `Invalid SSO token management input` before verification or SQL. + +Verifier rejection and missing/inactive/service/unbound identity throw `SsoManagementUnauthenticated` / `SSO management unauthenticated`. Verifier throws, malformed verified identity and binding failures throw `SsoManagementAuthenticationUnavailable` / `SSO management authentication unavailable`. Both are fixed errors without raw tokens, claims, driver messages or causes. An error thrown by an injected verifier is always a dependency failure, even if it has a domain-error name. Failed authentication reads no actor policies, writes no token-management decision and performs no credential or lifecycle mutation. Public authentication auditing is still a separate contract. + +After authentication, the [existing management service](token-management.md) reloads current actor policies and enforces `iam:Manage` on the stored credential owner or requested issuance principal. Implicit/explicit Deny, required authorization decision audit, owner-kind lifetime caps and atomic lifecycle writes remain unchanged. Authentication supplies no permissions, extra audit kinds, secrets, inference or usage calls. Neither access tokens nor issuer/subject claims are included in management decisions/lifecycle events; only existing nonsecret actor/target/request/credential/policy identifiers appear. Issued raw proxy tokens return once under the existing contract. + +Each operation re-verifies its access token and reloads binding/kind/active and policy state; no authentication cache. Request mutation during verification/SQL cannot change the captured operation. Changes between calls take effect. Binding and policy reads are separate and state changes after a read are not transactionally revalidated. Audited administrator provisioning, public API paths/errors/audit, automatic JWKS rotation/introspection/replay controls and full release gates remain pending. diff --git a/contracts/token-management.md b/contracts/token-management.md index fdc551d..565b48b 100644 --- a/contracts/token-management.md +++ b/contracts/token-management.md @@ -16,6 +16,8 @@ `test/postgres-token-management-service.test.ts` covers persisted role grants, direct Deny precedence, next-operation policy/attachment/active-state changes, missing actors, ignored forged fields, immutable owner scoping, malformed/unavailable snapshots, required decision-store failure, and invalid inputs before SQL. +The [internal SSO composition](sso-token-management.md) derives this trusted actor ID from an injected verified access-token identity and a current pre-registered human binding. It retains these authorization/mutation controls and has a configured RFC 9068 verifier; public HTTP authentication remains pending. + ## Operation snapshots Capture validated service request fields before actor resolution. The coordinator captures operation fields and projects immutable actor/policy context before owner lookup or audit; decision events and evaluated policy versions are frozen. Caller updates during asynchronous work cannot change this operation's target, expiry, credential, or attribution. Subsequent calls inspect current caller values and reload persisted policies. See [snapshot contract](token-management-snapshots.md). diff --git a/docs/PRD.md b/docs/PRD.md index 0573535..869dc0a 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -21,7 +21,7 @@ Success means unauthorized principals cannot obtain provider credentials or invo ## 3. Initial release scope 1. **Catalog and routes:** Register an OpenRouter upstream and direct OpenAI, Anthropic, and Google Gemini upstreams, their secret references, approved model aliases and routes, and versioned price information. Never redisplay a raw upstream credential in the UI. -2. **Identity:** Distinguish human users from service accounts. Human users sign in through company SSO; issue and revoke service-account credentials. Proxy tokens use an opaque versioned format with a random lookup ID and secret, a stored digest, explicit expiry, and immediate revocation. A trusted internal management coordinator evaluates `iam:Manage` on the token owner's `principal:` before issue or revoke. OIDC, pre-registered issuer/subject bindings, API-specific SSO JWT access tokens and owner-specific lifetime caps are selected; the exact token-verification and HTTP management profiles remain pending. +2. **Identity:** Distinguish human users from service accounts. Human users sign in through company SSO; issue and revoke service-account credentials. Proxy tokens use an opaque versioned format with a random lookup ID and secret, a stored digest, explicit expiry, and immediate revocation. A trusted internal management coordinator evaluates `iam:Manage` on the token owner's `principal:` before issue or revoke. OIDC, pre-registered issuer/subject bindings, API-specific SSO JWT access tokens and owner-specific lifetime caps are selected; the internal RFC 9068 verifier uses registered RS256 public keys, one-hour lifetime/age, 60-second tolerance and required auth_time; public HTTP management remains pending. 3. **Authorization:** Attach policies to principals and roles. Evaluate principal, action, resource, and supported conditions on every request. Deny by default and give explicit Deny precedence. A PostgreSQL reader can load a principal's direct and inherited policy snapshot from one consistent SQL statement; an internal policy simulator can evaluate a current persisted principal/action/resource and return only the decision and policy versions. Simulation checks IAM only and does not grant execution; public simulator authentication, reader visibility, and access audit remain undecided. Policy-management writes remain separate work. 4. **Gateway and routing:** Provide the core OpenAI-compatible `POST /v1/chat/completions` and `GET /v1/models` shapes. A delegated route sends an authorized request to OpenRouter; a managed route has OpenGranter choose among registered direct OpenAI, Anthropic, and Google Gemini adapters. Both use the same proxy-token, policy, limit, and audit path. The routing controls resemble OpenRouter's: administrator-approved provider allowlists and order, price/latency/throughput preferences, health-aware selection, optional Jev-assisted selection, and bounded fallback. Jev may recommend only a destination that passed all OpenGranter eligibility checks. Fallback may try another authorized candidate of the same route kind; it cannot silently switch between delegated and managed routes. Reject unsupported fields and routing overrides clearly. Delegated text streaming is implemented; direct-provider, tool and complete external-client streaming remain required release work. 5. **Usage:** Record request ID, principal, credential ID, requested alias, route kind, upstream, actual model and provider when known, timestamp, status, latency, input and output tokens, estimated cost, and upstream-reported cost separately. Represent unavailable token usage as unknown. @@ -70,7 +70,7 @@ Unrestricted cross-mode fallback, credit resale, every provider-specific API par | Jev-assisted managed selection | Optional TypeSafe Jev decision over already eligible direct candidates; OpenGranter validates the choice and makes the provider call. The authorized fallback policy is confirmed; the TypeSafe integration target and prompt-disclosure default remain provisional in [the routing contract](routing.md). | | Delegated IAM | Require permission for the model and every eligible final inference provider; restrict OpenRouter to the authorized provider set before sending | | Fallback boundary | Within delegated routes or within managed routes, never silently between the two kinds | -| Human sign-in | OIDC company SSO; pre-registered issuer/subject binding reader; API-specific JWT verification/HTTP profile pending | +| Human sign-in | OIDC company SSO; pre-registered issuer/subject binding reader; API-specific RFC 9068 verifier and internal management composition; public HTTP profile pending | | Deployment | AWS and on-premises support; release order TBD | | Content audit | Configurable; default, scope, retention, and readers TBD | | Streaming and monthly limits | Streaming required for external-client compatibility; failure/accounting contracts and monthly limits remain to specify | @@ -113,13 +113,13 @@ Distinguish malformed usage containers from absent reporting across all four ada Require the exact case-insensitive application/json type, preserving existing parameter handling. Unsupported prefix lookalikes fail through existing request denial before downstream work. See [contract](../contracts/chat-media-type.md). -## Agreed SSO management foundation (implementation pending) +## Agreed SSO management foundation -Company SSO uses OIDC. The first management API authenticates SSO human users only; services remain proxy users. New proxy-token lifetime caps are 30 days for human owners and 90 days for service owners, with expiry mandatory. Administrator-pre-registered issuer/subject bindings and API-specific SSO JWT access tokens were confirmed on 2026-10-11; exact token verification and public HTTP authentication remain pending. Existing IAM and mandatory decision audit apply. See [planning contract](../contracts/sso-management-foundation.md) and [plan](plans/105-sso-management-foundation.md). +Company SSO uses OIDC. The first management API authenticates SSO human users only; services remain proxy users. New proxy-token lifetime caps are 30 days for human owners and 90 days for service owners, with expiry mandatory. Administrator-pre-registered issuer/subject bindings and API-specific SSO JWT access tokens were confirmed on 2026-10-11; the internal RFC 9068 verifier and token-management composition are delivered under #488, while public HTTP authentication remains pending. Existing IAM and mandatory decision audit apply. See [planning contract](../contracts/sso-management-foundation.md) and [plan](plans/105-sso-management-foundation.md). ## Internal proxy-token lifetime enforcement -New issuance through the internal PostgreSQL management service now enforces the agreed owner limits: human 30 days, service 90 days. Trusted owner-kind lookup follows IAM and required audit; the cap uses the same timestamp as credential creation. Existing credentials are not retroactively changed. SSO and public management authentication remain pending. See [contract](../contracts/proxy-token-lifetimes.md). +New issuance through the internal PostgreSQL management service now enforces the agreed owner limits: human 30 days, service 90 days. Trusted owner-kind lookup follows IAM and required audit; the cap uses the same timestamp as credential creation. Existing credentials are not retroactively changed. The internal SSO verifier/composition are delivered separately; public management authentication remains pending. See [contract](../contracts/proxy-token-lifetimes.md). ## Internal management operation snapshots @@ -1011,3 +1011,7 @@ Complete administrator pricing snapshots may include optional finite discount an ## Pre-registered SSO identity read foundation (#486) Exact issuer/subject bindings resolve only to existing active human principals. Missing mappings, inactive humans and services yield no identity; no account creation or email fallback occurs. A persisted internal reader captures bounded opaque fields once and reloads current state each operation. It does not verify JWTs, authenticate public requests, grant IAM rights or mutate configuration. Existing policy/audit and proxy behavior remain required. See [plan](plans/486-sso-identity-bindings.md) and [contract](../contracts/sso-identity-bindings.md). + +## Internal SSO token management (#488) + +An internal service accepts an access token and captured issue/revoke operation fields instead of a nominated actor ID. A trusted injected verifier supplies verified issuer/subject, the current pre-registered active human binding derives the actor, and the existing token-management service reloads IAM policies and retains owner scoping, Deny, required decision audit, lifetime caps and atomic credential lifecycle writes. Forged caller identity/policy/owner fields have no authority. The configured factory verifies RFC 9068 access tokens with registered RS256 public keys, a one-hour lifetime/age and 60-second clock tolerance, requiring signed auth_time under trusted issuer end-user semantics. It returns only a verified identity and enables no public login route. HTTP authentication/audit and audited provisioning remain separate. See [plan](plans/488-sso-token-management.md) and [contract](../contracts/sso-token-management.md). diff --git a/docs/acceptance.md b/docs/acceptance.md index f236ba5..35fc5de 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -1422,3 +1422,7 @@ Root/price/condition/row/day/index getters and array lengths are read once; inhe ## Pre-registered SSO identity binding cases (#486) Verify migrated uniqueness/FK/restricted deletion, exact issuer/subject case/Unicode/whitespace matching, cross-issuer separation and multiple bindings for one principal without automatic creation. Current missing/inactive/service identities yield no human; next-call mapping/kind/active changes take effect. Public reader tests cover own field/getter capture, source mutation across SQL awaits, frozen parameterization, ignored forged caller state, malformed/duplicate/mismatched results and fixed claim-free errors before SQL for invalid input. No JWT verification, policy grant, audit append or HTTP management flow is certified. See [plan](plans/486-sso-identity-bindings.md) and [contract](../contracts/sso-identity-bindings.md). + +## Internal SSO token-management cases (#488) + +Verified bound active humans can issue/revoke through persisted current IAM and required audit; unverified/unbound/inactive/service identities cannot reach management SQL or nominate actor/owner/policies. Verify fixed private input/authentication/dependency errors, required own getter capture, input/claim mutation across awaits, current binding/policy/attachment state, implicit/explicit Deny, audit/dependency failures, stored owner scope, exact human/service lifetime caps and unchanged atomic credential lifecycle behavior. Decisions/lifecycle events contain no raw access token or issuer/subject claims. Invalid input fails before verifier/SQL and failed auth appends no management decision. Use runtime-generated synthetic signed JWTs through the actual configured verifier and migrated PostgreSQL. Cover wrong signature/algorithm/key/type, missing or malformed mandatory claims, exact issuer/audience, one-hour temporal bounds/60-second skew, required auth_time and safe clock/configuration failures. Injected fixtures cover dependency faults. No public HTTP authentication is certified. See [plan](plans/488-sso-token-management.md) and [contract](../contracts/sso-token-management.md). diff --git a/docs/adr/0008-pre-registered-sso-management.md b/docs/adr/0008-pre-registered-sso-management.md index b3ba370..d075234 100644 --- a/docs/adr/0008-pre-registered-sso-management.md +++ b/docs/adr/0008-pre-registered-sso-management.md @@ -1,3 +1,3 @@ # Pre-registered SSO identities and API access tokens -The contributor selected administrator-pre-registered issuer/subject bindings and API-specific SSO JWT access tokens for the first human-only management API on 2026-10-11. This trades automatic first-login onboarding and browser session state for explicit identity enrollment and an API authentication boundary; changing it later would change provisioning and client login flows. SSO identity supplies no IAM bypass. Exact token verification and HTTP profiles remain separate decisions. See [plan](../plans/486-sso-identity-bindings.md). +The contributor selected administrator-pre-registered issuer/subject bindings and API-specific SSO JWT access tokens for the first human-only management API on 2026-10-11. This trades automatic first-login onboarding and browser session state for explicit identity enrollment and an API authentication boundary; changing it later would change provisioning and client login flows. They subsequently selected the RFC 9068 access-token profile over an unspecified provider-specific format, accepting that nonconforming IdP tokens need a later explicit profile. SSO identity supplies no IAM bypass. They also confirmed administrator-managed RS256 public keys, a maximum one-hour lifetime/age with 60-second tolerance, and required auth_time, accepting manual key deployment and rejection of tokens without end-user authentication context. Its meaning remains a trusted issuer guarantee; public HTTP authentication/audit remains separate. See [binding plan](../plans/486-sso-identity-bindings.md) and [composition plan](../plans/488-sso-token-management.md). diff --git a/docs/architecture.md b/docs/architecture.md index c717cbc..c4ccebf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,7 +116,7 @@ Fail closed when authentication, policy, route bounds, secrets, or required audi ## Decisions still needed 1. Supported OpenRouter request options and first API capability set across all four adapters. -2. OIDC identity binding and management authentication profile. Human-only first management authentication is confirmed but pending implementation; internal owner-specific lifetime caps are implemented separately. +2. Public OIDC management HTTP routes/errors/authentication audit and administrator provisioning. Exact pre-registered active-human binding, the internal RFC 9068 verifier/composition and owner-specific lifetime caps are implemented; issuer auth_time semantics remain a deployment trust prerequisite. 3. Operational configuration, secret delivery, migration coordination, and process startup for the PostgreSQL driver and secret stores in AWS and on-premises deployments. 4. Whether monthly limits warn or block, and how concurrent calls reserve capacity. 5. Content-audit default, configuration scope, retention, reader permissions, and tamper-resistant export. @@ -1222,3 +1222,9 @@ HTTP projection and PostgreSQL list/route validation reuse this snapshot before ## Pre-registered SSO identity bindings (#486) Migration011 adds C-collated exact issuer/subject keys, a composite primary key and a restricting FK to existing IAM principals; multiple bindings may name one principal. The bundled TypeScript reader captures own bounded well-formed opaque strings and freezes projected parameters before one joined binding/principal query. Return only an active human ID; missing/inactive/service state yields no identity, and malformed/ambiguous/mismatched/dependency failures expose fixed errors without claims or causes. No cache, normalization, email fallback, automatic account creation, JWT/key fetching or public authentication is introduced. State changed after this read is not revalidated; later verification and current policy/audit boundaries remain necessary. See [plan](plans/486-sso-identity-bindings.md) and [contract](../contracts/sso-identity-bindings.md). + +## Internal SSO token-management composition (#488) + +Project own access-token/request/target/expiry fields once before asynchronous work. The trusted access-token verification port precedes binding SQL; only a verified exact current active-human mapping derives the internal authenticatedActorId. Reuse current-policy token management without copying IAM or mutation logic. Verification rejection or missing/non-human binding yields fixed unauthenticated errors before management; malformed verifier claims and verifier/binding failures expose safe authentication availability errors. Failed auth creates no management decision with an unverified actor. Existing decision/lifecycle events carry only approved management metadata, without raw access token or issuer/subject claims. + +No authentication cache or public route is introduced. Binding and policy reads are separate and later changes are not transactionally revalidated. The configured factory wires a real local-key RFC 9068 verifier with immutable administrator issuer/audience/RS256 keys, mandatory bounded claims, one-hour lifetime/age, 60-second skew and required auth_time. Its end-user meaning depends on trusted issuer semantics. Configuration/key changes require a rebuilt verifier; no network JWKS lookup, replay/introspection or public authentication audit is added. All migrations and OpenRouter pins remain unchanged. See [plan](plans/488-sso-token-management.md) and [contract](../contracts/sso-token-management.md). diff --git a/docs/openrouter-compatibility.md b/docs/openrouter-compatibility.md index f95a6f1..7733184 100644 --- a/docs/openrouter-compatibility.md +++ b/docs/openrouter-compatibility.md @@ -1025,3 +1025,7 @@ Enforce the official utc_days minItems=1 even though this SDK accepts empty arra ## SSO management identity read foundation (#486) The contributor confirmed pre-registered issuer/subject bindings and API-specific SSO JWT access tokens for human-only management. An internal persisted binding reader now resolves existing active humans with exact keys and safe failures; it neither verifies tokens nor exposes a management API or changes proxy credentials, discovery, IAM, audit, limits or usage. All three OpenRouter pins remain unchanged. JWT/HTTP management, catalog provisioning/refresh, full #116 and unresolved #7 remain open. See [plan](plans/486-sso-identity-bindings.md) and [contract](../contracts/sso-identity-bindings.md). + +## Internal SSO token-management composition (#488) + +The management-only composition derives an actor from an injected verified API access-token identity and its active human binding before existing current IAM/audit/credential operations. Caller identity/policy/owner fields are ignored, and raw access tokens/issuer/subject are excluded from decision/lifecycle records and fixed errors. A configured RFC 9068 verifier uses immutable registered RS256 public keys, exact issuer/API audience, one-hour lifetime/age, 60-second tolerance and required auth_time under trusted issuer end-user semantics. No proxy chat/discovery/limit/usage/provider change or public management route is added. Manual rotation requires rebuilding configuration; public authentication audit, network JWKS/introspection/replay and audited provisioning remain separate. All migrations and three OpenRouter pins remain unchanged; full #116/#7 remain open. See [plan](plans/488-sso-token-management.md) and [contract](../contracts/sso-token-management.md). diff --git a/docs/plans/488-sso-token-management.md b/docs/plans/488-sso-token-management.md new file mode 100644 index 0000000..6ccec50 --- /dev/null +++ b/docs/plans/488-sso-token-management.md @@ -0,0 +1,55 @@ +# RFC 9068 SSO token management + +## Issue and problem + +- Issue [#488](https://github.com/Theorvane/OpenGranter/issues/488). #486 supplies exact SSO bindings, but no real JWT verifier or internal composition derives the management actor without caller nomination. +- The contributor confirmed RFC 9068, administrator-registered issuer/API audience and RS256 public JWKs, a one-hour maximum access-token lifetime/age with 60-second clock tolerance, and required signed auth_time on 2026-10-11. The issuer must use auth_time only for actual end-user authentication. These choices expanded this issue before production code. + +## Scope and expected behavior + +- Implement a real TypeScript verifier using pinned jose, local administrator public keys and no discovery/network. Require the RFC claims iss/sub/aud/exp/iat/jti/client_id plus local auth_time; enforce exact issuer, API audience membership, RS256 signatures and at+jwt media typing. Output only a frozen verified issuer/subject. +- Require nonnegative finite NumericDates, exp > iat, exp-iat <= 3600 seconds and auth_time <= iat. Optional nbf must precede exp. Enforce time since iat <= 3600 with 60-second tolerance, future iat/nbf within that tolerance and expiry with the same tolerance. auth_time may predate a refresh: no additional login-freshness requirement is implied. +- Local bounds: compact token 1–16384 UTF-16 units, issuer 1–2048, subject/client_id/jti/API audience/key IDs 1–256, audience arrays at most 64, public keys 1–16. Strings exclude NUL/ill-formed Unicode. Configured issuer is HTTPS without userinfo/query/fragment/whitespace and stays exact. Keys are canonical public RSA JWKs with unique IDs, 2048–4096-bit moduli, allowed signing/verification metadata and no private/unknown own members. These are explicit local restrictions. +- Add internal PostgreSQL issue/revoke composition. Capture own required operation fields once before asynchronous work; verified identity resolves a current active human binding before existing fresh IAM/token management. Ignore forged actor/policy/owner/claim fields. Metadata IDs are 1–256; operation expiry remains a nonnegative safe integer. +- Preserve implicit/explicit Deny, owner-scoped iam:Manage, required decision audit before mutation, 30/90-day proxy-token caps and atomic lifecycle events. No authentication cache; reverify each operation. No raw access token/issuer/subject in decisions/lifecycle/errors. Failed authentication appends no token-management decision with an unreliable actor. +- No public HTTP authentication/endpoint/audit contract, automatic JWKS fetch/rotation, introspection/replay/revocation cache, binding/account provisioning, schema migration, inference or usage change. + +## Design + +- createRfc9068SsoAccessTokenVerifier({ issuer, audience, jwks, now }) snapshots own configuration and public keys before returning verifyAccessToken. Use jose local JWKS/jwtVerify with RS256, type/issuer/audience/required-claim/max-age/skew options; validate custom claim types, bounds and temporal relationships explicitly. Signature/token rejections return undefined; clock/unexpected failures throw safe SsoAccessTokenVerificationUnavailable. Invalid configuration throws InvalidSsoAccessTokenConfiguration without private data/cause. +- createPostgresSsoTokenManagementService({ client, now, verifyAccessToken }) accepts only a trusted verification port. createPostgresRfc9068SsoTokenManagementService wires the real verifier from administrator configuration. Both expose issue({ accessToken, requestId, principalId, expiresAt }) and revoke({ accessToken, requestId, credentialId }), deriving authenticatedActorId internally. +- Invalid inputs throw InvalidSsoTokenManagementInput before verifier/SQL. Verification rejection/missing/inactive/service binding throws SsoManagementUnauthenticated. Malformed verifier claims, verifier/binding throws and dependency failures throw SsoManagementAuthenticationUnavailable. Never trust a thrown verifier error's name or preserve its cause. Existing later management errors retain their safe denial/availability contracts. +- Reuse existing IAM/audit/credential components. Configuration snapshots require a rebuilt verifier for manual key changes; overlap old/new registered keys for rotation. Binding and policy reads are separate; later/intervening state changes are not transactionally revalidated. +- [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html#section-2.2) makes auth_time optional and client_credentials subjects normally identify applications. Required auth_time is a confirmed local restriction depending on trusted issuer semantics, not universal human-grant proof. [RFC 8725](https://www.rfc-editor.org/rfc/rfc8725.html#section-3.12) requires separate token validation rules. [jose local keys](https://github.com/panva/jose/blob/main/docs/jwks/local/functions/createLocalJWKSet.md) and [claim options](https://github.com/panva/jose/blob/main/docs/types/interfaces/JWTClaimVerificationOptions.md) support the chosen flow. Type comparison follows media-type case/application-prefix semantics. +- Update [PRD](../PRD.md), [architecture](../architecture.md), [acceptance](../acceptance.md), [SSO foundation](../../contracts/sso-management-foundation.md), [token management](../../contracts/token-management.md), [composition contract](../../contracts/sso-token-management.md), [JWT contract](../../contracts/sso-access-tokens.md), [CONTEXT](../../CONTEXT.md), [ADR0008](../adr/0008-pre-registered-sso-management.md) and [compatibility inventory](../openrouter-compatibility.md). Public HTTP/authentication audit and audited provisioning remain explicit separate work. + +- The configured management factory also requires and captures its five own settings once, ignores extra authority, and sanitizes constructor/getter failures. JWT temporal checks use jose whole-second current-time granularity; fractional NumericDates remain supported. + +## TDD plan + +- Write public verifier/composition tests before production changes/dependency installation. First confirm the exports are absent, then run behaviorally against deny-only typed exports: valid signed human tokens/issue/revoke must fail before implementation, invalid configuration/clock/operation errors must be missing. +- Generate synthetic signing keys/tokens at runtime; never commit private keys or bearer fixtures. Test valid/invalid signatures, wrong algorithms/kids/types, ID/proxy/unsigned tokens, all mandatory claims, audience/time/size limits, local auth_time restriction, extra untrusted header keys/URLs, private errors and immutable config/key projection. +- Test actual configured factory through migrated PostgreSQL: role grants, implicit/explicit Deny, binding/policy/active-state changes, stored owner scoping, audit/SQL/clock failures, lifetime caps, one-time proxy credentials and no identity/claims leakage in persisted events. +- Test injected-port failures and malformed claims before management, own getter/request capture, mutation across awaits, ignored forged fields and no automatic creation. Focused regressions then full npm run check, document/fixture scans and unchanged OpenRouter pins. + +## Delivery + +- Issue/new branch/English plan before production code; meaningful red, minimal green, full checks, exact published-diff review and sjungwon03-ai exact-head approval plus both CI checks before sjungwon03 squash merge/main sync. +- Internal configured service is delivered; no public login route is activated. Operators must register trusted issuers/audiences/public keys and confirm auth_time human semantics. Key changes require rebuilding configuration. Rollback removes the internal verifier/composition and dependency; no DB migration. Missing replay/introspection/public audit/provisioning are disclosed; #116/#7 remain open. + +## Verification evidence + +Valid SSO access tokens can now drive internal proxy-token issue/revoke without letting a caller nominate the authenticated actor. A real RFC 9068 verifier uses administrator-registered RS256 public JWKs, exact issuer/API audience, required claims and auth_time, a maximum one-hour lifetime/age and 60-second tolerance. Its frozen verified issuer/subject resolves only to a current pre-registered active human; the existing service then reloads IAM and retains owner-scoped iam:Manage, explicit/default Deny, required decision audit, 30/90-day owner caps and atomic credential lifecycle writes. + +Configuration/key and operation fields are captured once; raw tokens/SSO claims stay out of fixed errors and management records. Header keys/URLs cannot select trust or trigger network access. Both injected and configured factories reject authentication/dependency failures before management. The new exact production dependency is jose 6.2.12; private signing keys and synthetic tokens are generated only in test memory. + +Validation: +- Behavioral red: `node --experimental-strip-types --test test/sso-access-tokens.test.ts test/sso-token-management.test.ts` against deny-only typed exports failed 134 of 190 tests for missing valid verification/composition and missing input/config/clock errors (56 expected denials passed). An initial signing-fixture prevalidation problem was corrected before this red run; it is not counted as verifier evidence. +- Configuration-boundary red: the configured factory tests failed 7 of 89 cases before own-setting validation and sanitized constructor failures (82 existing cases passed). +- Green: the focused verifier/composition suite passes 200 tests, including real runtime-signed tokens through all migrated PostgreSQL stores, denial/failure paths, mutations, private records, time bounds, and crypto-key-import failure. +- Focused V8 coverage: both new production modules have 100% line/function coverage; the composition has 100% branch coverage and the verifier 99.11% branch coverage. Existing reused modules retain their broader suites. +- Final `npm run check`: 6,393 passed, zero failed, one existing opt-in real PostgreSQL test skipped locally; typecheck/lint/document-link-contract-secret scans and all three pinned OpenRouter integrity checks pass. Both repository CI jobs additionally gate merge, including the disposable real PostgreSQL service. +- `npm audit --omit=dev`: zero known production dependency vulnerabilities at verification time. `git diff --check` passes. Existing migrations and all three OpenRouter pins are byte-identical to the base. + +Limits: this is an internal authentication/management composition, not public login or a management HTTP route. The registered issuer must emit auth_time only for actual end-user authentication; this local restriction is not universal human-grant proof. Automatic/network JWKS rotation, introspection/access-token revocation/replay protection, public authentication-error/audit contracts and audited administrator provisioning remain separate. Manual key changes require a rebuilt verifier. Binding/policy reads are separate; later concurrent changes are not transactionally revalidated. Existing proxy/inference/discovery/IAM/limit/usage paths are unchanged; full release issue #116 and unresolved Jev issue #7 remain open. + diff --git a/package-lock.json b/package-lock.json index e89db4d..668df09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "opengranter", "version": "0.0.0", "dependencies": { + "jose": "6.2.12", "pg": "^8.23.0" }, "devDependencies": { @@ -566,6 +567,15 @@ "node": ">=16.20.0" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/openai": { "version": "7.23.0", "resolved": "https://registry.npmjs.org/openai/-/openai-7.23.0.tgz", diff --git a/package.json b/package.json index d29f43d..5f6252c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "typescript": "^7.0.2" }, "dependencies": { + "jose": "6.2.12", "pg": "^8.23.0" } } diff --git a/src/gateway/postgres-sso-token-management-service.ts b/src/gateway/postgres-sso-token-management-service.ts new file mode 100644 index 0000000..b9c1e1c --- /dev/null +++ b/src/gateway/postgres-sso-token-management-service.ts @@ -0,0 +1,167 @@ +import { createPostgresSsoIdentityBindingReader } from '../identity/postgres-sso-identity-bindings.ts'; +import { + createRfc9068SsoAccessTokenVerifier, + InvalidSsoAccessTokenConfiguration, + type SsoAccessTokenConfiguration, + type VerifiedSsoIdentity, +} from '../identity/rfc9068-sso-access-tokens.ts'; +import type { CredentialSqlClient } from './postgres-proxy-credentials.ts'; +import { createPostgresTokenManagementService } from './postgres-token-management-service.ts'; + +interface SsoRequest { + readonly accessToken: string; + readonly requestId: string; +} +interface SsoIssueRequest extends SsoRequest { + readonly principalId: string; + readonly expiresAt: number; +} +interface SsoRevokeRequest extends SsoRequest { + readonly credentialId: string; +} +export class InvalidSsoTokenManagementInput extends Error { + constructor() { + super('Invalid SSO token management input'); + this.name = 'InvalidSsoTokenManagementInput'; + } +} +export class SsoManagementUnauthenticated extends Error { + constructor() { + super('SSO management unauthenticated'); + this.name = 'SsoManagementUnauthenticated'; + } +} +export class SsoManagementAuthenticationUnavailable extends Error { + constructor() { + super('SSO management authentication unavailable'); + this.name = 'SsoManagementAuthenticationUnavailable'; + } +} +function request(input: unknown, fields: readonly string[]): Record { + if ( + typeof input !== 'object' || + input === null || + Array.isArray(input) || + fields.some((field) => !Object.hasOwn(input, field)) + ) + throw new InvalidSsoTokenManagementInput(); + return input as Record; +} +function opaque(value: unknown, maximum: number): value is string { + if (typeof value !== 'string' || !value.length || value.length > maximum || value.includes('\0')) + return false; + for (const character of value) { + const code = character.charCodeAt(0); + if (character.length === 1 && code >= 0xd800 && code <= 0xdfff) return false; + } + return true; +} + +export function createPostgresSsoTokenManagementService(ports: { + readonly client: CredentialSqlClient; + readonly now: () => number; + readonly verifyAccessToken: (token: string) => Promise; +}): { + readonly issue: ( + input: SsoIssueRequest, + ) => Promise<{ readonly credentialId: string; readonly token: string }>; + readonly revoke: (input: SsoRevokeRequest) => Promise; +} { + const { client, now, verifyAccessToken } = ports; + const bindings = createPostgresSsoIdentityBindingReader(client); + const management = createPostgresTokenManagementService({ client, now }); + async function authenticate(token: string): Promise { + let verified: VerifiedSsoIdentity | undefined; + try { + verified = await verifyAccessToken(token); + } catch { + throw new SsoManagementAuthenticationUnavailable(); + } + if (verified === undefined) throw new SsoManagementUnauthenticated(); + let principalId: string | undefined; + try { + principalId = await bindings.resolveHumanPrincipal(verified); + } catch { + throw new SsoManagementAuthenticationUnavailable(); + } + if (principalId === undefined) throw new SsoManagementUnauthenticated(); + return principalId; + } + return { + async issue(input) { + let accessToken: string; + let requestId: string; + let principalId: string; + let expiresAt: number; + try { + const value = request(input, ['accessToken', 'requestId', 'principalId', 'expiresAt']); + const token = value.accessToken; + const id = value.requestId; + const owner = value.principalId; + const expiry = value.expiresAt; + if ( + !opaque(token, 16384) || + !opaque(id, 256) || + !opaque(owner, 256) || + typeof expiry !== 'number' || + !Number.isSafeInteger(expiry) || + expiry < 0 + ) + throw new InvalidSsoTokenManagementInput(); + accessToken = token; + requestId = id; + principalId = owner; + expiresAt = expiry; + } catch { + throw new InvalidSsoTokenManagementInput(); + } + return management.issue({ + authenticatedActorId: await authenticate(accessToken), + requestId, + principalId, + expiresAt, + }); + }, + async revoke(input) { + let accessToken: string; + let requestId: string; + let credentialId: string; + try { + const value = request(input, ['accessToken', 'requestId', 'credentialId']); + const token = value.accessToken; + const id = value.requestId; + const credential = value.credentialId; + if (!opaque(token, 16384) || !opaque(id, 256) || !opaque(credential, 256)) + throw new InvalidSsoTokenManagementInput(); + accessToken = token; + requestId = id; + credentialId = credential; + } catch { + throw new InvalidSsoTokenManagementInput(); + } + return management.revoke({ + authenticatedActorId: await authenticate(accessToken), + requestId, + credentialId, + }); + }, + }; +} +export function createPostgresRfc9068SsoTokenManagementService( + ports: SsoAccessTokenConfiguration & { readonly client: CredentialSqlClient }, +) { + try { + if ( + typeof ports !== 'object' || + ports === null || + Array.isArray(ports) || + ['client', 'now', 'issuer', 'audience', 'jwks'].some((field) => !Object.hasOwn(ports, field)) + ) + throw new InvalidSsoAccessTokenConfiguration(); + const { client, now, issuer, audience, jwks } = ports; + const verifyAccessToken = createRfc9068SsoAccessTokenVerifier({ issuer, audience, jwks, now }); + return createPostgresSsoTokenManagementService({ client, now, verifyAccessToken }); + } catch { + throw new InvalidSsoAccessTokenConfiguration(); + } +} diff --git a/src/identity/rfc9068-sso-access-tokens.ts b/src/identity/rfc9068-sso-access-tokens.ts new file mode 100644 index 0000000..49da541 --- /dev/null +++ b/src/identity/rfc9068-sso-access-tokens.ts @@ -0,0 +1,214 @@ +import { createPublicKey } from 'node:crypto'; +import { createLocalJWKSet, errors, type JWK, jwtVerify } from 'jose'; + +export interface SsoAccessTokenConfiguration { + readonly issuer: string; + readonly audience: string; + readonly jwks: unknown; + readonly now: () => number; +} +export interface VerifiedSsoIdentity { + readonly issuer: string; + readonly subject: string; +} +export class InvalidSsoAccessTokenConfiguration extends Error { + constructor() { + super('Invalid SSO access token configuration'); + this.name = 'InvalidSsoAccessTokenConfiguration'; + } +} +export class SsoAccessTokenVerificationUnavailable extends Error { + constructor() { + super('SSO access token verification unavailable'); + this.name = 'SsoAccessTokenVerificationUnavailable'; + } +} +function record(value: unknown, fields: readonly string[]): Record { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + fields.some((field) => !Object.hasOwn(value, field)) + ) + throw new InvalidSsoAccessTokenConfiguration(); + return value as Record; +} + +function opaque(value: unknown, maximum: number): value is string { + if (typeof value !== 'string' || !value.length || value.length > maximum || value.includes('\0')) + return false; + for (const character of value) { + const code = character.charCodeAt(0); + if (character.length === 1 && code >= 0xd800 && code <= 0xdfff) return false; + } + return true; +} + +function rsaInteger(value: unknown, maximumBytes: number): string { + if ( + typeof value !== 'string' || + !value.length || + value.length > Math.ceil((maximumBytes * 4) / 3) || + !/^[A-Za-z0-9_-]+$/.test(value) + ) + throw new InvalidSsoAccessTokenConfiguration(); + const decoded = Buffer.from(value, 'base64url'); + if ( + !decoded.length || + decoded.length > maximumBytes || + decoded[0] === 0 || + decoded.toString('base64url') !== value + ) + throw new InvalidSsoAccessTokenConfiguration(); + return value; +} + +function publicKeys(value: unknown): JWK[] { + const keys = record(value, ['keys']).keys; + if (!Array.isArray(keys)) throw new InvalidSsoAccessTokenConfiguration(); + const length = keys.length; + if (length < 1 || length > 16) throw new InvalidSsoAccessTokenConfiguration(); + const result: JWK[] = []; + const ids = new Set(); + for (let index = 0; index < length; index++) { + if (!Object.hasOwn(keys, index)) throw new InvalidSsoAccessTokenConfiguration(); + const key = record(keys[index], ['kty', 'n', 'e', 'kid']); + if ( + Reflect.ownKeys(key).some( + (field) => + typeof field !== 'string' || + !['kty', 'n', 'e', 'kid', 'alg', 'use', 'key_ops'].includes(field), + ) + ) + throw new InvalidSsoAccessTokenConfiguration(); + const kty = key.kty; + const n = rsaInteger(key.n, 512); + const e = rsaInteger(key.e, 8); + const kid = key.kid; + const alg = Object.hasOwn(key, 'alg') ? key.alg : 'RS256'; + const use = Object.hasOwn(key, 'use') ? key.use : 'sig'; + const operations = Object.hasOwn(key, 'key_ops') ? key.key_ops : ['verify']; + if ( + kty !== 'RSA' || + !opaque(kid, 256) || + ids.has(kid) || + alg !== 'RS256' || + use !== 'sig' || + !Array.isArray(operations) || + operations.length !== 1 || + !Object.hasOwn(operations, 0) || + operations[0] !== 'verify' + ) + throw new InvalidSsoAccessTokenConfiguration(); + const exponent = BigInt(`0x${Buffer.from(e, 'base64url').toString('hex')}`); + const imported = createPublicKey({ key: { kty, n, e }, format: 'jwk' }); + const bits = imported.asymmetricKeyDetails?.modulusLength; + if ( + exponent < 3n || + exponent % 2n !== 1n || + imported.asymmetricKeyType !== 'rsa' || + bits === undefined || + bits < 2048 || + bits > 4096 + ) + throw new InvalidSsoAccessTokenConfiguration(); + ids.add(kid); + result.push({ kty, n, e, kid, alg: 'RS256', use: 'sig', key_ops: ['verify'] }); + } + return result; +} + +function numericDate(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +/** Local administrator trust snapshot; no network discovery or request-selected keys. */ +export function createRfc9068SsoAccessTokenVerifier( + configuration: SsoAccessTokenConfiguration, +): (token: string) => Promise { + let issuer: string; + let audience: string; + let now: () => number; + let resolveKey: ReturnType; + try { + const config = record(configuration, ['issuer', 'audience', 'jwks', 'now']); + const rawIssuer = config.issuer; + const rawAudience = config.audience; + const jwks = config.jwks; + const clock = config.now; + if ( + !opaque(rawIssuer, 2048) || + /\s/.test(rawIssuer) || + !opaque(rawAudience, 256) || + typeof clock !== 'function' + ) + throw new InvalidSsoAccessTokenConfiguration(); + const url = new URL(rawIssuer); + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.search || + url.hash || + rawIssuer.includes('?') || + rawIssuer.includes('#') + ) + throw new InvalidSsoAccessTokenConfiguration(); + issuer = rawIssuer; + audience = rawAudience; + now = clock as () => number; + resolveKey = createLocalJWKSet({ keys: publicKeys(jwks) }); + } catch { + throw new InvalidSsoAccessTokenConfiguration(); + } + + return async (token) => { + if (!opaque(token, 16384)) return undefined; + let currentDate: Date; + try { + const timestamp = now(); + if (!Number.isSafeInteger(timestamp) || timestamp < 0 || timestamp > 8_640_000_000_000_000) + throw new SsoAccessTokenVerificationUnavailable(); + currentDate = new Date(timestamp); + } catch { + throw new SsoAccessTokenVerificationUnavailable(); + } + try { + const { payload } = await jwtVerify(token, resolveKey, { + algorithms: ['RS256'], + issuer, + audience, + typ: 'at+jwt', + requiredClaims: ['iss', 'sub', 'aud', 'exp', 'iat', 'jti', 'client_id', 'auth_time'], + maxTokenAge: 3600, + clockTolerance: 60, + currentDate, + }); + const { sub, client_id: clientId, jti, aud, exp, iat, auth_time: authTime, nbf } = payload; + const validAudience = + opaque(aud, 256) || + (Array.isArray(aud) && + aud.length > 0 && + aud.length <= 64 && + aud.every((item) => opaque(item, 256))); + if ( + !opaque(sub, 256) || + !opaque(clientId, 256) || + !opaque(jti, 256) || + !validAudience || + !numericDate(exp) || + !numericDate(iat) || + !numericDate(authTime) || + exp <= iat || + exp - iat > 3600 || + authTime > iat || + (nbf !== undefined && (!numericDate(nbf) || nbf >= exp)) + ) + return undefined; + return Object.freeze({ issuer, subject: sub }); + } catch (error) { + if (error instanceof errors.JOSEError) return undefined; + throw new SsoAccessTokenVerificationUnavailable(); + } + }; +} diff --git a/test/sso-access-token-fixture.ts b/test/sso-access-token-fixture.ts new file mode 100644 index 0000000..d809de8 --- /dev/null +++ b/test/sso-access-token-fixture.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { CompactSign, exportJWK, generateKeyPair, type JWTHeaderParameters } from 'jose'; + +/** Signing material exists only in memory; no private key/token fixtures are persisted. */ +export async function createSsoAccessTokenFixture() { + const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true }); + const exported = await exportJWK(publicKey); + assert.equal(exported.kty, 'RSA'); + assert.ok(typeof exported.n === 'string' && typeof exported.e === 'string'); + const publicJwk = { kty: 'RSA', kid: 'current', n: exported.n, e: exported.e }; + const nowSeconds = 2_000_000_000; + const issuer = 'https://sso.example/private-issuer-fixture'; + const audience = 'urn:opengranter:management'; + const subject = 'private-subject-fixture'; + const claims = { + iss: issuer, + sub: subject, + aud: audience, + iat: nowSeconds - 10, + exp: nowSeconds + 3500, + client_id: 'human-ui', + jti: 'fixture-jti', + auth_time: nowSeconds - 100, + }; + return { + privateKey, + publicKey, + publicJwk, + nowSeconds, + issuer, + audience, + subject, + claims, + config: { issuer, audience, jwks: { keys: [publicJwk] }, now: () => nowSeconds * 1000 }, + token: (patch: Record = {}, header: Record = {}) => + new CompactSign( + new TextEncoder().encode( + JSON.stringify({ ...claims, ...patch }, (_key, value) => + value === Infinity ? '__numeric_overflow__' : value, + ).replaceAll('"__numeric_overflow__"', '1e999'), + ), + ) + .setProtectedHeader({ + alg: 'RS256', + typ: 'at+jwt', + kid: 'current', + ...header, + } as JWTHeaderParameters) + .sign(privateKey), + }; +} diff --git a/test/sso-access-tokens.test.ts b/test/sso-access-tokens.test.ts new file mode 100644 index 0000000..d59a991 --- /dev/null +++ b/test/sso-access-tokens.test.ts @@ -0,0 +1,416 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import test from 'node:test'; +import { exportJWK, generateSecret, SignJWT, UnsecuredJWT } from 'jose'; +import { createRfc9068SsoAccessTokenVerifier } from '../src/identity/rfc9068-sso-access-tokens.ts'; +import { createSsoAccessTokenFixture } from './sso-access-token-fixture.ts'; + +const fixture = await createSsoAccessTokenFixture(); +const other = await createSsoAccessTokenFixture(); +const { nowSeconds: now, config } = fixture; +type Config = Parameters[0]; +function fixed(name: string, message: string) { + return (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.name, name); + assert.equal(error.message, message); + assert.equal(error.cause, undefined); + assert.deepEqual(Object.keys(error), ['name']); + return true; + }; +} +const invalid = fixed( + 'InvalidSsoAccessTokenConfiguration', + 'Invalid SSO access token configuration', +); +const unavailable = fixed( + 'SsoAccessTokenVerificationUnavailable', + 'SSO access token verification unavailable', +); + +test('real RS256 access verification returns only a frozen exact issuer/subject', async () => { + const verify = createRfc9068SsoAccessTokenVerifier(config); + const value = await verify(await fixture.token({ role: 'admin', principalId: 'forged' })); + assert.deepEqual(value, { issuer: fixture.issuer, subject: fixture.subject }); + assert.ok(Object.isFrozen(value)); +}); + +for (const typ of ['at+jwt', 'AT+JWT', 'application/at+jwt', 'APPLICATION/AT+JWT']) { + test(`RFC media typing accepts ${typ}`, async () => { + assert.ok(await createRfc9068SsoAccessTokenVerifier(config)(await fixture.token({}, { typ }))); + }); +} +for (const typ of [undefined, 'JWT', 'id+jwt', 'application/jwt', '', null, 1]) { + test(`access verifier denies other token type ${String(typ)}`, async () => { + assert.equal( + await createRfc9068SsoAccessTokenVerifier(config)(await fixture.token({}, { typ })), + undefined, + ); + }); +} + +test('verifier denies wrong key, signature, kid, symmetric and unsigned tokens', async () => { + const verify = createRfc9068SsoAccessTokenVerifier(config); + assert.equal( + await verify(await other.token({ iss: fixture.issuer, sub: fixture.subject })), + undefined, + ); + const signed = await fixture.token(); + const parts = signed.split('.'); + const signature = parts[2]; + assert.ok(signature); + parts[2] = (signature[0] === 'A' ? 'B' : 'A') + signature.slice(1); + assert.equal(await verify(parts.join('.')), undefined); + assert.equal(await verify(await fixture.token({}, { kid: 'unknown' })), undefined); + const secret = await generateSecret('HS256'); + const symmetric = await new SignJWT(fixture.claims) + .setProtectedHeader({ alg: 'HS256', typ: 'at+jwt', kid: 'current' }) + .sign(secret); + assert.equal(await verify(symmetric), undefined); + assert.equal(await verify(new UnsecuredJWT(fixture.claims).encode()), undefined); +}); + +test('one local key permits absent kid, overlap rotation uses exact kid and ambiguous selection denies', async () => { + const single = createRfc9068SsoAccessTokenVerifier(config); + assert.ok(await single(await fixture.token({}, { kid: undefined }))); + const overlap = createRfc9068SsoAccessTokenVerifier({ + ...config, + jwks: { keys: [fixture.publicJwk, { ...other.publicJwk, kid: 'previous' }] }, + }); + assert.ok(await overlap(await fixture.token())); + assert.ok( + await overlap( + await other.token({ iss: fixture.issuer, sub: fixture.subject }, { kid: 'previous' }), + ), + ); + assert.equal(await overlap(await fixture.token({}, { kid: undefined })), undefined); + const rebuilt = createRfc9068SsoAccessTokenVerifier({ + ...config, + jwks: { keys: [{ ...other.publicJwk, kid: 'previous' }] }, + }); + assert.equal(await rebuilt(await fixture.token()), undefined); +}); + +for (const claim of ['iss', 'sub', 'aud', 'exp', 'iat', 'jti', 'client_id', 'auth_time']) { + test(`verifier requires ${claim}`, async () => { + assert.equal( + await createRfc9068SsoAccessTokenVerifier(config)( + await fixture.token({ [claim]: undefined }), + ), + undefined, + ); + }); +} +const badClaims: readonly [string, Record][] = [ + ['wrong issuer', { iss: `${fixture.issuer}/` }], + ['wrong audience', { aud: 'urn:other' }], + ['mixed malformed audience', { aud: [fixture.audience, 1] }], + ['oversized audience array', { aud: Array(65).fill(fixture.audience) }], + ['blank audience member', { aud: [fixture.audience, ''] }], + ['oversized audience member', { aud: [fixture.audience, 'x'.repeat(257)] }], + ['subject null', { sub: null }], + ['empty subject', { sub: '' }], + ['oversized subject', { sub: 'x'.repeat(257) }], + ['subject NUL', { sub: 'x\0y' }], + ['subject surrogate', { sub: '\ud800' }], + ['client number', { client_id: 1 }], + ['empty client', { client_id: '' }], + ['oversized client', { client_id: 'x'.repeat(257) }], + ['jti number', { jti: 1 }], + ['empty jti', { jti: '' }], + ['jti NUL', { jti: 'x\0y' }], + ['oversized jti', { jti: 'x'.repeat(257) }], + ['negative iat', { iat: -1 }], + ['string iat', { iat: String(now) }], + ['null expiry', { exp: null }], + ['infinite expiry', { exp: Infinity }], + ['zero duration', { exp: now - 10 }], + ['reversed duration', { exp: now - 11 }], + ['duration beyond one hour', { exp: now - 10 + 3600.1 }], + ['expiry at skew cutoff', { iat: now - 100, exp: now - 60, auth_time: now - 200 }], + ['expiry beyond skew', { iat: now - 100, exp: now - 60.1, auth_time: now - 200 }], + ['future iat beyond skew', { iat: now + 60.1, exp: now + 3600 + 60.1 }], + ['old issuance beyond age/skew', { iat: now - 3660.1, exp: now - 60.1, auth_time: now - 4000 }], + ['future nbf beyond skew', { nbf: now + 60.1 }], + ['nbf at expiry', { nbf: now + 3500 }], + ['negative nbf', { nbf: -1 }], + ['auth_time null', { auth_time: null }], + ['auth_time string', { auth_time: String(now - 100) }], + ['negative auth_time', { auth_time: -1 }], + ['future auth_time after iat', { auth_time: now - 9.9 }], +]; +for (const [name, patch] of badClaims) { + test(`signed access token denies ${name}`, async () => { + assert.equal( + await createRfc9068SsoAccessTokenVerifier(config)(await fixture.token(patch)), + undefined, + ); + }); +} + +for (const [name, patch] of [ + ['maximum lifetime', { exp: now - 10 + 3600 }], + ['fractional NumericDates', { iat: now - 10.5, exp: now + 3500.25, auth_time: now - 100.75 }], + ['expiry inside tolerance', { iat: now - 100, exp: now - 59.5, auth_time: now - 200 }], + ['old issuance inside age/skew', { iat: now - 3659, exp: now - 59, auth_time: now - 4000 }], + ['future issuance at skew', { iat: now + 60, exp: now + 3660 }], + ['future not-before at skew', { nbf: now + 60 }], + ['original login predates refresh', { auth_time: 0 }], + ['audience membership', { aud: ['urn:other', fixture.audience] }], + ['maximum audience list', { aud: Array(64).fill(fixture.audience) }], + ['maximum Unicode subject', { sub: '😀'.repeat(128) }], + ['large ignored claims', { extra: 'x'.repeat(10000) }], +] as const) { + test(`valid access profile accepts ${name}`, async () => { + assert.ok(await createRfc9068SsoAccessTokenVerifier(config)(await fixture.token(patch))); + }); +} + +test('caller header keys/URLs neither replace registered keys nor trigger network access', async () => { + const original = globalThis.fetch; + let fetches = 0; + globalThis.fetch = async () => { + fetches++; + throw Error('Must not fetch caller keys'); + }; + try { + const verify = createRfc9068SsoAccessTokenVerifier(config); + const headers = { + jku: 'https://unregistered.example/keys', + x5u: 'https://unregistered.example/cert', + jwk: other.publicJwk, + }; + assert.ok(await verify(await fixture.token({}, headers))); + assert.equal(await verify(await other.token({ iss: fixture.issuer }, headers)), undefined); + assert.equal(fetches, 0); + } finally { + globalThis.fetch = original; + } +}); + +const small = generateKeyPairSync('rsa', { modulusLength: 1024 }).publicKey.export({ + format: 'jwk', +}); +const privateJwk = await exportJWK(fixture.privateKey); +const badConfigs: readonly [string, unknown][] = [ + ['null', null], + ['array', []], + ['inherited config', Object.create(config)], + ['missing issuer', { ...config, issuer: undefined }], + ['HTTP issuer', { ...config, issuer: 'http://sso.example' }], + ['issuer credentials', { ...config, issuer: 'https://user:pass@sso.example' }], + ['issuer query', { ...config, issuer: 'https://sso.example?x=1' }], + ['issuer fragment', { ...config, issuer: 'https://sso.example#x' }], + ['issuer whitespace', { ...config, issuer: ` ${fixture.issuer}` }], + ['oversized issuer', { ...config, issuer: 'https://sso.example/' + 'x'.repeat(2048) }], + ['empty audience', { ...config, audience: '' }], + ['audience NUL', { ...config, audience: 'x\0y' }], + ['oversized audience', { ...config, audience: 'x'.repeat(257) }], + ['missing clock', { ...config, now: undefined }], + ['null JWKS', { ...config, jwks: null }], + ['empty keys', { ...config, jwks: { keys: [] } }], + [ + 'too many keys', + { + ...config, + jwks: { + keys: Array.from({ length: 17 }, (_, index) => ({ + ...fixture.publicJwk, + kid: String(index), + })), + }, + }, + ], + ['duplicate IDs', { ...config, jwks: { keys: [fixture.publicJwk, fixture.publicJwk] } }], + ['inherited key', { ...config, jwks: { keys: [Object.create(fixture.publicJwk)] } }], + ['key hole', { ...config, jwks: { keys: new Array(1) } }], + ['private key', { ...config, jwks: { keys: [{ ...privateJwk, kid: 'current' }] } }], + ['weak RSA', { ...config, jwks: { keys: [{ ...small, kid: 'current' }] } }], + ['non-RSA key', { ...config, jwks: { keys: [{ ...fixture.publicJwk, kty: 'oct' }] } }], + [ + 'noncanonical modulus', + { ...config, jwks: { keys: [{ ...fixture.publicJwk, n: fixture.publicJwk.n + '=' }] } }, + ], + [ + 'leading-zero modulus', + { + ...config, + jwks: { + keys: [ + { + ...fixture.publicJwk, + n: Buffer.concat([ + Buffer.of(0), + Buffer.from(fixture.publicJwk.n, 'base64url'), + ]).toString('base64url'), + }, + ], + }, + }, + ], + ['invalid exponent', { ...config, jwks: { keys: [{ ...fixture.publicJwk, e: 'AA' }] } }], + ['wrong alg', { ...config, jwks: { keys: [{ ...fixture.publicJwk, alg: 'HS256' }] } }], + ['wrong usage', { ...config, jwks: { keys: [{ ...fixture.publicJwk, use: 'enc' }] } }], + ['wrong key ops', { ...config, jwks: { keys: [{ ...fixture.publicJwk, key_ops: ['sign'] }] } }], + [ + 'unknown own key field', + { ...config, jwks: { keys: [{ ...fixture.publicJwk, unknown: 'private fixture' }] } }, + ], + [ + 'throwing config', + { + ...config, + get issuer() { + throw Error('private config fixture'); + }, + }, + ], + [ + 'throwing key', + { + ...config, + jwks: { + keys: [ + { + ...fixture.publicJwk, + get n() { + throw Error('private key fixture'); + }, + }, + ], + }, + }, + ], +]; +for (const [name, value] of badConfigs) { + test(`verifier configuration rejects ${name} privately`, () => { + assert.throws(() => createRfc9068SsoAccessTokenVerifier(value as Config), invalid); + }); +} + +test('configuration getters are captured once; later config/key/algorithm changes cannot change trust', async () => { + const counts = new Map(); + const key: Record = {}; + for (const [name, value] of Object.entries({ + ...fixture.publicJwk, + alg: 'RS256', + use: 'sig', + key_ops: ['verify'], + })) + Object.defineProperty(key, name, { + get() { + counts.set(name, (counts.get(name) ?? 0) + 1); + assert.equal(counts.get(name), 1); + return value; + }, + }); + let issuer = fixture.issuer; + let keys: unknown[] = [key]; + let issuerReads = 0; + let keysReads = 0; + let clockReads = 0; + let clockCalls = 0; + const input = { + get issuer() { + issuerReads++; + return issuer; + }, + audience: fixture.audience, + jwks: { + get keys() { + keysReads++; + return keys; + }, + }, + get now() { + clockReads++; + return () => { + clockCalls++; + return now * 1000; + }; + }, + }; + const verify = createRfc9068SsoAccessTokenVerifier(input); + issuer = 'https://other.example'; + keys = [other.publicJwk]; + assert.ok(await verify(await fixture.token())); + assert.equal(issuerReads, 1); + assert.equal(keysReads, 1); + assert.equal(clockReads, 1); + assert.equal(clockCalls, 1); + assert.equal(counts.size, 7); +}); + +test('key list and key-operation custom iterators are never invoked', async () => { + const keys = [{ ...fixture.publicJwk, key_ops: ['verify'] }]; + keys[Symbol.iterator] = () => { + throw Error('Untrusted key iterator'); + }; + const first = keys[0]; + assert.ok(first); + first.key_ops[Symbol.iterator] = () => { + throw Error('Untrusted operation iterator'); + }; + assert.ok( + await createRfc9068SsoAccessTokenVerifier({ ...config, jwks: { keys } })(await fixture.token()), + ); +}); + +for (const [name, clock] of [ + [ + 'throws', + () => { + throw Error('private clock fixture'); + }, + ], + ['NaN', () => NaN], + ['negative', () => -1], + ['fractional ms', () => 0.5], + ['outside Date range', () => 8_640_000_000_000_001], +] as const) { + test(`invalid runtime clock reports safe availability: ${name}`, async () => { + await assert.rejects( + createRfc9068SsoAccessTokenVerifier({ ...config, now: clock })(await fixture.token()), + unavailable, + ); + }); +} + +test('malformed, oversized, NUL and proxy tokens are rejected without leaking data', async () => { + let clocks = 0; + const verify = createRfc9068SsoAccessTokenVerifier({ + ...config, + now: () => { + clocks++; + return now * 1000; + }, + }); + for (const token of [null, undefined, 1, '', 'x'.repeat(16385), '\ud800', 'x\0y']) + assert.equal(await verify(token as string), undefined); + assert.equal(clocks, 0); + for (const token of [ + 'not-a-jwt', + 'og1_fixture_fixture', + `${await fixture.token()}extra`, + await fixture.token({ extra: 'x'.repeat(15000) }), + ]) + assert.equal(await verify(token), undefined); +}); + +test('unexpected cryptographic engine failure exposes only safe verification availability', async () => { + const signed = await fixture.token(); + const subtle = globalThis.crypto.subtle; + const original = Object.getOwnPropertyDescriptor(subtle, 'importKey'); + Object.defineProperty(subtle, 'importKey', { + configurable: true, + value: async () => { + throw Error('private cryptographic engine failure'); + }, + }); + try { + await assert.rejects(createRfc9068SsoAccessTokenVerifier(config)(signed), unavailable); + } finally { + if (original) Object.defineProperty(subtle, 'importKey', original); + else Reflect.deleteProperty(subtle, 'importKey'); + } +}); diff --git a/test/sso-token-management.test.ts b/test/sso-token-management.test.ts new file mode 100644 index 0000000..0b01c1f --- /dev/null +++ b/test/sso-token-management.test.ts @@ -0,0 +1,663 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PGlite } from '@electric-sql/pglite'; +import { createPostgresProxyCredentialStore } from '../src/gateway/postgres-proxy-credentials.ts'; +import { + createPostgresRfc9068SsoTokenManagementService, + createPostgresSsoTokenManagementService, +} from '../src/gateway/postgres-sso-token-management-service.ts'; +import { createProxyTokenService } from '../src/gateway/proxy-tokens.ts'; +import { createRfc9068SsoAccessTokenVerifier } from '../src/identity/rfc9068-sso-access-tokens.ts'; +import { loadPostgresMigrationSources } from '../src/storage/postgres-migration-sources.ts'; +import { createSsoAccessTokenFixture } from './sso-access-token-fixture.ts'; + +const jwt = await createSsoAccessTokenFixture(); +const accessToken = await jwt.token(); +const issue = { + accessToken, + requestId: 'issue', + principalId: 'target-service', + expiresAt: jwt.nowSeconds * 1000 + 10000, +}; +type Service = ReturnType; +type Verifier = Parameters[0]['verifyAccessToken']; +const identity = { issuer: jwt.issuer, subject: jwt.subject }; +function fixed(name: string, message: string) { + return (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.name, name); + assert.equal(error.message, message); + assert.equal(error.cause, undefined); + assert.deepEqual(Object.keys(error), ['name']); + return true; + }; +} +const invalid = fixed('InvalidSsoTokenManagementInput', 'Invalid SSO token management input'); +const unauthenticated = fixed('SsoManagementUnauthenticated', 'SSO management unauthenticated'); +const unavailable = fixed( + 'SsoManagementAuthenticationUnavailable', + 'SSO management authentication unavailable', +); +const denied = fixed('TokenManagementDenied', 'Token management denied'); +const managementUnavailable = fixed('TokenManagementUnavailable', 'Token management unavailable'); + +function call(service: Service, operation: 'issue' | 'revoke', value: unknown) { + return operation === 'issue' + ? service.issue(value as Parameters[0]) + : service.revoke(value as Parameters[0]); +} +function inputFor(operation: 'issue' | 'revoke') { + return operation === 'issue' + ? { ...issue } + : { accessToken, requestId: 'revoke', credentialId: 'credential' }; +} + +for (const operation of ['issue', 'revoke'] as const) { + const raw = inputFor(operation); + for (const [name, value] of [ + ['null', null], + ['array', []], + ['inherited required fields', Object.create(raw)], + ['missing access token', { ...raw, accessToken: undefined }], + ['empty token', { ...raw, accessToken: '' }], + ['oversized token', { ...raw, accessToken: 'x'.repeat(16385) }], + ['NUL token', { ...raw, accessToken: 'x\0y' }], + ['surrogate token', { ...raw, accessToken: '\ud800' }], + ['numeric token', { ...raw, accessToken: 1 }], + ['empty request ID', { ...raw, requestId: '' }], + ['oversized request ID', { ...raw, requestId: 'x'.repeat(257) }], + ['NUL request ID', { ...raw, requestId: 'x\0y' }], + ['surrogate request ID', { ...raw, requestId: '\udfff' }], + [ + 'throwing input', + { + ...raw, + get requestId() { + throw Error('private request fixture'); + }, + }, + ], + ...(operation === 'issue' + ? [ + ['missing principal', { ...raw, principalId: undefined }], + ['oversized principal', { ...raw, principalId: 'x'.repeat(257) }], + ['NUL principal', { ...raw, principalId: 'x\0y' }], + ['negative expiry', { ...raw, expiresAt: -1 }], + ['fractional expiry', { ...raw, expiresAt: 1.5 }], + ['unsafe expiry', { ...raw, expiresAt: Number.MAX_SAFE_INTEGER + 1 }], + ] + : [ + ['missing credential', { ...raw, credentialId: undefined }], + ['empty credential', { ...raw, credentialId: '' }], + ['oversized credential', { ...raw, credentialId: 'x'.repeat(257) }], + ['NUL credential', { ...raw, credentialId: 'x\0y' }], + ]), + ] as const) { + test(`${operation} invalid input before verification or SQL: ${name}`, async () => { + let queries = 0; + let verifies = 0; + const service = createPostgresSsoTokenManagementService({ + now: jwt.config.now, + client: { + query: async () => { + queries++; + return { rows: [] }; + }, + }, + verifyAccessToken: async () => { + verifies++; + return identity; + }, + }); + await assert.rejects(call(service, operation, value), invalid); + assert.equal(queries, 0); + assert.equal(verifies, 0); + }); + } + for (const [name, verify, expected] of [ + ['verifier rejects', async () => undefined, unauthenticated], + [ + 'verifier throws', + async () => { + throw Error('private token/claim fixture'); + }, + unavailable, + ], + [ + 'verifier throws spoofed domain error', + async () => { + const error = Error('private token fixture'); + error.name = 'SsoManagementUnauthenticated'; + throw error; + }, + unavailable, + ], + ['null verified claims', async () => null, unavailable], + ['forged principal instead of claims', async () => ({ principalId: 'admin' }), unavailable], + ['inherited verified claims', async () => Object.create(identity), unavailable], + ['invalid verified issuer', async () => ({ ...identity, issuer: '' }), unavailable], + [ + 'claim getter throws', + async () => ({ + ...identity, + get subject() { + throw Error('private subject fixture'); + }, + }), + unavailable, + ], + ] as const) { + test(`${operation} rejects ${name} before binding or management`, async () => { + let queries = 0; + const service = createPostgresSsoTokenManagementService({ + now: jwt.config.now, + client: { + query: async () => { + queries++; + return { rows: [] }; + }, + }, + verifyAccessToken: verify as Verifier, + }); + await assert.rejects( + call(service, operation, { + ...raw, + authenticatedActorId: 'admin', + actor: { active: true }, + }), + expected, + ); + assert.equal(queries, 0); + }); + } + for (const [name, rows, expected] of [ + ['missing binding', [], unauthenticated], + [ + 'inactive human', + [{ ...identity, principal_id: 'admin', kind: 'human', active: false }], + unauthenticated, + ], + [ + 'service', + [{ ...identity, principal_id: 'worker', kind: 'service', active: true }], + unauthenticated, + ], + [ + 'mismatched binding', + [ + { + ...identity, + issuer: 'https://other.example', + principal_id: 'admin', + kind: 'human', + active: true, + }, + ], + unavailable, + ], + [ + 'duplicate binding', + [ + { ...identity, principal_id: 'admin', kind: 'human', active: true }, + { ...identity, principal_id: 'other', kind: 'human', active: true }, + ], + unavailable, + ], + ] as const) { + test(`${operation} denies ${name} before management`, async () => { + let queries = 0; + const service = createPostgresSsoTokenManagementService({ + now: jwt.config.now, + verifyAccessToken: async () => identity, + client: { + query: async (sql, params) => { + queries++; + assert.match(sql, /JOIN iam_principals/u); + assert.deepEqual(params, [identity.issuer, identity.subject]); + assert.ok(Object.isFrozen(params)); + return { rows }; + }, + }, + }); + await assert.rejects( + call(service, operation, { + ...raw, + authenticatedActorId: 'admin', + active: true, + kind: 'human', + }), + expected, + ); + assert.equal(queries, 1); + }); + } +} + +async function fixture() { + const db = new PGlite(); + for (const migration of await loadPostgresMigrationSources()) await db.exec(migration.sql); + await db.exec(` + INSERT INTO iam_principals VALUES ('human-admin','human',true),('other-human','human',true),('worker','service',true),('inactive','human',false),('target-service','service',true),('target-human','human',true); + INSERT INTO iam_roles VALUES ('managers'); + INSERT INTO iam_principal_roles VALUES ('human-admin','managers'); + INSERT INTO iam_policies VALUES ('manage','v1','[{"effect":"Allow","actions":["iam:Manage"],"resources":["principal:target-service","principal:target-human"]}]'); + INSERT INTO iam_role_policies VALUES ('managers','manage'); + `); + for (const [subject, principal] of [ + [jwt.subject, 'human-admin'], + ['other', 'other-human'], + ['worker', 'worker'], + ['inactive', 'inactive'], + ]) + await db.query('INSERT INTO iam_sso_identity_bindings VALUES ($1,$2,$3)', [ + jwt.issuer, + subject, + principal, + ]); + const queries: string[] = []; + const client = { + query: (sql: string, params: readonly unknown[]) => { + queries.push(sql); + return db.query(sql, [...params]); + }, + }; + const service = createPostgresRfc9068SsoTokenManagementService({ ...jwt.config, client }); + const tokens = createProxyTokenService({ + store: createPostgresProxyCredentialStore(client), + now: jwt.config.now, + }); + const injected = (verifyAccessToken: Verifier, query = client.query) => + createPostgresSsoTokenManagementService({ + now: jwt.config.now, + verifyAccessToken, + client: { query }, + }); + const counts = async () => + ( + await db.query<{ credentials: number; decisions: number; lifecycle: number }>( + 'SELECT (SELECT count(*)::int FROM proxy_credentials) AS credentials, (SELECT count(*)::int FROM token_management_decisions) AS decisions, (SELECT count(*)::int FROM proxy_credential_events) AS lifecycle', + ) + ).rows[0]; + const records = async () => ({ + decisions: (await db.query('SELECT * FROM token_management_decisions ORDER BY event_id')).rows, + lifecycle: (await db.query('SELECT * FROM proxy_credential_events ORDER BY event_id')).rows, + }); + return { db, client, queries, service, tokens, injected, counts, records }; +} + +test('configured signed SSO human issues/revokes through current persisted IAM and private lifecycle/audit', async () => { + const f = await fixture(); + try { + const result = await f.service.issue({ + ...issue, + authenticatedActorId: 'worker', + actor: { statements: [] }, + owner: 'other-human', + } as Parameters[0]); + assert.equal((await f.tokens.verifyCredential(result.token))?.principalId, 'target-service'); + assert.match(f.queries[0] ?? '', /JOIN iam_principals/u); + assert.ok( + f.queries.findIndex((sql) => sql.includes('INSERT INTO token_management_decisions')) < + f.queries.findIndex((sql) => sql.includes('WITH inserted AS')), + ); + assert.equal( + await f.service.revoke({ + accessToken, + requestId: 'revoke', + credentialId: result.credentialId, + principalId: 'other-human', + } as Parameters[0]), + true, + ); + assert.equal(await f.tokens.verifyCredential(result.token), undefined); + const state = await f.records(); + assert.deepEqual( + state.decisions + .map((row) => ({ ...(row as Record) })) + .map((row) => [row.actor_id, row.target_principal_id, row.outcome]), + [ + ['human-admin', 'target-service', 'allowed'], + ['human-admin', 'target-service', 'allowed'], + ], + ); + const serialized = JSON.stringify(state); + for (const privateValue of [accessToken, jwt.issuer, jwt.subject, result.token]) + assert.ok(!serialized.includes(privateValue)); + assert.deepEqual(await f.counts(), { credentials: 1, decisions: 2, lifecycle: 2 }); + } finally { + await f.db.close(); + } +}); + +test('configured verifier denial, unmapped/inactive/service identities and proxy tokens stop before management', async () => { + const f = await fixture(); + try { + const proxy = await f.tokens.issue({ + principalId: 'target-service', + actorId: 'bootstrap', + requestId: 'bootstrap', + expiresAt: issue.expiresAt, + }); + const baseline = await f.counts(); + for (const token of [ + await jwt.token({ auth_time: undefined }), + await jwt.token({ sub: 'missing' }), + await jwt.token({ sub: 'inactive' }), + await jwt.token({ sub: 'worker' }), + await jwt.token({ aud: 'urn:other' }), + proxy.token, + ]) { + const start = f.queries.length; + await assert.rejects( + f.service.issue({ + ...issue, + accessToken: token, + authenticatedActorId: 'human-admin', + } as Parameters[0]), + unauthenticated, + ); + assert.ok(f.queries.slice(start).every((sql) => sql.includes('JOIN iam_principals'))); + assert.deepEqual(await f.counts(), baseline); + } + assert.deepEqual((await f.db.query('SELECT count(*)::int AS count FROM iam_principals')).rows, [ + { count: 6 }, + ]); + } finally { + await f.db.close(); + } +}); + +test('fresh binding and policy state retains implicit/explicit Deny despite forged request authority', async () => { + const f = await fixture(); + try { + const request = { + ...issue, + statements: [{ effect: 'Allow', actions: ['*'], resources: ['*'] }], + authenticatedActorId: 'human-admin', + }; + await assert.rejects( + f.service.issue({ ...request, accessToken: await jwt.token({ sub: 'other' }) }), + denied, + ); + await f.db.exec( + 'INSERT INTO iam_policies VALUES (\'direct-deny\',\'d1\',\'[{"effect":"Deny","actions":["iam:Manage"],"resources":["*"]}]\'); INSERT INTO iam_principal_policies VALUES (\'human-admin\',\'direct-deny\')', + ); + await assert.rejects(f.service.issue(request), denied); + await f.db.exec('DELETE FROM iam_principal_policies; DELETE FROM iam_principal_roles'); + await assert.rejects(f.service.issue(request), denied); + await f.db.exec( + "INSERT INTO iam_principal_roles VALUES ('human-admin','managers'); UPDATE iam_policies SET version='v2' WHERE policy_id='manage'", + ); + const issued = await f.service.issue(request); + await f.db.query( + 'UPDATE iam_sso_identity_bindings SET principal_id=$3 WHERE issuer=$1 AND subject=$2', + [jwt.issuer, jwt.subject, 'other-human'], + ); + await assert.rejects( + f.service.revoke({ + accessToken, + requestId: 'changed-binding', + credentialId: issued.credentialId, + }), + denied, + ); + await f.db.query( + 'UPDATE iam_sso_identity_bindings SET principal_id=$3 WHERE issuer=$1 AND subject=$2', + [jwt.issuer, jwt.subject, 'human-admin'], + ); + await f.db.exec("UPDATE iam_principals SET active=false WHERE principal_id='human-admin'"); + const baseline = await f.counts(); + await assert.rejects( + f.service.revoke({ accessToken, requestId: 'inactive', credentialId: issued.credentialId }), + unauthenticated, + ); + assert.deepEqual(await f.counts(), baseline); + await f.db.exec("UPDATE iam_principals SET active=true WHERE principal_id='human-admin'"); + assert.equal( + await f.service.revoke({ + accessToken, + requestId: 'reactivated', + credentialId: issued.credentialId, + }), + true, + ); + assert.ok(JSON.stringify((await f.records()).decisions).includes('v2')); + } finally { + await f.db.close(); + } +}); + +for (const operation of ['issue', 'revoke'] as const) { + for (const stage of ['binding', 'policy', 'audit', 'owner', 'mutation'] as const) { + test(`${operation} ${stage} dependency failure blocks credential/lifecycle changes privately`, async () => { + const f = await fixture(); + try { + const existing = + operation === 'revoke' + ? await f.tokens.issue({ + principalId: 'target-service', + actorId: 'bootstrap', + requestId: 'bootstrap', + expiresAt: issue.expiresAt, + }) + : undefined; + const baseline = await f.counts(); + assert.ok(baseline); + const service = f.injected( + createRfc9068SsoAccessTokenVerifier(jwt.config), + async (sql, params) => { + if ( + (stage === 'binding' && sql.includes('JOIN iam_principals')) || + (stage === 'policy' && sql.includes('jsonb_build_object')) || + (stage === 'audit' && sql.includes('INSERT INTO token_management_decisions')) || + (stage === 'owner' && + (sql.includes('SELECT principal_id, kind FROM iam_principals') || + sql.includes('SELECT principal_id FROM proxy_credentials'))) || + (stage === 'mutation' && + (sql.includes('WITH inserted AS') || sql.includes('WITH changed AS'))) + ) + throw Error('private SQL/access-token fixture'); + return f.client.query(sql, params); + }, + ); + const input = + operation === 'issue' + ? issue + : { accessToken, requestId: 'revoke', credentialId: existing?.credentialId }; + await assert.rejects( + call(service, operation, input), + stage === 'binding' ? unavailable : managementUnavailable, + ); + const after = await f.counts(); + assert.ok(after); + assert.equal(after.credentials, baseline.credentials); + assert.equal(after.lifecycle, baseline.lifecycle); + assert.equal( + after.decisions - baseline.decisions, + stage === 'mutation' || (stage === 'owner' && operation === 'issue') ? 1 : 0, + ); + if (existing) assert.ok(await f.tokens.verifyCredential(existing.token)); + } finally { + await f.db.close(); + } + }); + } +} + +test('configured composition preserves exact human/service proxy-token lifetime caps', async () => { + const f = await fixture(); + try { + for (const [principalId, days] of [ + ['target-human', 30], + ['target-service', 90], + ] as const) { + const expiresAt = jwt.config.now() + days * 86_400_000; + assert.ok((await f.service.issue({ ...issue, principalId, expiresAt })).token); + await assert.rejects( + f.service.issue({ ...issue, principalId, expiresAt: expiresAt + 1 }), + managementUnavailable, + ); + } + assert.deepEqual(await f.counts(), { credentials: 2, decisions: 4, lifecycle: 2 }); + } finally { + await f.db.close(); + } +}); + +test('issue captures own request/verified getters once and prevents mutations across verifier and SQL awaits', async () => { + const f = await fixture(); + try { + const source: Record = { ...issue }; + const counts = new Map(); + const input = {}; + for (const name of Object.keys(issue)) + Object.defineProperty(input, name, { + get() { + counts.set(name, (counts.get(name) ?? 0) + 1); + assert.equal(counts.get(name), 1); + return source[name]; + }, + }); + Object.defineProperty(input, 'authenticatedActorId', { + get() { + throw Error('Forged extra getter must remain unread'); + }, + }); + let issuer = jwt.issuer; + let subject = jwt.subject; + let issuerReads = 0; + let subjectReads = 0; + const verified = { + get issuer() { + assert.equal(++issuerReads, 1); + return issuer; + }, + get subject() { + assert.equal(++subjectReads, 1); + return subject; + }, + get role() { + throw Error('Extra verified field'); + }, + }; + const service = f.injected( + async (token) => { + assert.equal(token, accessToken); + Object.assign(source, { + accessToken: 'invalid', + principalId: 'target-human', + requestId: 'mutated', + expiresAt: 1, + }); + await Promise.resolve(); + return verified; + }, + async (sql, params) => { + if (sql.includes('JOIN iam_principals')) { + issuer = 'https://other.example'; + subject = 'worker'; + assert.deepEqual(params, [jwt.issuer, jwt.subject]); + assert.ok(Object.isFrozen(params)); + } + return f.client.query(sql, params); + }, + ); + const result = await service.issue(input as Parameters[0]); + assert.equal((await f.tokens.verifyCredential(result.token))?.principalId, 'target-service'); + assert.equal(counts.size, 4); + assert.equal(issuerReads, 1); + assert.equal(subjectReads, 1); + assert.equal(((await f.records()).decisions[0] as Record).request_id, 'issue'); + } finally { + await f.db.close(); + } +}); + +test('revoke snapshots its credential and ignores caller owner/actor mutations during verification', async () => { + const f = await fixture(); + try { + const existing = await f.service.issue(issue); + const source = { accessToken, requestId: 'captured', credentialId: existing.credentialId }; + const service = f.injected(async (token) => { + assert.equal(token, accessToken); + source.credentialId = 'missing'; + source.requestId = 'mutated'; + await Promise.resolve(); + return identity; + }); + assert.equal( + await service.revoke({ + get accessToken() { + return source.accessToken; + }, + get requestId() { + return source.requestId; + }, + get credentialId() { + return source.credentialId; + }, + principalId: 'other-human', + authenticatedActorId: 'worker', + } as Parameters[0]), + true, + ); + assert.equal(await f.tokens.verifyCredential(existing.token), undefined); + assert.equal( + ((await f.records()).decisions.at(-1) as Record).request_id, + 'captured', + ); + } finally { + await f.db.close(); + } +}); + +const configuredPorts = { + ...jwt.config, + client: { query: async () => ({ rows: [] }) }, +}; +for (const [name, value] of [ + ['null', null], + ['array', []], + ['inherited configuration', Object.create(configuredPorts)], + ...['issuer', 'audience', 'jwks', 'now', 'client'].map((field) => [ + `throwing ${field}`, + Object.defineProperty({ ...configuredPorts }, field, { + get() { + throw Error('private administrator configuration'); + }, + }), + ]), +] as const) { + test(`configured management factory rejects ${name} without exposing settings`, () => { + assert.throws( + () => + createPostgresRfc9068SsoTokenManagementService( + value as Parameters[0], + ), + fixed('InvalidSsoAccessTokenConfiguration', 'Invalid SSO access token configuration'), + ); + }); +} +test('configured management factory captures own settings once without reading extra authority', async () => { + const counts = new Map(); + const source = {}; + for (const [field, value] of Object.entries(configuredPorts)) { + Object.defineProperty(source, field, { + get() { + counts.set(field, (counts.get(field) ?? 0) + 1); + assert.equal(counts.get(field), 1); + return value; + }, + }); + } + Object.defineProperty(source, 'authenticatedActorId', { + get() { + throw Error('Caller authority must not be read'); + }, + }); + const service = createPostgresRfc9068SsoTokenManagementService( + source as Parameters[0], + ); + await assert.rejects(service.issue(issue), unauthenticated); + assert.equal(counts.size, 5); +});