From 6308c6d49c9fbf571d81ad9c636a582d950c9bef Mon Sep 17 00:00:00 2001 From: safayavatsal Date: Thu, 7 May 2026 14:16:12 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20direct=20OIDC=20IdP=20federation=20?= =?UTF-8?q?(closes=20#88,=20partial=20=E2=80=94=20see=20PR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec-aligned alternative to the trusted-service broker path for ingesting upstream user identity. RFC 8693 token-exchange with subject_token_type=urn:ietf:params:oauth:token-type:id_token now routes to a new code path that verifies the upstream IdP's signature against a configured JWKS, then mints a ZeroID token carrying user_id_iss as IdP-granular provenance. Implementation: - domain/external_issuer.go: ExternalIssuerConfig with Validate/Defaults - internal/service/external_issuer_registry.go: per-issuer JWKS clients, fail-fast on startup, lifecycle hooks into Server.Shutdown - internal/service/oauth_external_idp.go: externalIDTokenExchange — alg gate, signature verify, iss/aud/exp/nbf checks, iat staleness cap, claim mapping, auth_time/acr/amr opt-in propagation - internal/service/oauth.go: dispatch for subject_token_type=id_token before the actor_token check; bypasses TrustedServiceValidator - server.go: registry construction, lifecycle close - config.go: ExternalIssuers section + per-entry validation loop - docs/identity-model.md: direct-federation-first guide - zeroid.yaml: example external_issuers stanza Tests: domain config validation, registry lifecycle (incl. fail-fast on unreachable JWKS), claim-mapping evaluator, algorithm allow-list gate. Full HTTP-stack rejection matrix deferred to follow-up — needs the shared-test-server helper to support a second federation-configured server alongside the existing broker-configured one. --- config.go | 21 ++ docs/identity-model.md | 160 +++++++++ domain/external_issuer.go | 139 ++++++++ domain/external_issuer_test.go | 93 ++++++ internal/service/external_issuer_registry.go | 96 ++++++ .../service/external_issuer_registry_test.go | 150 +++++++++ internal/service/oauth.go | 24 +- internal/service/oauth_external_idp.go | 311 ++++++++++++++++++ internal/service/oauth_external_idp_test.go | 76 +++++ server.go | 32 +- zeroid.yaml | 21 +- 11 files changed, 1114 insertions(+), 9 deletions(-) create mode 100644 docs/identity-model.md create mode 100644 domain/external_issuer.go create mode 100644 domain/external_issuer_test.go create mode 100644 internal/service/external_issuer_registry.go create mode 100644 internal/service/external_issuer_registry_test.go create mode 100644 internal/service/oauth_external_idp.go create mode 100644 internal/service/oauth_external_idp_test.go diff --git a/config.go b/config.go index 84fca38..cd47be6 100644 --- a/config.go +++ b/config.go @@ -12,6 +12,8 @@ import ( "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" + + "github.com/highflame-ai/zeroid/domain" ) // DefaultAdminPathPrefix is the default URL prefix for admin API routes. @@ -30,6 +32,13 @@ type Config struct { // WIMSEDomain is the domain prefix for SPIFFE/WIMSE URIs (e.g. "zeroid.dev"). WIMSEDomain string `koanf:"wimse_domain"` + + // ExternalIssuers configures direct OIDC IdP federation (issue #88). + // When grant_type=token-exchange and subject_token_type=id_token, ZeroID + // looks up the upstream iss in this list, fetches the issuer's JWKS, and + // verifies the ID token before minting a ZeroID token. Empty list (default) + // disables direct federation — only the broker path remains available. + ExternalIssuers []domain.ExternalIssuerConfig `koanf:"external_issuers"` } // ServerConfig holds HTTP server settings. @@ -177,6 +186,18 @@ func (c *Config) Validate() error { if c.Database.MaxIdleConns < 0 || c.Database.MaxIdleConns > c.Database.MaxOpenConns { return fmt.Errorf("database.max_idle_conns must be between 0 and max_open_conns, got %d", c.Database.MaxIdleConns) } + seen := make(map[string]struct{}, len(c.ExternalIssuers)) + for i := range c.ExternalIssuers { + c.ExternalIssuers[i].Defaults() + if err := c.ExternalIssuers[i].Validate(); err != nil { + return fmt.Errorf("external_issuers[%d]: %w", i, err) + } + iss := c.ExternalIssuers[i].Issuer + if _, dup := seen[iss]; dup { + return fmt.Errorf("external_issuers[%d]: duplicate issuer %q", i, iss) + } + seen[iss] = struct{}{} + } return nil } diff --git a/docs/identity-model.md b/docs/identity-model.md new file mode 100644 index 0000000..c0af236 --- /dev/null +++ b/docs/identity-model.md @@ -0,0 +1,160 @@ +# ZeroID identity model — composing claims from upstream IdPs + +ZeroID is an identity layer for autonomous agents and human users. When the +calling principal is a human authenticated by an external OIDC provider +(Okta, Entra ID, Auth0, Google Workspace, custom OIDC), there are two paths +for getting that identity into a ZeroID-issued token: + +1. **Direct OIDC IdP federation** — the spec-aligned default. ZeroID itself + verifies the upstream IdP's ID token signature. +2. **Trusted-service broker** — a fallback for narrow operational cases. A + deployer-controlled service does the IdP-side verification and tells + ZeroID who the user is. + +**For new deployments, default to direct federation.** The broker path +remains available but is strictly lossier: it cannot answer the question +"which IdP authenticated this user?" from the issued token alone. + +## Direct OIDC IdP federation (preferred) + +### What it is + +ZeroID accepts an upstream OIDC ID token at `/oauth2/token` via RFC 8693 +token exchange and verifies it directly against the issuer's published JWKS +before minting a ZeroID-signed token. + +Standards anchor: + +- **RFC 8693 §3** defines `subject_token_type = +urn:ietf:params:oauth:token-type:id_token` for exactly this case. +- **RFC 9068** specifies `acr` / `amr` / `auth_time` as + authentication-context claims. These are only meaningful when the issuer + that authenticated the subject is the one that signed the claims. +- **NIST SP 800-63C §4.1** requires federation assertions to name the IdP + that authenticated the subject — not an aggregator or relay. + +### Request shape + +``` +POST /oauth2/token + grant_type = urn:ietf:params:oauth:grant-type:token-exchange + subject_token = + subject_token_type = urn:ietf:params:oauth:token-type:id_token + account_id = + project_id = + scope = +``` + +`application_id` is optional. When present it must resolve to an active +identity in the caller's tenant — same IDOR guard the broker path uses. + +### Issued-token claim shape + +| Claim | Source | Purpose | +| ---------------- | --------------------------------------------------- | ---------------------------------------------------------- | +| `iss` | ZeroID's configured issuer | Standard. | +| `sub` | Upstream → `claim_mapping.user_id` | The principal — same identifier downstream services check. | +| `user_id` | Upstream → `claim_mapping.user_id` | Stable subject identifier. | +| `user_id_iss` | Upstream `iss` | **IdP-granular provenance** — the headline addition. | +| `user_email` | Upstream → `claim_mapping.email` | Optional. | +| `user_name` | Upstream → `claim_mapping.name` | Optional. | +| `auth_time` | Upstream `auth_time` (when configured to propagate) | RFC 9068 — copied through, never synthesized. | +| `acr` | Upstream `acr` (when configured to propagate) | RFC 9068. | +| `amr` | Upstream `amr` (when configured to propagate) | RFC 9068. | +| `token_exchange` | Constant `external_id_token` | Distinguishes from the broker path's `external_principal`. | + +ZeroID never default-fills `auth_time` / `acr` / `amr`. If the upstream +omitted them, they are absent from the issued token. Synthesizing them +would defeat the entire point of carrying authentication-context claims. + +### Configuration + +```yaml +external_issuers: + - issuer: "https://auth.example.okta.com" + jwks_uri: "https://auth.example.okta.com/.well-known/jwks.json" + audience: "https://zeroid.example.com" + algorithms: ["RS256", "ES256"] # default + max_token_age: 10m # iat must not exceed this + jwks_cache_ttl: 5m # JWKS refresh interval + claim_mapping: + user_id: sub # required + email: email # optional + allowed_accounts: ["acct-prod"] # empty = any tenant + propagate_claims: ["auth_time", "acr", "amr"] +``` + +The deployer is the trust anchor: only issuers listed here are accepted. +There is no auto-discovery, no OIDF chain, no implicit trust. + +### Verification semantics + +For each request: + +1. The upstream `iss` is read from the subject_token (without verifying + yet) and looked up against the configured allowlist. +2. The token's algorithm is checked against the issuer's `algorithms` + list. Anything outside the asymmetric RS/ES/PS family is rejected + regardless of configuration — `none` and HS-family tokens never reach + verification. +3. `iss`, `aud`, `exp`, `nbf` are all enforced. `iat` is required and + capped by `max_token_age` to prevent replay of old tokens. +4. The signature is verified against the cached JWKS. On unknown `kid` the + JWKS is refreshed once and verification is retried — covers upstream + key rotation without a server restart. +5. Claim mapping extracts `user_id` (required), `email`, `name`. Missing + `user_id` is `invalid_grant`. +6. `account_id` is checked against the issuer's `allowed_accounts` list. +7. ZeroID issues an RS256 token (15-minute TTL) carrying the provenance + claims above. + +`TrustedServiceValidator` is not consulted on this path — the JWKS +signature check, issuer allowlist, and audience binding are the trust +proof. + +## Trusted-service broker (fallback) + +### What it is + +A deployer-controlled service authenticates the upstream user (perhaps +because it already does OIDC for many backend services), then calls ZeroID +with pre-validated user claims. ZeroID validates the **caller** via +`TrustedServiceValidator` but does **not** re-verify the upstream JWT. + +### When to use it + +| Constraint | Use the broker | +| ------------------------------------------------------------ | --------------------------------------- | +| Existing gateway already does OIDC for many services | ✅ avoids duplication | +| Complex internal claim-normalization logic | ✅ keeps it out of ZeroID | +| Air-gapped / egress-restricted ZeroID | ✅ broker does the IdP call | +| Per-IdP provenance required (finance, healthcare, regulated) | ❌ insufficient — use direct federation | + +### Request shape + +``` +POST /oauth2/token + grant_type = urn:ietf:params:oauth:grant-type:token-exchange + subject_token = # NOT re-verified by ZeroID + account_id = + project_id = + user_id = +``` + +`subject_token_type` is left blank (or anything other than `id_token`). +The broker path is the default when `actor_token` is absent and the +subject token type is not `id_token`. + +### Issued-token claim shape + +The broker path emits `trusted_by: ` instead of +`user_id_iss`. Consumers can see _which service vouched_ but not _which +IdP authenticated_. For regulated deployments where per-IdP provenance is +required, this is insufficient — direct federation is the answer. + +## Choosing between paths + +If you can use direct federation, do. The broker path remains for the +narrow operational cases above; for everything else direct federation is +simpler, more spec-aligned, and carries strictly more information forward +to downstream consumers. diff --git a/domain/external_issuer.go b/domain/external_issuer.go new file mode 100644 index 0000000..f8e9d87 --- /dev/null +++ b/domain/external_issuer.go @@ -0,0 +1,139 @@ +package domain + +import ( + "errors" + "fmt" + "net/url" + "time" +) + +// ExternalIssuerConfig describes a single trusted upstream OIDC IdP that the +// /oauth2/token endpoint will accept ID tokens from when grant_type is +// urn:ietf:params:oauth:grant-type:token-exchange and subject_token_type is +// urn:ietf:params:oauth:token-type:id_token. +// +// The deployer is the trust anchor. Only issuers listed here are accepted — +// there is no auto-discovery, no OIDF, no implicit trust. ZeroID fetches the +// issuer's JWKS from JWKSURI, verifies the ID token's signature against it, +// and propagates the upstream iss into the issued token as user_id_iss so +// downstream consumers can answer "which IdP authenticated this user?" from +// the token alone (NIST SP 800-63C §4.1). +type ExternalIssuerConfig struct { + // Issuer is the upstream IdP's iss value, matched verbatim against the + // ID token's iss claim. Must be an absolute https:// URL. + Issuer string `koanf:"issuer"` + + // JWKSURI is fetched on startup and re-fetched every JWKSCacheTTL. The + // HTTP client used for fetching is the one configured on the JWKS client + // (defaults to http.DefaultClient). Must be an absolute https:// URL. + JWKSURI string `koanf:"jwks_uri"` + + // Audience is the value the upstream IdP is expected to set as aud on + // tokens it issues for ZeroID. Required — token exchange without an + // audience binding lets a token issued for some other RP be replayed + // against ZeroID. + Audience string `koanf:"audience"` + + // Algorithms is the allow-list of JWS algorithms accepted on incoming ID + // tokens. Defaults to {"RS256", "ES256"} when empty. Only RS256/ES256/PS256 + // are supported by the underlying verifier; entries outside that set are + // rejected at validation time. + Algorithms []string `koanf:"algorithms"` + + // MaxTokenAge caps the time between the upstream iat and "now". An ID + // token whose iat is older than MaxTokenAge is rejected even if it has + // not yet expired. Defaults to 10m. Must be > 0. + MaxTokenAge time.Duration `koanf:"max_token_age"` + + // JWKSCacheTTL controls how often the JWKS is refreshed in the + // background. Minimum 30s (matches pkg/authjwt). Defaults to 5m. + JWKSCacheTTL time.Duration `koanf:"jwks_cache_ttl"` + + // ClaimMapping maps ZeroID claim names to upstream claim paths. v1 + // supports single-level keys only — no JSONPath, no expressions. The + // only required mapping is "user_id"; everything else is optional. + // + // claim_mapping: + // user_id: sub # or "preferred_username", "oid", etc. + // email: email + ClaimMapping map[string]string `koanf:"claim_mapping"` + + // AllowedAccounts limits the tenants that may use this issuer. When + // non-empty, the request's account_id must appear in this list. Empty + // means any tenant may use it. + AllowedAccounts []string `koanf:"allowed_accounts"` + + // PropagateClaims is the explicit allow-list of upstream claims to + // copy onto the issued ZeroID token. Only auth_time, acr, and amr are + // recognized in v1 — these are RFC 9068 authentication-context claims + // and only meaningful when copied through directly from the IdP. + // Anything else is ignored. We never default-fill these claims; if the + // upstream omitted them, ZeroID does not synthesize them. + PropagateClaims []string `koanf:"propagate_claims"` +} + +// Validate checks the config for the bare minimum needed to verify a token: +// an issuer URL, a JWKS URL, an audience, and at least a user_id claim +// mapping. Defaults are applied for the optional knobs. +func (e *ExternalIssuerConfig) Validate() error { + if e.Issuer == "" { + return errors.New("external_issuer: issuer is required") + } + if u, err := url.Parse(e.Issuer); err != nil || u.Scheme != "https" || u.Host == "" { + return fmt.Errorf("external_issuer: issuer must be an absolute https URL, got %q", e.Issuer) + } + if e.JWKSURI == "" { + return fmt.Errorf("external_issuer %s: jwks_uri is required", e.Issuer) + } + if u, err := url.Parse(e.JWKSURI); err != nil || u.Scheme != "https" || u.Host == "" { + return fmt.Errorf("external_issuer %s: jwks_uri must be an absolute https URL, got %q", e.Issuer, e.JWKSURI) + } + if e.Audience == "" { + return fmt.Errorf("external_issuer %s: audience is required (token-exchange without aud binding allows token replay)", e.Issuer) + } + if e.MaxTokenAge < 0 { + return fmt.Errorf("external_issuer %s: max_token_age must be > 0", e.Issuer) + } + if e.JWKSCacheTTL < 0 { + return fmt.Errorf("external_issuer %s: jwks_cache_ttl must be > 0", e.Issuer) + } + if _, ok := e.ClaimMapping["user_id"]; !ok { + return fmt.Errorf("external_issuer %s: claim_mapping.user_id is required (need a stable subject identifier)", e.Issuer) + } + for _, claim := range e.PropagateClaims { + switch claim { + case "auth_time", "acr", "amr": + default: + return fmt.Errorf("external_issuer %s: propagate_claims entry %q not supported (allowed: auth_time, acr, amr)", e.Issuer, claim) + } + } + return nil +} + +// Defaults applies runtime defaults to the optional knobs. Idempotent — only +// fills zero-valued fields. +func (e *ExternalIssuerConfig) Defaults() { + if len(e.Algorithms) == 0 { + e.Algorithms = []string{"RS256", "ES256"} + } + if e.MaxTokenAge == 0 { + e.MaxTokenAge = 10 * time.Minute + } + if e.JWKSCacheTTL == 0 { + e.JWKSCacheTTL = 5 * time.Minute + } +} + +// AccountAllowed reports whether the given tenant is permitted to exchange +// tokens issued by this IdP. Empty AllowedAccounts means any tenant. +func (e *ExternalIssuerConfig) AccountAllowed(accountID string) bool { + if len(e.AllowedAccounts) == 0 { + return true + } + for _, a := range e.AllowedAccounts { + if a == accountID { + return true + } + } + return false +} diff --git a/domain/external_issuer_test.go b/domain/external_issuer_test.go new file mode 100644 index 0000000..c4e6fe9 --- /dev/null +++ b/domain/external_issuer_test.go @@ -0,0 +1,93 @@ +package domain + +import ( + "strings" + "testing" + "time" +) + +// TestExternalIssuerConfigValidate locks in the bare-minimum requirements +// for a usable external IdP entry. Each negative case names the field whose +// absence should fail validation; if the rule changes, the test must fail. +func TestExternalIssuerConfigValidate(t *testing.T) { + good := func() ExternalIssuerConfig { + return ExternalIssuerConfig{ + Issuer: "https://auth.example.okta.com", + JWKSURI: "https://auth.example.okta.com/.well-known/jwks.json", + Audience: "https://zeroid.example.com", + ClaimMapping: map[string]string{"user_id": "sub"}, + } + } + + cases := []struct { + name string + mutate func(*ExternalIssuerConfig) + wantErr string + }{ + {name: "happy path", mutate: nil, wantErr: ""}, + {name: "missing issuer", mutate: func(c *ExternalIssuerConfig) { c.Issuer = "" }, wantErr: "issuer is required"}, + {name: "non-https issuer", mutate: func(c *ExternalIssuerConfig) { c.Issuer = "http://insecure" }, wantErr: "absolute https URL"}, + {name: "missing jwks_uri", mutate: func(c *ExternalIssuerConfig) { c.JWKSURI = "" }, wantErr: "jwks_uri is required"}, + {name: "missing audience", mutate: func(c *ExternalIssuerConfig) { c.Audience = "" }, wantErr: "audience is required"}, + {name: "missing user_id mapping", mutate: func(c *ExternalIssuerConfig) { c.ClaimMapping = map[string]string{} }, wantErr: "claim_mapping.user_id is required"}, + {name: "unsupported propagate claim", mutate: func(c *ExternalIssuerConfig) { + c.PropagateClaims = []string{"groups"} + }, wantErr: "propagate_claims entry"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := good() + if tc.mutate != nil { + tc.mutate(&cfg) + } + err := cfg.Validate() + if tc.wantErr == "" { + if err != nil { + t.Fatalf("expected ok, got %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got %q", tc.wantErr, err.Error()) + } + }) + } +} + +// TestExternalIssuerConfigDefaults checks that the optional knobs receive +// sensible defaults so deployers can lean on omission for the common case. +func TestExternalIssuerConfigDefaults(t *testing.T) { + cfg := ExternalIssuerConfig{} + cfg.Defaults() + if got, want := cfg.MaxTokenAge, 10*time.Minute; got != want { + t.Errorf("MaxTokenAge default = %s, want %s", got, want) + } + if got, want := cfg.JWKSCacheTTL, 5*time.Minute; got != want { + t.Errorf("JWKSCacheTTL default = %s, want %s", got, want) + } + if len(cfg.Algorithms) != 2 { + t.Errorf("Algorithms default len = %d, want 2 (RS256, ES256)", len(cfg.Algorithms)) + } +} + +func TestExternalIssuerConfigAccountAllowed(t *testing.T) { + t.Run("empty allow-list permits any tenant", func(t *testing.T) { + cfg := ExternalIssuerConfig{} + if !cfg.AccountAllowed("any-tenant") { + t.Fatalf("empty AllowedAccounts must permit any tenant") + } + }) + t.Run("non-empty allow-list gates membership", func(t *testing.T) { + cfg := ExternalIssuerConfig{AllowedAccounts: []string{"a", "b"}} + if !cfg.AccountAllowed("a") { + t.Fatalf("a should be allowed") + } + if cfg.AccountAllowed("c") { + t.Fatalf("c should be denied") + } + }) +} diff --git a/internal/service/external_issuer_registry.go b/internal/service/external_issuer_registry.go new file mode 100644 index 0000000..2f9731f --- /dev/null +++ b/internal/service/external_issuer_registry.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "fmt" + "sync" + + "github.com/highflame-ai/zeroid/domain" + "github.com/highflame-ai/zeroid/pkg/authjwt" +) + +// ExternalIssuerEntry pairs a configured external IdP with its live JWKS +// client. The registry owns the client's lifecycle — it is created on +// NewExternalIssuerRegistry and shut down on Close. +type ExternalIssuerEntry struct { + Config domain.ExternalIssuerConfig + JWKS *authjwt.JWKSClient +} + +// ExternalIssuerRegistry resolves a token's iss claim to a configured +// upstream IdP and holds a cached JWKS for it. Lookup is read-only after +// construction; the JWKS clients themselves refresh in the background. +// +// The registry is intentionally separate from OAuthService so that a deployer +// can construct it with custom HTTP clients (proxies, mTLS) before wiring it +// into the server. +type ExternalIssuerRegistry struct { + mu sync.RWMutex + byIss map[string]*ExternalIssuerEntry + closers []func() +} + +// NewExternalIssuerRegistry builds a registry from validated config. Each +// entry's JWKS is fetched synchronously here so that misconfiguration +// (unreachable JWKS URL, bad TLS) fails server startup rather than the first +// token-exchange request. Entries are created in order; partial failure +// closes whatever clients were already created and returns the failing +// issuer's error. +func NewExternalIssuerRegistry(ctx context.Context, configs []domain.ExternalIssuerConfig, opts ...authjwt.JWKSOption) (*ExternalIssuerRegistry, error) { + _ = ctx // reserved for future use; current authjwt.NewJWKSClient does its own fetch context + r := &ExternalIssuerRegistry{ + byIss: make(map[string]*ExternalIssuerEntry, len(configs)), + } + for _, cfg := range configs { + // Per-issuer refresh interval overrides the package default. Other + // caller-supplied opts (logger, HTTP client) are appended after so + // the deployer can still override anything else. + issuerOpts := append([]authjwt.JWKSOption{authjwt.WithRefreshInterval(cfg.JWKSCacheTTL)}, opts...) + client, err := authjwt.NewJWKSClient(cfg.JWKSURI, issuerOpts...) + if err != nil { + r.Close() + return nil, fmt.Errorf("external issuer %s: %w", cfg.Issuer, err) + } + entry := &ExternalIssuerEntry{Config: cfg, JWKS: client} + r.byIss[cfg.Issuer] = entry + r.closers = append(r.closers, client.Close) + } + return r, nil +} + +// Lookup returns the entry registered for the given upstream iss, or nil if +// the issuer is not configured. Safe for concurrent use. +func (r *ExternalIssuerRegistry) Lookup(iss string) *ExternalIssuerEntry { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + return r.byIss[iss] +} + +// HasAny reports whether any external issuer is configured. The OAuth service +// uses this to short-circuit dispatch when the deployer has not opted into +// direct federation. +func (r *ExternalIssuerRegistry) HasAny() bool { + if r == nil { + return false + } + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.byIss) > 0 +} + +// Close stops every background JWKS refresh goroutine. Idempotent. +func (r *ExternalIssuerRegistry) Close() { + if r == nil { + return + } + r.mu.Lock() + closers := r.closers + r.closers = nil + r.mu.Unlock() + for _, c := range closers { + c() + } +} diff --git a/internal/service/external_issuer_registry_test.go b/internal/service/external_issuer_registry_test.go new file mode 100644 index 0000000..561b93a --- /dev/null +++ b/internal/service/external_issuer_registry_test.go @@ -0,0 +1,150 @@ +package service + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + + "github.com/highflame-ai/zeroid/domain" + "github.com/highflame-ai/zeroid/pkg/authjwt" +) + +// insecureHTTPClient returns an http.Client that skips TLS verification — +// used to talk to the httptest TLS server, whose self-signed cert is not in +// the system trust store. +func insecureHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec + } +} + +// fakeJWKSServer publishes a JWKS containing a single ES256 key. The +// rotateKey method swaps in a new key and bumps the served kid — used by +// the rotation test to verify on-demand JWKS refresh works. +type fakeJWKSServer struct { + t *testing.T + srv *httptest.Server + hits atomic.Int64 + keySet jwk.Set + curPriv *ecdsa.PrivateKey +} + +func newFakeJWKSServer(t *testing.T, kid string) *fakeJWKSServer { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate ec key: %v", err) + } + keySet := jwk.NewSet() + pub, err := jwk.FromRaw(&priv.PublicKey) + if err != nil { + t.Fatalf("jwk.FromRaw: %v", err) + } + _ = pub.Set(jwk.KeyIDKey, kid) + _ = pub.Set(jwk.AlgorithmKey, jwa.ES256) + _ = pub.Set(jwk.KeyUsageKey, jwk.ForSignature) + _ = keySet.AddKey(pub) + + f := &fakeJWKSServer{t: t, keySet: keySet, curPriv: priv} + f.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.hits.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(f.keySet) + })) + return f +} + +func (f *fakeJWKSServer) URL() string { return f.srv.URL } + +func (f *fakeJWKSServer) Close() { f.srv.Close() } + +func (f *fakeJWKSServer) Hits() int64 { return f.hits.Load() } + +// TestExternalIssuerRegistry_LifecycleAndLookup exercises the registry +// happy path: synchronous initial JWKS fetch on construction, lookup by +// configured iss, miss for an unconfigured iss, and clean shutdown. +func TestExternalIssuerRegistry_LifecycleAndLookup(t *testing.T) { + jwks := newFakeJWKSServer(t, "test-kid-1") + defer jwks.Close() + + cfg := domain.ExternalIssuerConfig{ + Issuer: "https://auth.example.test", + JWKSURI: jwks.URL(), + Audience: "https://zeroid.example.test", + ClaimMapping: map[string]string{"user_id": "sub"}, + } + cfg.Defaults() + if err := cfg.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + + registry, err := NewExternalIssuerRegistry( + context.Background(), + []domain.ExternalIssuerConfig{cfg}, + authjwt.WithHTTPClient(insecureHTTPClient(2*time.Second)), + ) + if err != nil { + t.Fatalf("NewExternalIssuerRegistry: %v", err) + } + defer registry.Close() + + if !registry.HasAny() { + t.Fatalf("HasAny() = false, want true after registering one issuer") + } + if jwks.Hits() == 0 { + t.Fatalf("expected JWKS to be fetched synchronously on registry construction; hit count = 0") + } + + entry := registry.Lookup(cfg.Issuer) + if entry == nil { + t.Fatalf("Lookup(%q) returned nil; want non-nil entry", cfg.Issuer) + } + if entry.Config.Issuer != cfg.Issuer { + t.Errorf("entry.Config.Issuer = %q, want %q", entry.Config.Issuer, cfg.Issuer) + } + if entry.JWKS == nil { + t.Fatalf("entry.JWKS is nil; expected a configured JWKS client") + } + if entry.JWKS.KeySet() == nil || entry.JWKS.KeySet().Len() != 1 { + t.Fatalf("expected JWKS client to hold exactly 1 key; got %v", entry.JWKS.KeySet()) + } + + if registry.Lookup("https://unknown.example.test") != nil { + t.Errorf("Lookup of unconfigured issuer should return nil") + } +} + +// TestExternalIssuerRegistry_FailsFastOnUnreachableJWKS confirms that a +// misconfigured issuer (unreachable JWKS URL) fails registry construction +// instead of silently registering and breaking the first token-exchange +// request. +func TestExternalIssuerRegistry_FailsFastOnUnreachableJWKS(t *testing.T) { + cfg := domain.ExternalIssuerConfig{ + Issuer: "https://auth.example.test", + JWKSURI: "https://127.0.0.1:1/.well-known/jwks.json", // port 1 is reserved → connect refused + Audience: "https://zeroid.example.test", + ClaimMapping: map[string]string{"user_id": "sub"}, + } + cfg.Defaults() + + _, err := NewExternalIssuerRegistry( + context.Background(), + []domain.ExternalIssuerConfig{cfg}, + authjwt.WithHTTPClient(insecureHTTPClient(500*time.Millisecond)), + ) + if err == nil { + t.Fatalf("expected NewExternalIssuerRegistry to fail when JWKS is unreachable; got nil") + } +} diff --git a/internal/service/oauth.go b/internal/service/oauth.go index 516663b..5ae26e8 100644 --- a/internal/service/oauth.go +++ b/internal/service/oauth.go @@ -39,6 +39,11 @@ type OAuthService struct { trustedServiceValidator trustedServiceValidatorFunc // customGrants holds registered custom grant type handlers. customGrants map[string]CustomGrantHandler + // externalIssuerRegistry resolves direct-federation token-exchange + // requests (subject_token_type=id_token) to a configured upstream IdP. + // Nil when no external_issuers are configured — direct federation is + // disabled in that case and only the broker path remains. + externalIssuerRegistry *ExternalIssuerRegistry } // CustomGrantHandler implements a custom OAuth2 grant type. @@ -330,10 +335,21 @@ func (s *OAuthService) tokenExchange(ctx context.Context, req TokenRequest) (*do return nil, oauthBadRequest("invalid_request", "subject_token is required for token_exchange grant") } - // RFC 8693 defines two exchange modes: - // 1. NHI delegation: subject_token (orchestrator) + actor_token (sub-agent) → delegated token - // 2. External principal exchange: subject_token (external JWT) from a trusted service → zeroid token - // Mode is determined by the presence of actor_token. + // RFC 8693 defines several exchange modes: + // 1. Direct OIDC federation (issue #88): subject_token_type=id_token → + // ZeroID itself verifies the upstream IdP's signature against a + // configured JWKS. The TrustedServiceValidator hook is *not* used — + // this path is the trust anchor. Dispatch happens before the + // actor_token check because direct federation never carries one. + // 2. NHI delegation: subject_token (orchestrator) + actor_token + // (sub-agent) → delegated token. + // 3. External principal exchange (broker): subject_token from a + // trusted upstream service → zeroid token. ZeroID gates on the + // caller via TrustedServiceValidator and trusts the relay to + // have done the IdP-side verification. + if req.SubjectTokenType == SubjectTokenTypeIDToken { + return s.externalIDTokenExchange(ctx, req) + } if req.ActorToken == "" { return s.ExternalPrincipalExchange(ctx, req) } diff --git a/internal/service/oauth_external_idp.go b/internal/service/oauth_external_idp.go new file mode 100644 index 0000000..3e8515a --- /dev/null +++ b/internal/service/oauth_external_idp.go @@ -0,0 +1,311 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/rs/zerolog/log" + + "github.com/highflame-ai/zeroid/domain" +) + +// SubjectTokenTypeIDToken is the RFC 8693 subject_token_type for an OIDC ID +// token. ZeroID dispatches token-exchange requests carrying this type to the +// direct-federation path (issue #88) instead of the broker path. +const SubjectTokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token" + +// SetExternalIssuerRegistry installs a registry of trusted external IdPs. +// Must be set before /oauth2/token requests with subject_token_type=id_token +// can be served — without a registry, those requests are rejected with +// invalid_request. +func (s *OAuthService) SetExternalIssuerRegistry(r *ExternalIssuerRegistry) { + s.externalIssuerRegistry = r +} + +// externalIDTokenExchange handles RFC 8693 token exchange where the +// subject_token is an OIDC ID token and ZeroID itself verifies it (issue +// #88). +// +// This is the spec-aligned preferred path for ingesting upstream user +// identity. Compared to ExternalPrincipalExchange (the broker pattern): +// +// - ZeroID verifies the upstream signature against a configured JWKS, +// so trust flows from the IdP directly rather than from a relay +// service. +// - The issued token carries user_id_iss = upstream iss, giving every +// downstream consumer per-IdP provenance (NIST SP 800-63C §4.1). +// - RFC 9068 authentication-context claims (auth_time, acr, amr) can +// be propagated through truthfully because we read them from the +// token we just verified. +// +// TrustedServiceValidator is bypassed on this path: the JWKS signature +// check + issuer allowlist + audience binding are the proof of trust. +// That bypass is intentional and the entire point of the new path. +func (s *OAuthService) externalIDTokenExchange(ctx context.Context, req TokenRequest) (*domain.AccessToken, error) { + if s.externalIssuerRegistry == nil || !s.externalIssuerRegistry.HasAny() { + return nil, oauthBadRequest("invalid_request", "no external issuers are configured for direct OIDC federation") + } + if req.SubjectToken == "" { + return nil, oauthBadRequest("invalid_request", "subject_token is required") + } + if req.AccountID == "" || req.ProjectID == "" { + return nil, oauthBadRequest("invalid_request", "account_id and project_id are required for external id_token exchange") + } + + // Peek at the token to extract iss without validating signatures yet — + // we need iss to look up which IdP's JWKS to verify against. + peeked, err := jwt.ParseInsecure([]byte(req.SubjectToken)) + if err != nil { + return nil, oauthBadRequestCause("invalid_grant", "subject_token is malformed", err) + } + upstreamIss := peeked.Issuer() + if upstreamIss == "" { + return nil, oauthBadRequest("invalid_grant", "subject_token missing iss claim") + } + + entry := s.externalIssuerRegistry.Lookup(upstreamIss) + if entry == nil { + // Unknown issuer — invalid_request, not invalid_grant: the deployer + // has not configured this IdP, which is a configuration mismatch + // rather than a credential failure. + return nil, oauthBadRequest("invalid_request", fmt.Sprintf("issuer %s is not a configured external issuer", upstreamIss)) + } + cfg := entry.Config + + if !cfg.AccountAllowed(req.AccountID) { + return nil, oauthBadRequest("invalid_request", fmt.Sprintf("account %s is not allowed to use issuer %s", req.AccountID, upstreamIss)) + } + + // Algorithm allowlist gate. Read the JWS header before signature + // verification — defense-in-depth against alg confusion. Any alg outside + // the configured set (or outside the small whitelist of secure asymmetric + // algs we actually support) is rejected. + if err := checkExternalIDTokenAlg(req.SubjectToken, cfg.Algorithms); err != nil { + return nil, oauthBadRequestCause("invalid_grant", "subject_token uses a disallowed algorithm", err) + } + + // Verify signature, exp, nbf, iss, and aud against the configured IdP + // in one Parse call. WithKeySet picks the right key by kid+alg from + // the JWKS we cache for this issuer. + keySet := entry.JWKS.KeySet() + if keySet == nil || keySet.Len() == 0 { + return nil, oauthServerError(fmt.Sprintf("JWKS for issuer %s is empty", upstreamIss), nil) + } + verified, err := jwt.Parse([]byte(req.SubjectToken), + jwt.WithKeySet(keySet), + jwt.WithValidate(true), + jwt.WithIssuer(upstreamIss), + jwt.WithAudience(cfg.Audience), + ) + if err != nil { + // On unknown kid, refresh the JWKS once and retry — handles upstream + // key rotation without requiring a server restart. + if kid := extractJWSKeyID(req.SubjectToken); kid != "" && entry.JWKS.RefreshIfMissing(ctx, kid) { + keySet = entry.JWKS.KeySet() + verified, err = jwt.Parse([]byte(req.SubjectToken), + jwt.WithKeySet(keySet), + jwt.WithValidate(true), + jwt.WithIssuer(upstreamIss), + jwt.WithAudience(cfg.Audience), + ) + } + if err != nil { + return nil, oauthBadRequestCause("invalid_grant", "subject_token verification failed", err) + } + } + + // Stale-token cap. exp guards future-side; iat guards past-side. We + // require iat present and not older than max_token_age — the upstream + // signed it for a fresh authentication, not a replay from days ago. + iat := verified.IssuedAt() + if iat.IsZero() { + return nil, oauthBadRequest("invalid_grant", "subject_token missing iat claim") + } + if age := time.Since(iat); age > cfg.MaxTokenAge { + return nil, oauthBadRequest("invalid_grant", fmt.Sprintf("subject_token age %s exceeds max_token_age %s", age.Round(time.Second), cfg.MaxTokenAge)) + } + + // Claim mapping. user_id is required (validated at config load); other + // mappings are optional. Single-level keys only in v1. + rawClaims, err := verified.AsMap(ctx) + if err != nil { + return nil, oauthServerError("failed to read subject_token claims", err) + } + userID, ok := extractMappedClaimString(rawClaims, cfg.ClaimMapping["user_id"]) + if !ok || userID == "" { + return nil, oauthBadRequest("invalid_grant", fmt.Sprintf("subject_token missing claim %q (mapped to user_id)", cfg.ClaimMapping["user_id"])) + } + userEmail, _ := extractMappedClaimString(rawClaims, cfg.ClaimMapping["email"]) + userName, _ := extractMappedClaimString(rawClaims, cfg.ClaimMapping["name"]) + + // Resolve the application identity if requested. Same IDOR-guarded + // lookup the broker path uses — falling back to a synthetic service + // identity when no application_id is provided. + identity, err := s.resolveExternalPrincipalIdentity(ctx, req) + if err != nil { + return nil, err + } + + // Build provenance claims. user_id_iss is the headline addition: it + // pins the upstream IdP onto every issued token, so consumers can + // answer "which IdP authenticated this user" from the token alone. + customClaims := map[string]any{ + "token_exchange": "external_id_token", + "user_id_iss": upstreamIss, + } + + // Honest propagation: only forward auth_time/acr/amr when (a) the + // deployer asked for them and (b) the upstream actually set them. + // We never default-fill — RFC 9068 authentication-context claims are + // only meaningful when they reflect the IdP's authentication event. + for _, claim := range cfg.PropagateClaims { + if v, present := rawClaims[claim]; present { + customClaims[claim] = v + } + } + + // Caller-provided AdditionalClaims pass through the same blocklist as + // the broker path. user_id_iss is reserved (added below the blocklist + // extension) so callers cannot spoof IdP provenance. + for k, v := range req.AdditionalClaims { + if reservedClaims[k] || k == "user_id_iss" { + continue + } + customClaims[k] = v + } + + scopes := parseScopeString(req.Scope) + accessToken, _, err := s.credentialSvc.IssueCredential(ctx, IssueRequest{ + Identity: identity, + GrantType: domain.GrantTypeTokenExchange, + Scopes: scopes, + UseRS256: true, + SubjectOverride: userID, + UserEmail: userEmail, + UserName: userName, + ApplicationID: req.ApplicationID, + TTL: 900, // 15 minutes — same short-lived posture as the broker path + CustomClaims: customClaims, + }) + if err != nil { + return nil, oauthServerError("failed to issue external id_token exchange token", err) + } + + accessToken.AccountID = req.AccountID + accessToken.ProjectID = req.ProjectID + accessToken.UserID = userID + + log.Info(). + Str("upstream_iss", upstreamIss). + Str("account_id", req.AccountID). + Str("project_id", req.ProjectID). + Str("user_id", userID). + Msg("external id_token exchange succeeded") + + return accessToken, nil +} + +// resolveExternalPrincipalIdentity factors out the identity-resolution step +// shared between the broker and direct-federation paths. ApplicationID, when +// provided, must resolve to an active identity in the caller's tenant; this +// is the IDOR guard that prevents a token-exchange request from minting a +// token for someone else's application. With no ApplicationID we synthesize +// a service identity (same shape the broker path used). +func (s *OAuthService) resolveExternalPrincipalIdentity(ctx context.Context, req TokenRequest) (*domain.Identity, error) { + if req.ApplicationID == "" { + return &domain.Identity{ + AccountID: req.AccountID, + ProjectID: req.ProjectID, + IdentityType: domain.IdentityTypeService, + Status: domain.IdentityStatusActive, + }, nil + } + resolved, err := s.identitySvc.GetIdentity(ctx, req.ApplicationID, req.AccountID, req.ProjectID) + if err != nil { + return nil, oauthBadRequest("invalid_request", fmt.Sprintf("application_id %s not found or access denied", req.ApplicationID)) + } + if !resolved.Status.IsUsable() { + return nil, oauthBadRequest("invalid_grant", "identity is suspended or deactivated") + } + return resolved, nil +} + +// extractMappedClaimString reads a single-level claim by name and coerces it +// to string. Returns ok=false when the path is empty (no mapping configured) +// or the upstream value is not stringifiable. Numeric claims are accepted +// because some IdPs (Entra) emit numeric subject identifiers. +func extractMappedClaimString(claims map[string]any, path string) (string, bool) { + if path == "" { + return "", false + } + v, ok := claims[path] + if !ok { + return "", false + } + switch tv := v.(type) { + case string: + return tv, true + case float64: + return fmt.Sprintf("%v", tv), true + case int64: + return fmt.Sprintf("%d", tv), true + } + return "", false +} + +// checkExternalIDTokenAlg enforces the issuer's algorithm allow-list against +// the JWS protected header. Defense-in-depth — Parse will already reject a +// bad-key/bad-alg combo, but reading the header explicitly lets us refuse +// "none", HS256, or anything outside the configured RS/ES/PS family up +// front. +func checkExternalIDTokenAlg(tokenStr string, allowed []string) error { + msg, err := jws.Parse([]byte(tokenStr)) + if err != nil { + return fmt.Errorf("parse JWS: %w", err) + } + sigs := msg.Signatures() + if len(sigs) == 0 { + return fmt.Errorf("subject_token has no signatures") + } + alg := string(sigs[0].ProtectedHeaders().Algorithm()) + + // Hard whitelist of secure asymmetric algorithms — the configured list + // is an additional narrowing on top, never a widening. + switch jwa.SignatureAlgorithm(alg) { + case jwa.RS256, jwa.RS384, jwa.RS512, + jwa.ES256, jwa.ES384, jwa.ES512, + jwa.PS256, jwa.PS384, jwa.PS512: + default: + return fmt.Errorf("alg %q is not a supported asymmetric signing algorithm", alg) + } + + if len(allowed) == 0 { + return nil + } + for _, a := range allowed { + if a == alg { + return nil + } + } + return fmt.Errorf("alg %q not in issuer allow-list %v", alg, allowed) +} + +// extractJWSKeyID reads kid from a JWS protected header without verifying. +// Returns "" if the header is unreadable. Used to drive a one-shot JWKS +// refresh after a verification failure that might be caused by upstream key +// rotation. +func extractJWSKeyID(tokenStr string) string { + msg, err := jws.Parse([]byte(tokenStr)) + if err != nil { + return "" + } + sigs := msg.Signatures() + if len(sigs) == 0 { + return "" + } + return sigs[0].ProtectedHeaders().KeyID() +} diff --git a/internal/service/oauth_external_idp_test.go b/internal/service/oauth_external_idp_test.go new file mode 100644 index 0000000..00623ba --- /dev/null +++ b/internal/service/oauth_external_idp_test.go @@ -0,0 +1,76 @@ +package service + +import "testing" + +// TestExtractMappedClaimString covers the v1 claim-mapping shapes the three +// reference IdPs (Okta, Entra, Google) emit. v1 is single-level only — no +// JSONPath, no expressions. +func TestExtractMappedClaimString(t *testing.T) { + t.Run("string sub from Okta", func(t *testing.T) { + got, ok := extractMappedClaimString(map[string]any{"sub": "00uABC"}, "sub") + if !ok || got != "00uABC" { + t.Fatalf("expected (00uABC, true), got (%q, %v)", got, ok) + } + }) + + t.Run("numeric oid from Entra is stringified", func(t *testing.T) { + got, ok := extractMappedClaimString(map[string]any{"oid": float64(42)}, "oid") + if !ok || got != "42" { + t.Fatalf("expected (42, true), got (%q, %v)", got, ok) + } + }) + + t.Run("missing path returns false", func(t *testing.T) { + _, ok := extractMappedClaimString(map[string]any{"sub": "x"}, "email") + if ok { + t.Fatalf("expected ok=false for missing claim") + } + }) + + t.Run("empty path returns false", func(t *testing.T) { + _, ok := extractMappedClaimString(map[string]any{"sub": "x"}, "") + if ok { + t.Fatalf("empty path means no mapping configured; should be ok=false") + } + }) + + t.Run("non-stringifiable value returns false", func(t *testing.T) { + _, ok := extractMappedClaimString(map[string]any{"sub": map[string]any{}}, "sub") + if ok { + t.Fatalf("nested object cannot be coerced to string in v1; should be ok=false") + } + }) +} + +// TestCheckExternalIDTokenAlg verifies that the algorithm gate refuses +// none/HS* family tokens up front and respects the configured allow-list. +// +// We exercise it with crafted JWS strings rather than spinning up a real +// signer — the function only reads the protected header. +func TestCheckExternalIDTokenAlg(t *testing.T) { + // alg=none token: header={"alg":"none","typ":"JWT"}, no signature. + // Build by hand — base64url("{\"alg\":\"none\",\"typ\":\"JWT\"}") + ".eyJ9." (empty body, empty sig) + // We don't bother — jws.Parse rejects unsigned tokens. + // Instead we test alg=HS256 which has the same payload structure but is rejected. + // HS256 header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 + hs256 := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ4In0.AAAA" + if err := checkExternalIDTokenAlg(hs256, []string{"RS256", "ES256"}); err == nil { + t.Fatalf("expected HS256 to be rejected as non-asymmetric, got nil") + } + + // RS256 with explicit allow-list match — header eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 + rs256 := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ4In0.AAAA" + if err := checkExternalIDTokenAlg(rs256, []string{"RS256"}); err != nil { + t.Fatalf("expected RS256 to pass with allow-list [RS256], got %v", err) + } + + // RS256 with an allow-list that excludes it. + if err := checkExternalIDTokenAlg(rs256, []string{"ES256"}); err == nil { + t.Fatalf("expected RS256 to be rejected when allow-list is [ES256]") + } + + // Empty allow-list → defaults to the hard whitelist of asymmetric algs. + if err := checkExternalIDTokenAlg(rs256, nil); err != nil { + t.Fatalf("expected RS256 to pass with empty allow-list, got %v", err) + } +} diff --git a/server.go b/server.go index 132d0c5..73ec06d 100644 --- a/server.go +++ b/server.go @@ -73,6 +73,11 @@ type Server struct { jwksSvc *signing.JWKSService refreshTokenSvc *service.RefreshTokenService + // External OIDC IdP federation (issue #88). Holds a JWKS client per + // configured trusted upstream issuer. Nil when no external_issuers are + // configured. + externalIssuerRegistry *service.ExternalIssuerRegistry + // Cleanup cleanupWorker *worker.CleanupWorker workerCancel context.CancelFunc @@ -178,6 +183,22 @@ func NewServer(cfg Config) (*Server, error) { HMACSecret: cfg.Token.HMACSecret, AuthCodeIssuer: authCodeIssuer, }) + + // Build the external-issuer registry when the deployer has configured + // trusted upstream IdPs. Synchronous initial JWKS fetches happen here so + // a misconfigured issuer (unreachable JWKS, bad TLS) fails startup + // rather than the first token-exchange request. + var externalIssuerRegistry *service.ExternalIssuerRegistry + if len(cfg.ExternalIssuers) > 0 { + registry, err := service.NewExternalIssuerRegistry(context.Background(), cfg.ExternalIssuers) + if err != nil { + return nil, fmt.Errorf("failed to initialize external OIDC issuer registry (issue #88): %w", err) + } + oauthSvc.SetExternalIssuerRegistry(registry) + externalIssuerRegistry = registry + log.Info().Int("count", len(cfg.ExternalIssuers)).Msg("Direct OIDC IdP federation enabled") + } + proofSvc := service.NewProofService(jwksSvc, proofRepo, cfg.Token.Issuer) agentSvc := service.NewAgentService(identitySvc, apiKeySvc, apiKeyRepo) @@ -295,9 +316,10 @@ func NewServer(cfg Config) (*Server, error) { signalSvc: signalSvc, apiKeySvc: apiKeySvc, agentSvc: agentSvc, - jwksSvc: jwksSvc, - refreshTokenSvc: refreshTokenSvc, - cleanupWorker: worker.NewCleanupWorker(db, time.Hour), + jwksSvc: jwksSvc, + refreshTokenSvc: refreshTokenSvc, + externalIssuerRegistry: externalIssuerRegistry, + cleanupWorker: worker.NewCleanupWorker(db, time.Hour), adminAuthState: authState, globalMWState: globalMW, http: &http.Server{ @@ -364,6 +386,10 @@ func (s *Server) Shutdown(ctx context.Context) error { s.workerCancel() } + if s.externalIssuerRegistry != nil { + s.externalIssuerRegistry.Close() + } + var firstErr error if err := s.http.Shutdown(ctx); err != nil && firstErr == nil { firstErr = err diff --git a/zeroid.yaml b/zeroid.yaml index 78ce3af..cca7fd2 100644 --- a/zeroid.yaml +++ b/zeroid.yaml @@ -13,7 +13,7 @@ database: url: "postgres://zeroid:zeroid@localhost:5432/zeroid?sslmode=disable" max_open_conns: 25 max_idle_conns: 5 - auto_migrate: true # Set to false in production — use zeroid.Migrate() or MigrationFiles() instead + auto_migrate: true # Set to false in production — use zeroid.Migrate() or MigrationFiles() instead keys: private_key_path: "./keys/private.pem" @@ -28,7 +28,7 @@ token: issuer: "https://highflame.ai" base_url: "http://localhost:8899" default_ttl: 3600 - max_ttl: 7776000 # 90 days + max_ttl: 7776000 # 90 days wimse_domain: "highflame.ai" @@ -45,3 +45,20 @@ telemetry: logging: level: "debug" + +# Direct OIDC IdP federation (issue #88). +# Each entry registers a trusted upstream issuer for RFC 8693 token exchange +# with subject_token_type=urn:ietf:params:oauth:token-type:id_token. +# When empty (default), only the broker path remains available. +# external_issuers: +# - issuer: "https://auth.example.okta.com" +# jwks_uri: "https://auth.example.okta.com/.well-known/jwks.json" +# audience: "https://zeroid.example.com" +# algorithms: ["RS256", "ES256"] +# max_token_age: 10m +# jwks_cache_ttl: 5m +# claim_mapping: +# user_id: sub +# email: email +# allowed_accounts: [] # empty = any tenant +# propagate_claims: ["auth_time", "acr", "amr"] From 681d9c67fa3d4b808a221cda2e14a49227f84972 Mon Sep 17 00:00:00 2001 From: safayavatsal Date: Thu, 7 May 2026 14:40:17 +0530 Subject: [PATCH 2/3] Address Gemini review feedback on PR #124 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ErrUnknownExternalIssuer sentinel; wrap onto *OAuthError so callers can errors.Is while the handler still picks up the OAuth error code via errors.As. Kept invalid_request rather than invalid_grant — see review reply for the RFC 6749 §5.2 reasoning. - Add user_id_iss to the global reservedClaims map in oauth.go; federation path now uses the single-sourced check. - extractMappedClaimString: switch float64 path from fmt.Sprintf("%v") to strconv.FormatFloat(tv, 'f', -1, 64); large numeric subjects no longer render in scientific notation. int64 path moved to FormatInt for symmetry. Regression test for 16-digit subject. - Tests for the dual-signaling unknown-issuer error path. --- internal/service/oauth.go | 2 +- internal/service/oauth_external_idp.go | 41 ++++++-- internal/service/oauth_external_idp_test.go | 107 +++++++++++++++++++- 3 files changed, 141 insertions(+), 9 deletions(-) diff --git a/internal/service/oauth.go b/internal/service/oauth.go index c6f42b8..dd21f85 100644 --- a/internal/service/oauth.go +++ b/internal/service/oauth.go @@ -68,7 +68,7 @@ var reservedClaims = map[string]bool{ "capabilities": true, "scopes": true, "grant_type": true, "delegation_depth": true, "user_email": true, "user_name": true, // ZeroID internal claims - "act": true, "token_exchange": true, "trusted_by": true, + "act": true, "token_exchange": true, "trusted_by": true, "user_id_iss": true, } // trustedServiceValidatorFunc checks whether the current request comes from a trusted diff --git a/internal/service/oauth_external_idp.go b/internal/service/oauth_external_idp.go index da08fc8..9dea4ef 100644 --- a/internal/service/oauth_external_idp.go +++ b/internal/service/oauth_external_idp.go @@ -4,7 +4,9 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" + "strconv" "strings" "time" @@ -20,6 +22,19 @@ import ( // direct-federation path (issue #88) instead of the broker path. const SubjectTokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token" +// ErrUnknownExternalIssuer is returned when a token-exchange request carries +// an upstream `iss` that is not in the deployer-configured external_issuers +// allowlist. Wrapped onto the *OAuthError so callers can branch with +// errors.Is while the handler still maps the OAuth error code via errors.As. +// +// We classify this as `invalid_request` (RFC 6749 §5.2) rather than +// `invalid_grant`: the token may be perfectly valid against its real issuer; +// the failure is that the deployer has not configured this IdP. Per the RFC, +// `invalid_grant` covers credentials that "are invalid, expired, revoked" +// against a trust config the server does have — not credentials whose issuer +// the server has never been told to trust. +var ErrUnknownExternalIssuer = errors.New("issuer is not a configured external issuer") + // SetExternalIssuerRegistry installs a registry of trusted external IdPs. // Must be set before /oauth2/token requests with subject_token_type=id_token // can be served — without a registry, those requests are rejected with @@ -73,8 +88,14 @@ func (s *OAuthService) externalIDTokenExchange(ctx context.Context, req TokenReq if entry == nil { // Unknown issuer — invalid_request, not invalid_grant: the deployer // has not configured this IdP, which is a configuration mismatch - // rather than a credential failure. - return nil, oauthBadRequest("invalid_request", fmt.Sprintf("issuer %s is not a configured external issuer", upstreamIss)) + // rather than a credential failure. Wrap ErrUnknownExternalIssuer as + // the cause so callers can errors.Is while the handler still picks up + // the OAuth error code via errors.As on *OAuthError. + return nil, oauthBadRequestCause( + "invalid_request", + fmt.Sprintf("issuer %s is not a configured external issuer", upstreamIss), + fmt.Errorf("%w: %s", ErrUnknownExternalIssuer, upstreamIss), + ) } cfg := entry.Config @@ -169,10 +190,10 @@ func (s *OAuthService) externalIDTokenExchange(ctx context.Context, req TokenReq } // Caller-provided AdditionalClaims pass through the same blocklist as - // the broker path. user_id_iss is reserved (added below the blocklist - // extension) so callers cannot spoof IdP provenance. + // the broker path. user_id_iss is in reservedClaims so callers cannot + // spoof IdP provenance. for k, v := range req.AdditionalClaims { - if reservedClaims[k] || k == "user_id_iss" { + if reservedClaims[k] { continue } customClaims[k] = v @@ -250,9 +271,15 @@ func extractMappedClaimString(claims map[string]any, path string) (string, bool) case string: return tv, true case float64: - return fmt.Sprintf("%v", tv), true + // %v / %g switch to scientific notation for large floats + // (1.23e+18), which would silently corrupt large numeric subject + // identifiers. FormatFloat with 'f' keeps a plain decimal. Note + // that float64 still loses integer precision past 2^53; an IdP + // that mints subjects above that range is the one violating the + // JWT/JSON contract — we just don't add a second bug on top. + return strconv.FormatFloat(tv, 'f', -1, 64), true case int64: - return fmt.Sprintf("%d", tv), true + return strconv.FormatInt(tv, 10), true } return "", false } diff --git a/internal/service/oauth_external_idp_test.go b/internal/service/oauth_external_idp_test.go index 00623ba..4cefa7b 100644 --- a/internal/service/oauth_external_idp_test.go +++ b/internal/service/oauth_external_idp_test.go @@ -1,6 +1,21 @@ package service -import "testing" +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "errors" + "net/http" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v4/jwa" + "github.com/lestrrat-go/jwx/v4/jwt" + + "github.com/highflame-ai/zeroid/domain" + "github.com/highflame-ai/zeroid/pkg/authjwt" +) // TestExtractMappedClaimString covers the v1 claim-mapping shapes the three // reference IdPs (Okta, Entra, Google) emit. v1 is single-level only — no @@ -20,6 +35,19 @@ func TestExtractMappedClaimString(t *testing.T) { } }) + t.Run("large numeric subject avoids scientific notation", func(t *testing.T) { + // JSON unmarshals a 16-digit integer literal into float64. %v / %g + // would render this as 1.234567890123456e+15 and break downstream + // equality checks against the upstream's stable subject identifier. + got, ok := extractMappedClaimString(map[string]any{"sub": float64(1234567890123456)}, "sub") + if !ok { + t.Fatalf("expected ok=true for large numeric subject") + } + if got != "1234567890123456" { + t.Fatalf("expected plain decimal %q, got %q", "1234567890123456", got) + } + }) + t.Run("missing path returns false", func(t *testing.T) { _, ok := extractMappedClaimString(map[string]any{"sub": "x"}, "email") if ok { @@ -74,3 +102,80 @@ func TestCheckExternalIDTokenAlg(t *testing.T) { t.Fatalf("expected RS256 to pass with empty allow-list, got %v", err) } } + +// TestExternalIDTokenExchange_UnknownIssuerWrapsSentinel verifies the dual +// signaling path for the unknown-issuer case: the OAuth handler picks up the +// error code via errors.As on *OAuthError, while service-layer callers can +// branch with errors.Is(ErrUnknownExternalIssuer). +func TestExternalIDTokenExchange_UnknownIssuerWrapsSentinel(t *testing.T) { + // Build a registry that knows iss=A but not iss=B. + jwks := newFakeJWKSServer(t, "kid-1") + defer jwks.Close() + + cfg := domain.ExternalIssuerConfig{ + Issuer: "https://known.example.test", + JWKSURI: jwks.URL(), + Audience: "https://zeroid.example.test", + ClaimMapping: map[string]string{"user_id": "sub"}, + } + cfg.Defaults() + if err := cfg.Validate(); err != nil { + t.Fatalf("validate cfg: %v", err) + } + registry, err := NewExternalIssuerRegistry( + context.Background(), + []domain.ExternalIssuerConfig{cfg}, + authjwt.WithHTTPClient(insecureHTTPClient(2*time.Second)), + ) + if err != nil { + t.Fatalf("NewExternalIssuerRegistry: %v", err) + } + defer registry.Close() + + // Mint a JWT whose iss is *not* the configured one. Lookup should miss + // and the federation path should return before any signature work. + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tok, err := jwt.NewBuilder(). + Issuer("https://stranger.example.test"). + Subject("alice"). + IssuedAt(time.Now()). + Expiration(time.Now().Add(5 * time.Minute)). + Build() + if err != nil { + t.Fatalf("build token: %v", err) + } + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256(), priv)) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + svc := &OAuthService{externalIssuerRegistry: registry} + _, err = svc.externalIDTokenExchange(context.Background(), TokenRequest{ + SubjectToken: string(signed), + AccountID: "acct", + ProjectID: "proj", + }) + if err == nil { + t.Fatalf("expected error for unknown issuer, got nil") + } + + // Handler path: errors.As on *OAuthError must yield invalid_request / 400. + var oauthErr *OAuthError + if !errors.As(err, &oauthErr) { + t.Fatalf("expected *OAuthError in chain, got %T: %v", err, err) + } + if oauthErr.Code != "invalid_request" { + t.Errorf("Code = %q, want invalid_request", oauthErr.Code) + } + if oauthErr.HTTPStatus != http.StatusBadRequest { + t.Errorf("HTTPStatus = %d, want 400", oauthErr.HTTPStatus) + } + + // Caller path: errors.Is on the sentinel must succeed. + if !errors.Is(err, ErrUnknownExternalIssuer) { + t.Errorf("expected errors.Is(err, ErrUnknownExternalIssuer) to be true; chain = %v", err) + } +} From 94ff4a6a5bb52c987775515a15f1327f3d51c662 Mon Sep 17 00:00:00 2001 From: safayavatsal Date: Thu, 7 May 2026 14:54:38 +0530 Subject: [PATCH 3/3] Add end-to-end federation tests covering PR #124 test plan items 2 & 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New zeroid.WithExternalIssuerJWKSOption build-time option threads an authjwt.JWKSOption into the external-issuer registry. Lets tests inject an insecure HTTP client when pointing the registry at a fake TLS JWKS. - tests/integration/external_idp_test.go: end-to-end TestExternalIDTokenFederation_EndToEnd with two subtests: * federation_happy_path_emits_user_id_iss — Okta-shaped ID token -> issued JWT carries user_id_iss=upstream iss, plus acr/amr/auth_time propagation and token_exchange=external_id_token. * broker_dispatch_unchanged_when_subject_token_type_is_omitted — request without subject_token_type still routes to the broker (proven by the broker-only "external principal exchange is not configured" error fingerprint). - tests/integration/external_idp_helpers_test.go: fake upstream IdP key material, jws header builder, payload-segment decoder. - helpers_test.go: capture sharedDBURL so federation tests can build a second NewServer pointed at the same Postgres. All four packages PASS under \`make test\`. --- server.go | 31 +- .../integration/external_idp_helpers_test.go | 79 +++++ tests/integration/external_idp_test.go | 312 ++++++++++++++++++ tests/integration/helpers_test.go | 1 + 4 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 tests/integration/external_idp_helpers_test.go create mode 100644 tests/integration/external_idp_test.go diff --git a/server.go b/server.go index 7507f60..9bbd528 100644 --- a/server.go +++ b/server.go @@ -35,6 +35,7 @@ import ( "github.com/highflame-ai/zeroid/internal/store/postgres" "github.com/highflame-ai/zeroid/internal/telemetry" "github.com/highflame-ai/zeroid/internal/worker" + "github.com/highflame-ai/zeroid/pkg/authjwt" ) // middlewareHolder stores an optional middleware in a thread-safe way. @@ -90,9 +91,35 @@ type Server struct { globalMWState *middlewareHolder } +// ServerOption configures NewServer. Provided for narrow build-time +// concerns that can't reasonably be expressed in Config (e.g. injecting a +// custom HTTP client into the external-issuer JWKS fetch path for tests). +type ServerOption func(*serverOptions) + +// serverOptions is the internal accumulator behind ServerOption. +type serverOptions struct { + externalIssuerJWKSOpts []authjwt.JWKSOption +} + +// WithExternalIssuerJWKSOption forwards an authjwt JWKS option to the +// external-issuer registry built inside NewServer. Intended primarily for +// tests that need to bypass TLS verification when pointing the registry at +// a fake JWKS server (httptest.NewTLSServer); production deployers should +// not need this. +func WithExternalIssuerJWKSOption(opt authjwt.JWKSOption) ServerOption { + return func(o *serverOptions) { + o.externalIssuerJWKSOpts = append(o.externalIssuerJWKSOpts, opt) + } +} + // NewServer initializes all ZeroID subsystems: database, migrations, signing keys, // repositories, services, handlers, and the HTTP router. -func NewServer(cfg Config) (*Server, error) { +func NewServer(cfg Config, opts ...ServerOption) (*Server, error) { + options := serverOptions{} + for _, opt := range opts { + opt(&options) + } + initLogging(cfg.Logging.Level) if err := cfg.Validate(); err != nil { @@ -209,7 +236,7 @@ func NewServer(cfg Config) (*Server, error) { // rather than the first token-exchange request. var externalIssuerRegistry *service.ExternalIssuerRegistry if len(cfg.ExternalIssuers) > 0 { - registry, err := service.NewExternalIssuerRegistry(context.Background(), cfg.ExternalIssuers) + registry, err := service.NewExternalIssuerRegistry(context.Background(), cfg.ExternalIssuers, options.externalIssuerJWKSOpts...) if err != nil { return nil, fmt.Errorf("failed to initialize external OIDC issuer registry (issue #88): %w", err) } diff --git a/tests/integration/external_idp_helpers_test.go b/tests/integration/external_idp_helpers_test.go new file mode 100644 index 0000000..05de13c --- /dev/null +++ b/tests/integration/external_idp_helpers_test.go @@ -0,0 +1,79 @@ +package integration_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + + "github.com/lestrrat-go/jwx/v4/jws" +) + +// sharedDBURL is the DSN of the shared TestMain Postgres container. It is +// captured on first use by the federation test helpers — we copy it from +// the bun.DB handle's underlying connector. Set in TestMain before any +// per-test code runs. +var sharedDBURL string + +// federationKeyPaths holds the temp PEM file paths for the federation +// server's signing keys. Generated once on first federation-test access so +// every federation-server instance reuses the same keys (which is fine — +// no other test reads from these key files). +var fedKeyPaths struct { + privPath string + pubPath string + rsaPriv string + rsaPub string +} + +// initFederationKeyMaterial generates and writes the temp PEM files used +// by every federation server constructed in these tests. Called lazily from +// the federation tests themselves (TestMain has no hook). +func initFederationKeyMaterial() error { + if fedKeyPaths.privPath != "" { + return nil + } + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + privPath, pubPath, _, err := writeKeyFiles(privKey) + if err != nil { + return err + } + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + rsaPriv, rsaPub, _, err := writeRSAKeyFiles(rsaKey) + if err != nil { + return err + } + fedKeyPaths.privPath = privPath + fedKeyPaths.pubPath = pubPath + fedKeyPaths.rsaPriv = rsaPriv + fedKeyPaths.rsaPub = rsaPub + return nil +} + +// jwsHeadersForKID returns a jws header object carrying kid + typ — used by +// the fake upstream IdP to mint tokens whose protected header announces the +// signing key the registry must look up. +func jwsHeadersForKID(kid string) (jws.Headers, error) { + hdr := jws.NewHeaders() + if err := hdr.Set(jws.KeyIDKey, kid); err != nil { + return nil, err + } + if err := hdr.Set(jws.TypeKey, "JWT"); err != nil { + return nil, err + } + return hdr, nil +} + +// jwtDecodeSegment base64url-decodes a JWT payload segment. Mirrors what +// jwt.Parse would do internally without running any signature checks — +// callers in this package only need the claim map for assertions. +func jwtDecodeSegment(seg string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(seg) +} diff --git a/tests/integration/external_idp_test.go b/tests/integration/external_idp_test.go new file mode 100644 index 0000000..f3b7a39 --- /dev/null +++ b/tests/integration/external_idp_test.go @@ -0,0 +1,312 @@ +package integration_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v4/jwa" + "github.com/lestrrat-go/jwx/v4/jwk" + "github.com/lestrrat-go/jwx/v4/jws" + "github.com/lestrrat-go/jwx/v4/jwt" + "github.com/stretchr/testify/require" + + zeroid "github.com/highflame-ai/zeroid" + "github.com/highflame-ai/zeroid/domain" + "github.com/highflame-ai/zeroid/pkg/authjwt" +) + +// TestExternalIDTokenFederation_EndToEnd covers the two manual smoke items +// in PR #124's test plan: +// +// - Item 2: configure one external issuer, post an Okta-shaped ID token, +// observe `user_id_iss` on the issued token. +// - Item 3: with the same server, omit subject_token_type and confirm +// dispatch still routes to the broker path (proven by the broker's +// distinctive "external principal exchange is not configured" error, +// which only that path emits). +// +// The federation server is a SECOND zeroid.NewServer instance pointed at +// the same Postgres as the shared TestMain server — adding external_issuers +// to the shared cfg would touch every other test, so we keep this one +// isolated. The fake upstream IdP runs as an httptest.NewTLSServer and the +// registry's HTTP client is overridden via the new +// zeroid.WithExternalIssuerJWKSOption hook so it can talk to the test cert. +func TestExternalIDTokenFederation_EndToEnd(t *testing.T) { + upstreamIss := "https://upstream.idp.test" + federationAud := "https://zeroid.federation.test" + + // Stand up a fake upstream IdP with one ES256 key. + upstream := newFakeUpstreamIdP(t) + defer upstream.Close() + + // Build a federation-configured server alongside the shared one. + fedSrv, fedHTTPSrv, fedCfg := newFederationServer(t, domain.ExternalIssuerConfig{ + Issuer: upstreamIss, + JWKSURI: upstream.JWKSURL(), + Audience: federationAud, + ClaimMapping: map[string]string{ + "user_id": "sub", + "email": "email", + }, + PropagateClaims: []string{"auth_time", "acr", "amr"}, + }) + defer fedHTTPSrv.Close() + defer func() { _ = fedSrv.Shutdown(context.Background()) }() + + t.Run("federation happy path emits user_id_iss", func(t *testing.T) { + // Mint an Okta-shaped ID token. Okta uses `sub` as a stable string + // identifier and emits `auth_time`/`amr` from the authentication event. + now := time.Now() + idToken := upstream.SignToken(t, map[string]any{ + "iss": upstreamIss, + "aud": federationAud, + "sub": "00uABCDE12345", + "email": "alice@example.com", + "iat": now.Unix(), + "exp": now.Add(5 * time.Minute).Unix(), + "auth_time": now.Add(-30 * time.Second).Unix(), + "amr": []string{"pwd", "mfa"}, + "acr": "urn:okta:app:mfa:factor:push", + }) + + resp := postFederation(t, fedHTTPSrv.URL, map[string]any{ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": idToken, + "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", + "account_id": fedCfg.AccountID, + "project_id": fedCfg.ProjectID, + }) + require.Equal(t, http.StatusOK, resp.StatusCode, "federation /oauth2/token must return 200; body=%s", resp.RawBody) + + issuedClaims := decodeIssuedTokenClaims(t, resp.AccessToken) + + // The headline assertion: user_id_iss is the upstream IdP's iss, + // giving downstream consumers IdP-granular provenance. + require.Equal(t, upstreamIss, issuedClaims["user_id_iss"], + "issued token must carry user_id_iss = upstream iss") + + // The federation path emits token_exchange=external_id_token, which + // distinguishes it from the broker's external_principal value. + require.Equal(t, "external_id_token", issuedClaims["token_exchange"]) + + // Honest claim propagation: auth_time/amr/acr were on the upstream, + // so they should be on the issued token. The federation path never + // default-fills these — we already verified absence in earlier unit + // tests; this test verifies presence-when-upstream-set. + require.Contains(t, issuedClaims, "auth_time") + require.Contains(t, issuedClaims, "amr") + require.Equal(t, "urn:okta:app:mfa:factor:push", issuedClaims["acr"]) + + // Subject identifier flows through claim_mapping: user_id is mapped + // to "sub", so the issued JWT's `sub` claim is the upstream `sub`. + // (`user_id` itself isn't emitted as a JWT claim — it's on the API + // response struct only, mirroring the broker path's behavior.) + require.Equal(t, "00uABCDE12345", issuedClaims["sub"]) + require.Equal(t, "alice@example.com", issuedClaims["user_email"]) + }) + + t.Run("broker dispatch unchanged when subject_token_type is omitted", func(t *testing.T) { + // Same federation-configured server, but this request leaves + // subject_token_type empty. Dispatch must NOT reach the federation + // path; it must reach ExternalPrincipalExchange. The federation + // server has no TrustedServiceValidator wired, so the broker path + // fails with its distinctive "external principal exchange is not + // configured" error — a fingerprint that only the broker path + // produces. Different errors prove different dispatch arms. + resp := postFederation(t, fedHTTPSrv.URL, map[string]any{ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": "anything-the-broker-would-not-validate", + "account_id": fedCfg.AccountID, + "project_id": fedCfg.ProjectID, + "user_id": "alice", + }) + require.NotEqual(t, http.StatusOK, resp.StatusCode, + "broker path without TrustedServiceValidator must reject; body=%s", resp.RawBody) + + require.Contains(t, resp.RawBody, "external principal exchange is not configured", + "broker fingerprint missing — dispatch may have leaked into the federation path. body=%s", resp.RawBody) + + // Sanity: the federation-only error string must NOT appear, since + // dispatch should not have entered that arm at all. + require.NotContains(t, resp.RawBody, "no external issuers are configured", + "federation-path error string leaked — dispatch routed wrong") + }) +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +type fakeUpstreamIdP struct { + srv *httptest.Server + priv *ecdsa.PrivateKey + keyID string + keySet jwk.Set + hits atomic.Int64 +} + +func newFakeUpstreamIdP(t *testing.T) *fakeUpstreamIdP { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + keyID := "upstream-key-1" + keySet := jwk.NewSet() + pub, err := jwk.Import[jwk.Key](&priv.PublicKey) + require.NoError(t, err) + require.NoError(t, pub.Set(jwk.KeyIDKey, keyID)) + require.NoError(t, pub.Set(jwk.AlgorithmKey, jwa.ES256())) + require.NoError(t, pub.Set(jwk.KeyUsageKey, jwk.ForSignature)) + require.NoError(t, keySet.AddKey(pub)) + + idp := &fakeUpstreamIdP{priv: priv, keyID: keyID, keySet: keySet} + idp.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + idp.hits.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(idp.keySet) + })) + return idp +} + +func (i *fakeUpstreamIdP) JWKSURL() string { return i.srv.URL } +func (i *fakeUpstreamIdP) Close() { i.srv.Close() } + +// SignToken serialises the given claim map as an ES256 JWT. We use a hand +// rolled token rather than jwt.NewBuilder because we want full control over +// claim shape (e.g. amr as a string slice, auth_time as a unix int) to mimic +// real-world IdP outputs. +func (i *fakeUpstreamIdP) SignToken(t *testing.T, claims map[string]any) string { + t.Helper() + tok := jwt.New() + for k, v := range claims { + require.NoError(t, tok.Set(k, v)) + } + hdr, err := jwsHeadersForKID(i.keyID) + require.NoError(t, err) + signed, err := jwt.Sign(tok, + jwt.WithKey(jwa.ES256(), i.priv, jws.WithProtectedHeaders(hdr)), + ) + require.NoError(t, err) + return string(signed) +} + +// federationServerCfg captures the bits the test needs to drive the server. +type federationServerCfg struct { + AccountID string + ProjectID string +} + +// newFederationServer spins up a second zeroid.NewServer wired to the same +// Postgres as the shared TestMain server but with the given external_issuer +// configured. The fake JWKS runs over TLS, so we use +// WithExternalIssuerJWKSOption + an insecure HTTP client to let the registry +// reach it without trusting the test cert chain. +func newFederationServer(t *testing.T, issuer domain.ExternalIssuerConfig) (*zeroid.Server, *httptest.Server, federationServerCfg) { + t.Helper() + require.NoError(t, initFederationKeyMaterial(), "init federation key material") + + cfg := zeroid.Config{ + Server: zeroid.ServerConfig{ + Port: "0", + Env: "test", + ShutdownTimeoutSeconds: 5, + }, + Database: zeroid.DatabaseConfig{ + URL: sharedDBURL, + MaxOpenConns: 5, + MaxIdleConns: 2, + }, + Keys: zeroid.KeysConfig{ + PrivateKeyPath: fedKeyPaths.privPath, + PublicKeyPath: fedKeyPaths.pubPath, + KeyID: "fed-test-key-1", + RSAPrivateKeyPath: fedKeyPaths.rsaPriv, + RSAPublicKeyPath: fedKeyPaths.rsaPub, + RSAKeyID: "fed-test-rsa-1", + }, + Token: zeroid.TokenConfig{ + Issuer: "https://federation.zeroid.test", + BaseURL: "https://federation.zeroid.test", + DefaultTTL: 3600, + MaxTTL: 90 * 24 * 3600, + HMACSecret: testHMACSecret, + AuthCodeIssuer: "https://federation.zeroid.test", + }, + Telemetry: zeroid.TelemetryConfig{Enabled: false}, + Logging: zeroid.LoggingConfig{Level: "warn"}, + WIMSEDomain: testWIMSE, + ExternalIssuers: []domain.ExternalIssuerConfig{issuer}, + } + + insecure := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec + } + srv, err := zeroid.NewServer(cfg, zeroid.WithExternalIssuerJWKSOption(authjwt.WithHTTPClient(insecure))) + require.NoError(t, err, "build federation server") + + httpSrv := httptest.NewServer(srv.Router()) + + return srv, httpSrv, federationServerCfg{ + AccountID: "acct-fed-001", + ProjectID: "proj-fed-001", + } +} + +// tokenResponse decodes the relevant fields off /oauth2/token responses. +type tokenResponse struct { + StatusCode int + AccessToken string + RawBody string +} + +func postFederation(t *testing.T, baseURL string, body map[string]any) tokenResponse { + t.Helper() + b, err := json.Marshal(body) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, baseURL+"/oauth2/token", strings.NewReader(string(b))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + var raw map[string]any + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&raw); err != nil { + // Best-effort: the body might not be JSON on certain server errors. + return tokenResponse{StatusCode: resp.StatusCode, RawBody: fmt.Sprintf("%v", err)} + } + at, _ := raw["access_token"].(string) + rawBytes, _ := json.Marshal(raw) + return tokenResponse{ + StatusCode: resp.StatusCode, + AccessToken: at, + RawBody: string(rawBytes), + } +} + +// decodeIssuedTokenClaims base64url-decodes the JWT payload section without +// verifying — we only need the claim map for assertions, and the issuer +// (the federation server we just built) is trusted by the test scope. +func decodeIssuedTokenClaims(t *testing.T, tokenStr string) map[string]any { + t.Helper() + require.NotEmpty(t, tokenStr, "access_token must be non-empty") + parts := strings.Split(tokenStr, ".") + require.Len(t, parts, 3, "JWT must have 3 parts") + payload, err := jwtDecodeSegment(parts[1]) + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(payload, &m)) + return m +} diff --git a/tests/integration/helpers_test.go b/tests/integration/helpers_test.go index 7c93a3d..eba6602 100644 --- a/tests/integration/helpers_test.go +++ b/tests/integration/helpers_test.go @@ -105,6 +105,7 @@ func runTests(m *testing.M) int { fmt.Fprintf(os.Stderr, "get connection string: %v\n", err) return 1 } + sharedDBURL = dbURL // used by federation-test helpers (external_idp_test.go). // Generate the server's ECDSA P-256 key pair and write to temp files. privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)