Skip to content
Open
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
21 changes: 21 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -31,6 +33,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"`
}

// AttestationConfig governs the attestation verification subsystem. The
Expand Down Expand Up @@ -200,6 +209,18 @@ func (c *Config) Validate() error {
if err := validateWIMSEDomain(c.WIMSEDomain); err != nil {
return fmt.Errorf("wimse_domain: %w", err)
}
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
}

Expand Down
160 changes: 160 additions & 0 deletions docs/identity-model.md
Original file line number Diff line number Diff line change
@@ -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 = <upstream IdP's ID token>
subject_token_type = urn:ietf:params:oauth:token-type:id_token
account_id = <tenant>
project_id = <tenant>
scope = <optional>
```

`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 = <pre-validated jwt> # NOT re-verified by ZeroID
account_id = <tenant>
project_id = <tenant>
user_id = <external 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: <service-name>` 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.
139 changes: 139 additions & 0 deletions domain/external_issuer.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading