diff --git a/README.md b/README.md index d94319e0..093d6674 100644 --- a/README.md +++ b/README.md @@ -873,6 +873,7 @@ graph TD | GET | `/.well-known/jwks.json` | JWKS public keys | | GET | `/.well-known/oauth-authorization-server` | OAuth2 server metadata (RFC 8414) | | GET | `/.well-known/oauth-protected-resource` | Protected Resource Metadata (RFC 9728) — discovery entry point clients hit before AS metadata | +| GET | `/.well-known/openid-configuration` | OIDC Discovery 1.0 metadata — the path AWS / Azure / GCP fetch to federate to ZeroID as an external issuer. AS metadata plus `subject_types_supported` and `id_token_signing_alg_values_supported`. ZeroID issues no `id_token`; this document exists for trust establishment, not OIDC login | | GET | `/.well-known/spiffe-trust-bundle.json` | SPIFFE JWT-SVID trust bundle | | POST | `/oauth2/token` | Issue token (7 grant types, including `urn:openid:params:grant-type:ciba`) | | POST | `/oauth2/token/introspect` | Token introspection (RFC 7662) | @@ -943,6 +944,7 @@ The extensions ZeroID layers on these baseline specs — the additional JWT clai | JSON Web Key Sets | RFC 7517 | Public key distribution | | OAuth Authorization Server Metadata | RFC 8414 | `/.well-known/oauth-authorization-server` discovery | | OAuth Protected Resource Metadata | RFC 9728 | `/.well-known/oauth-protected-resource` — first hop of the OAuth discovery chain; points clients at AS metadata | +| OpenID Connect Discovery 1.0 | OpenID | `/.well-known/openid-configuration` — the discovery path cloud workload-identity federation (AWS IAM OIDC providers, Azure federated identity credentials, GCP workload identity pools) requires to trust ZeroID as an external issuer. Metadata only: ZeroID is not an OpenID Provider and issues no `id_token` | | WIMSE / SPIFFE | IETF Draft | Agent workload identity URIs | | Shared Signals Framework (SSF) | OpenID SSF | Real-time revocation event propagation | | CAEP | OpenID CAEP | Continuous access evaluation signals | diff --git a/docs/spec/zeroid-oauth-extensions.md b/docs/spec/zeroid-oauth-extensions.md index 895e4c4b..b1a3ba9a 100644 --- a/docs/spec/zeroid-oauth-extensions.md +++ b/docs/spec/zeroid-oauth-extensions.md @@ -818,8 +818,11 @@ either side — only ZeroID's standard issuer surface: compatibility test. - **Issuer discovery.** `/.well-known/oauth-authorization-server` (Section 11.1) advertises `issuer` and `jwks_uri`; a relying party either points its JWKS - source at that discovery document or is given the JWKS URL explicitly. ZeroID - does not publish an `/.well-known/openid-configuration` document. + source at that discovery document or is given the JWKS URL explicitly. + Relying parties that discover an external issuer the OIDC way — AWS IAM OIDC + identity providers, Azure federated identity credentials and GCP workload + identity pools all fetch `/.well-known/openid-configuration` and do not read + RFC 8414's path — are served by the OIDC discovery document (Section 11.4). - **SPIFFE consumers.** `/.well-known/spiffe-trust-bundle.json` (Section 11.3) serves the same keys with `use="JWT-SVID"` for SPIFFE-strict validators. @@ -876,6 +879,35 @@ JWT-SVID trust bundle: each key's `use` is `JWT-SVID`, and the document carries `spiffe_sequence` and `spiffe_refresh_hint` per the SPIFFE bundle format. This lets SPIFFE-aware verifiers consume ZeroID's `sub` (a SPIFFE ID) natively. +### 11.4 OpenID Connect Discovery metadata + +`GET /.well-known/openid-configuration` serves the Section 11.1 document plus +the two members OpenID Connect Discovery 1.0 §3 makes REQUIRED that RFC 8414 §2 +does not define: + +| Field | Value | Source spec | +|---|---|---| +| `subject_types_supported` | `["public"]` | OIDC Discovery §3 | +| `id_token_signing_alg_values_supported` | derived from the live keyset (`["ES256","RS256"]` in the reference deployment) | OIDC Discovery §3 | + +The document exists for **federation trust establishment**, not for OIDC login. +It is the discovery path the cloud relying parties in Section 10 fetch to locate +`jwks_uri`; publishing only RFC 8414's path leaves ZeroID undiscoverable to them +even though the keyset is already public. + +`subject_types_supported` is `public` — not `pairwise` — because ZeroID's `sub` +is the workload's WIMSE URI (Section 3.1) and is identical for every verifier. A +per-relying-party pseudonymous subject would break the cloud role trust policies +that pin a literal `sub` value. + +**ZeroID is not an OpenID Provider.** It issues no `id_token`, and this document +**MUST NOT** be read as advertising one. `response_types_supported` is inherited +from Section 11.1 and carries at most `"code"` — never `"id_token"` or +`"id_token token"` — so no relying party can request an ID Token from this +authorization server. `scopes_supported` is not advertised, so `openid` is not +offered either. The OIDC Discovery §5 UserInfo endpoint, §2 WebFinger issuer +discovery, and every ID Token clause are out of scope. + ## 12. Client ID Metadata Documents (CIMD) ZeroID implements @@ -1175,8 +1207,8 @@ See §9.1 / §9.2. ### 14.5 Discovery fields -See §11.1 / §11.2 / §11.3. `client_id_metadata_document_supported` is specified -in §12.7. +See §11.1 / §11.2 / §11.3 / §11.4. `client_id_metadata_document_supported` is +specified in §12.7. ### 14.6 Workload Identity Federation @@ -1184,8 +1216,8 @@ Inbound: proof types (`oidc_token`, `image_hash`, `tpm`) and the `OIDCPolicyConfig` / `OIDCIssuerConfig` policy fields (`issuers`, `url`, `audiences`, `required_claims`) — §10.1–10.3. Outbound: the ZeroID-as-federation-issuer surface — JWKS `use="sig"`, -`oauth-authorization-server` `issuer`/`jwks_uri`, and the SPIFFE trust bundle -(§10.4). +`oauth-authorization-server` and `openid-configuration` `issuer`/`jwks_uri`, and +the SPIFFE trust bundle (§10.4, §11.4). ## 15. References diff --git a/internal/handler/wellknown.go b/internal/handler/wellknown.go index e93c59f1..c6f9ab1f 100644 --- a/internal/handler/wellknown.go +++ b/internal/handler/wellknown.go @@ -34,6 +34,13 @@ type OAuthMetadataOutput struct { Body map[string]any } +// OpenIDConfigurationOutput is the OpenID Connect Discovery 1.0 document +// published at /.well-known/openid-configuration. See openidConfigurationOp for +// why ZeroID serves this path despite not being an OpenID Provider. +type OpenIDConfigurationOutput struct { + Body map[string]any +} + // ProtectedResourceMetadataOutput is the RFC 9728 OAuth 2.0 Protected Resource // Metadata document published at /.well-known/oauth-protected-resource. Agents // that hit a 401 with a WWW-Authenticate: Bearer resource_metadata="…" header @@ -70,6 +77,14 @@ func (a *API) registerWellKnownRoutes(api huma.API) { Tags: []string{"Discovery"}, }, a.oauthMetadataOp) + huma.Register(api, huma.Operation{ + OperationID: "openid-configuration", + Method: http.MethodGet, + Path: "/.well-known/openid-configuration", + Summary: "OpenID Connect Discovery 1.0 metadata", + Tags: []string{"Discovery"}, + }, a.openidConfigurationOp) + huma.Register(api, huma.Operation{ OperationID: "oauth-protected-resource", Method: http.MethodGet, @@ -114,6 +129,15 @@ func (a *API) spiffeTrustBundleOp(_ context.Context, _ *struct{}) (*SPIFFETrustB } func (a *API) oauthMetadataOp(_ context.Context, _ *struct{}) (*OAuthMetadataOutput, error) { + return &OAuthMetadataOutput{Body: a.buildASMetadata()}, nil +} + +// buildASMetadata constructs the RFC 8414 Authorization Server Metadata +// document. Returns a FRESH map on every call — both /.well-known/ +// oauth-authorization-server and /.well-known/openid-configuration serve it, +// and the OIDC document adds members to what it gets back. A shared or cached +// map would let the OIDC-only members leak into the RFC 8414 document. +func (a *API) buildASMetadata() map[string]any { // Evaluate the gate ONCE per request. The predicate is deliberately // dynamic — a deployer may register resolvers after NewServer — so calling // it twice could emit a document advertising CIMD without the @@ -229,7 +253,100 @@ func (a *API) oauthMetadataOp(_ context.Context, _ *struct{}) (*OAuthMetadataOut body["client_id_metadata_document_supported"] = true } - return &OAuthMetadataOutput{Body: body}, nil + return body +} + +// openidConfigurationOp serves OpenID Connect Discovery 1.0 metadata at +// /.well-known/openid-configuration. +// +// # Why an AS that issues no id_token serves an OIDC discovery document +// +// This document is here for FEDERATION TRUST ESTABLISHMENT, not for OIDC login. +// Every cloud that can be taught to trust an external issuer — AWS IAM OIDC +// identity providers, Azure federated identity credentials, GCP workload +// identity pools — discovers that issuer by fetching this exact path to find +// its `jwks_uri`. All three were written against OpenID Connect Discovery, not +// against RFC 8414, so publishing only /.well-known/oauth-authorization-server +// leaves ZeroID undiscoverable to all of them even though the keyset it would +// verify against is already public at /.well-known/jwks.json. Serving this +// document is what makes "configure your cloud to trust identities ZeroID +// issued" a one-time setup rather than an impossibility (ADR 0028 D8). +// +// # ZeroID is NOT an OpenID Provider +// +// It issues no id_token. Do not read this document as evidence otherwise, and +// do not add members here that imply an OIDC authentication flow. The structural +// guard is `response_types_supported`, inherited from the RFC 8414 document: it +// carries at most "code" and never "id_token"/"id_token token", so no relying +// party can request an id_token from this AS no matter what it discovers here. +// When id_token issuance lands, that is the member that changes — along with +// adding "openid" to a scopes_supported list — and it should change in the same +// PR that makes the claim true. +// +// Body is the RFC 8414 document plus the two members OIDC Discovery §3 makes +// REQUIRED that RFC 8414 §2 does not define. The remaining OIDC-required +// members (issuer, authorization_endpoint, token_endpoint, jwks_uri, +// response_types_supported) are already in the base document, which is why this +// is a superset rather than a separate document — two hand-maintained documents +// describing one AS is how they drift. +func (a *API) openidConfigurationOp(_ context.Context, _ *struct{}) (*OpenIDConfigurationOutput, error) { + body := a.buildASMetadata() + + // OIDC Discovery §3 REQUIRED. "public" means every relying party sees the + // same `sub` for the same principal — which is what ZeroID does: `sub` is + // the stable identity ID. The alternative, "pairwise", would mean minting a + // per-RP pseudonymous subject, and a per-RP `sub` would break the federation + // use case this document exists for: a cloud role trust policy pins a + // literal `sub` value, so that value has to be stable across verifiers. + body["subject_types_supported"] = []string{"public"} + + // OIDC Discovery §3 REQUIRED. Derived from the live keyset rather than + // hardcoded, so it cannot drift from what /.well-known/jwks.json actually + // publishes — a federation verifier that trusts an alg we no longer sign + // with (or refuses one we do) fails at token-verification time, far from + // this file. + // + // Read this as a statement about the algorithms this ISSUER SIGNS WITH, + // which is what a federation verifier consumes it as. It is not a promise + // that id_tokens are available — see the note above on response_types_supported. + body["id_token_signing_alg_values_supported"] = a.signingAlgValues() + + return &OpenIDConfigurationOutput{Body: body}, nil +} + +// signingAlgValues returns the distinct `alg` values of the published signing +// keys, in keyset order (ES256 first — it is always loaded; RS256 only when the +// deployer configured an RSA key). Keys with no `alg` are skipped rather than +// emitted as "": an empty string is not a JWA algorithm name and would make the +// discovery document invalid for every consumer, not just the one key. +func (a *API) signingAlgValues() []string { + set := a.jwksSvc.KeySet() + + // Non-nil zero-length start: this member is REQUIRED, and a nil slice + // marshals to `null`, which is not the "no algorithms" the empty array is. + algs := []string{} + seen := map[string]struct{}{} + + for i := range set.Len() { + key, ok := set.Key(i) + if !ok { + continue + } + + alg, ok := key.Algorithm() + if !ok || alg.String() == "" { + continue + } + + if _, dup := seen[alg.String()]; dup { + continue + } + + seen[alg.String()] = struct{}{} + algs = append(algs, alg.String()) + } + + return algs } // protectedResourceMetadataOp serves RFC 9728 OAuth 2.0 Protected Resource diff --git a/tests/integration/COMPLIANCE.md b/tests/integration/COMPLIANCE.md index 1ddaf6f0..21b9d4dc 100644 --- a/tests/integration/COMPLIANCE.md +++ b/tests/integration/COMPLIANCE.md @@ -43,9 +43,16 @@ Add one when introducing a feature that implements a spec the project advertises | RFC 9396 (Rich Authorization Requests) | `rar_compliance_test.go` | Covered | | RFC 9449 (DPoP) | `dpop_compliance_test.go` | Covered | | OpenID CIBA Core 1.0 | `ciba_compliance_test.go` | Covered | +| OpenID Connect Discovery 1.0 | `oidc_discovery_compliance_test.go` | Partial — metadata only, see note | | SPIFFE ID + JWT-SVID | `spiffe_compliance_test.go` | Covered | | OpenID SSF / CAEP | `cae_test.go` (behavioral) | Partial — see note | +### OpenID Connect Discovery 1.0 scope note + +ZeroID serves `/.well-known/openid-configuration` for **federation trust establishment**, not for OIDC login: it is the path AWS IAM OIDC identity providers, Azure federated identity credentials and GCP workload identity pools all fetch to discover an external issuer's keyset, and none of them read RFC 8414's path. ZeroID is **not** an OpenID Provider — it issues no `id_token` — so only the §3 metadata clauses and the §4 retrieval clauses are in scope. The §5 UserInfo, §2 issuer-discovery-via-WebFinger, and every ID Token clause are deliberately out of scope and untested. + +The suite therefore carries an unusual test — `TestOIDCDiscovery_NeverAdvertisesIDToken` — asserting the *absence* of a capability. That is the guard keeping this document honest while ZeroID publishes OIDC metadata it cannot fully back. When `id_token` issuance lands, that test is the one that must be deleted, in the same PR that makes the claim true. + ### OpenID SSF / CAEP scope note ZeroID consumes CAE-style signals via `POST /signals/ingest` and propagates them across delegation chains. The signal schema is ZeroID-local (`signal_type` is a freeform string), NOT strict OpenID SSF/CAEP (which formalize event-type URIs like `https://schemas.openid.net/secevent/caep/event-type/session-revoked`). The behavioral contract — severity-driven revocation, cascade to delegation children — is covered by `cae_test.go`'s 6 happy-path tests. Strict SSF Stream Configuration and Stream Status endpoints (per OpenID SSF §7) aren't implemented; the README's SSF / CAEP entry describes the signal-shape inspiration rather than full stream-protocol conformance. A dedicated SSF/CAEP compliance suite is deferred until those endpoints land. diff --git a/tests/integration/oidc_discovery_compliance_test.go b/tests/integration/oidc_discovery_compliance_test.go new file mode 100644 index 00000000..c1e02ef3 --- /dev/null +++ b/tests/integration/oidc_discovery_compliance_test.go @@ -0,0 +1,180 @@ +// OpenID Connect Discovery 1.0 compliance suite. +// +// See COMPLIANCE.md for the conventions this file follows. +// +// ZeroID serves /.well-known/openid-configuration for FEDERATION TRUST +// ESTABLISHMENT, not for OIDC login — AWS IAM OIDC identity providers, Azure +// federated identity credentials and GCP workload identity pools all discover +// an external issuer's keyset through this path and not through RFC 8414's. +// See internal/handler/wellknown.go openidConfigurationOp. +// +// That purpose is why the ZeroID-policy tests at the bottom of this file matter +// as much as the spec clauses above them: a document that parses but names the +// wrong keys, or a document that quietly starts implying id_token issuance ZeroID +// does not do, both fail the reason the endpoint exists. + +package integration_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fetchOpenIDConfiguration returns the parsed JSON body of +// /.well-known/openid-configuration. +func fetchOpenIDConfiguration(t *testing.T) map[string]any { + t.Helper() + resp := get(t, "/.well-known/openid-configuration", nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + + return decode(t, resp) +} + +// ── OIDC Discovery 1.0 §4 — Obtaining Provider Configuration Information ──── + +func TestOIDCDiscovery1_0_S4_WellKnownPathIsExact(t *testing.T) { + // OIDC Discovery 1.0 §4: "openid-configuration MUST be the final path + // component" of the configuration information location, formed by + // concatenating /.well-known/openid-configuration to the Issuer. + // The path is the whole contract here: every cloud federation consumer + // hardcodes it, so serving the same document at any other path is + // indistinguishable from not serving it at all. + resp := get(t, "/.well-known/openid-configuration", nil) + assert.Equal(t, http.StatusOK, resp.StatusCode, + "GET /.well-known/openid-configuration MUST return 200") +} + +func TestOIDCDiscovery1_0_S4_3_IssuerMatchesDiscoveryPrefix(t *testing.T) { + // OIDC Discovery 1.0 §4.3: "The issuer value returned MUST be identical to + // the Issuer URL that was used as the prefix to /.well-known/ + // openid-configuration to retrieve the configuration information." + // AWS and Azure both re-derive the issuer from the URL they fetched and + // refuse the provider when it disagrees, so a mismatch here is not a + // pedantic failure — it is a federation that cannot be configured. + body := fetchOpenIDConfiguration(t) + assert.Equal(t, testIssuer, body["issuer"], + "issuer MUST equal the prefix the document was fetched from") +} + +// ── OIDC Discovery 1.0 §3 — OpenID Provider Metadata ─────────────────────── + +func TestOIDCDiscovery1_0_S3_SubjectTypesSupportedRequired(t *testing.T) { + // OIDC Discovery 1.0 §3: "subject_types_supported REQUIRED. JSON array + // containing a list of the Subject Identifier types that this OP + // supports." + body := fetchOpenIDConfiguration(t) + subjectTypes, ok := body["subject_types_supported"].([]any) + require.True(t, ok, "subject_types_supported REQUIRED") + assert.Equal(t, []any{"public"}, subjectTypes, + "ZeroID `sub` is the stable identity ID, identical for every verifier — "+ + "a pairwise subject would break the cloud role trust policies that "+ + "pin a literal `sub` value") +} + +func TestOIDCDiscovery1_0_S3_IDTokenSigningAlgValuesSupportedRequired(t *testing.T) { + // OIDC Discovery 1.0 §3: "id_token_signing_alg_values_supported REQUIRED. + // JSON array containing a list of the JWS signing algorithms (alg values) + // supported by the OP for the ID Token." + body := fetchOpenIDConfiguration(t) + algs, ok := body["id_token_signing_alg_values_supported"].([]any) + require.True(t, ok, "id_token_signing_alg_values_supported REQUIRED") + assert.NotEmpty(t, algs, + "an issuer that signs nothing cannot be federated to — the member is "+ + "derived from the live keyset, so an empty list means the keyset is empty") +} + +// ── ZeroID policy — why this endpoint exists ─────────────────────────────── + +func TestOIDCDiscovery_SigningAlgsMatchPublishedJWKS(t *testing.T) { + // Not a spec clause. A federation verifier reads the alg list here and the + // keys from jwks_uri, then rejects any token whose alg is not in the list. + // If the two ever disagree the failure surfaces at token-verification time + // in someone else's cloud, which is the most expensive place to debug it. + // Deriving the list from the keyset is what prevents that; this test is what + // keeps it derived. + body := fetchOpenIDConfiguration(t) + + advertised := map[string]bool{} + algs, _ := body["id_token_signing_alg_values_supported"].([]any) + + for _, a := range algs { + alg, _ := a.(string) + assert.NotEmpty(t, alg, "an empty alg name is not a JWA algorithm") + advertised[alg] = true + } + + published := map[string]bool{} + + for _, key := range fetchJWKSKeys(t) { + if alg, ok := key["alg"].(string); ok && alg != "" { + published[alg] = true + } + } + + require.NotEmpty(t, published, "the published keyset must name its algorithms") + assert.Equal(t, published, advertised, + "the advertised alg list MUST be exactly the algs in /.well-known/jwks.json") +} + +func TestOIDCDiscovery_NeverAdvertisesIDToken(t *testing.T) { + // Not a spec clause — the inverse. ZeroID issues no id_token, and this + // document existing must not become evidence that it does. response_types_ + // supported is the structural guard: a relying party cannot request an + // id_token without asking for one here, so as long as no id_token response + // type is advertised, publishing OIDC metadata promises nothing ZeroID + // cannot deliver. + // + // When id_token issuance lands, this test is the one that should fail — and + // it should be deleted in the same PR that makes the claim true, not before. + body := fetchOpenIDConfiguration(t) + + responseTypes, _ := body["response_types_supported"].([]any) + for _, rt := range responseTypes { + assert.NotContains(t, rt, "id_token", + "ZeroID issues no id_token; advertising a response type that "+ + "requests one is a promise it cannot keep") + } + + scopes, _ := body["scopes_supported"].([]any) + assert.NotContains(t, scopes, "openid", + "the openid scope is what requests an id_token (OIDC Core §3.1.2.1) — "+ + "advertising it would be the same broken promise") +} + +func TestOIDCDiscovery_IsSupersetOfASMetadata(t *testing.T) { + // Not a spec clause. ZeroID is one authorization server described by two + // discovery documents, and RFC 8414 §5 permits exactly that. Two + // hand-maintained documents is how they drift, so the OIDC document is + // built from the RFC 8414 one plus the members OIDC additionally requires. + // This pins that construction: every RFC 8414 member must appear here, + // identically. + asMeta := fetchASMetadata(t) + oidcMeta := fetchOpenIDConfiguration(t) + + for member, want := range asMeta { + got, present := oidcMeta[member] + require.True(t, present, + "%q is in the RFC 8414 document but missing from the OIDC document — "+ + "the two have drifted", member) + assert.Equal(t, want, got, + "%q disagrees between the two discovery documents for one AS", member) + } +} + +func TestOIDCDiscovery_OIDCOnlyMembersStayOutOfASMetadata(t *testing.T) { + // Not a spec clause. The OIDC document is built by adding members to the + // RFC 8414 document, so a shared or cached base map would let those + // additions leak backwards into the RFC 8414 document — silently, and only + // after the OIDC endpoint had been hit once. Fetch in that order, then + // assert the leak did not happen. + _ = fetchOpenIDConfiguration(t) + + asMeta := fetchASMetadata(t) + assert.NotContains(t, asMeta, "subject_types_supported", + "OIDC-only member leaked into the RFC 8414 document — the base map is shared") + assert.NotContains(t, asMeta, "id_token_signing_alg_values_supported", + "OIDC-only member leaked into the RFC 8414 document — the base map is shared") +}