Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
44 changes: 38 additions & 6 deletions docs/spec/zeroid-oauth-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1175,17 +1207,17 @@ 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

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

Expand Down
119 changes: 118 additions & 1 deletion internal/handler/wellknown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Comment thread
saucam marked this conversation as resolved.
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
Expand Down
7 changes: 7 additions & 0 deletions tests/integration/COMPLIANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading