SSO
A second Credential Issuance bootstrap
adapter: instead of a preshared secret matched against
credentials.yaml, the presented secret is treated as a raw OIDC ID
token, verified against an IdP’s JWKS. Unlike a credentials.yaml
entry (which defaults to tenant "default" when it omits one), an
SSO-sourced identity’s tenant has no default — a token missing the
configured tenant claim is rejected outright, not silently placed in
"default".
There is no separate sso feature flag — enable it by pointing
credential_issuance’s existing bootstrap source at oidc:
features:
credential_issuance: true
credential:
identities_file: "credentials.yaml" # still required even for oidc -- see note below
bootstrap_source: "oidc" # "presharedsecret" (default) | "oidc" | "mtls"
oidc:
issuer: "https://idp.example.com/"
jwks_uri: "https://idp.example.com/.well-known/jwks.json"
audience: "wardline"
identity_claim: "sub" # optional, default "sub"
tenant_claim: "tenant" # optional, default "tenant" -- required present on every token
credential.identities_file is required whenever features.credential_issuance
is on, regardless of bootstrap_source — a non-obvious quirk worth calling
out: even when every identity comes from the IdP via oidc, config
validation still requires this path to be set (it’s simply unused by the
OIDC bootstrapper itself).
More than one IdP: oidc_providers
credential.oidc (above) configures exactly one issuer. For more than
one — a real multi-tenant deployment where different tenants federate
through different IdPs — use credential.oidc_providers instead (mutually
exclusive with oidc; setting both is a config error):
credential:
identities_file: "credentials.yaml"
bootstrap_source: "oidc"
oidc_providers:
- issuer: "https://acme.okta.com/"
audience: "wardline"
tenant_claim: "tenant" # acme's tokens carry their own tenant claim
- issuer: "https://login.microsoftonline.com/widgets-inc/v2.0"
audience: "wardline"
tenant_claim: "tid" # widgets-inc's IdP names its tenant claim differently
Each entry is independently verified — its own JWKS (or discovery, same
jwks_uri-optional rule as the single-provider form above), its own
audience, its own identity_claim/tenant_claim. An incoming ID
token is routed to the right provider by its own iss claim before
verification — the same issuer-based routing every real multi-tenant
SSO gateway uses (Auth0’s multi-organization routing, Okta’s multi-IdP
routing rules, Azure AD B2C’s identity-provider selection): the router
only reads that one claim to pick which provider’s Authenticate runs
next, and that provider still fully re-verifies the token’s signature
against its own real JWKS and re-checks the issuer itself — a token
whose iss claim doesn’t match the key that actually signed it is
rejected exactly as it would be without multi-provider routing at all.
Two providers may not declare the same issuer (ambiguous routing,
rejected at config-validate time, not a runtime coin flip).
Authenticate verifies the token’s signature against jwks_uri (keys
cached and refreshed every 15 minutes), checks iss/aud/exp, then
reads identity_claim (sub by default) and tenant_claim for the
resolved identity and tenant. Any failure — bad signature, wrong
issuer/audience, expired token, or a missing/empty tenant claim — is a
generic 401, the same non-enumerable-failure posture as a rejected
preshared secret.
A token whose kid isn’t in the cached JWKS (an IdP rotated its signing
key since the last 15-minute refresh — real IdPs add a new key well
before removing the old one, exactly the overlap window Microsoft’s own
Azure AD key-rollover
guidance
describes handling this way) triggers one forced, rate-limited JWKS
refresh and a single retry before falling back to the generic 401 —
not a bare rejection until the next scheduled refresh. The rate limit
(at most one forced refresh per 30 seconds, across every concurrent
caller) bounds the cost so a flood of tokens signed with a genuinely
unknown key can’t turn every rejection into its own request against the
IdP. aud is accepted whether the token encodes it as a single string
or an array containing wardline’s configured audience alongside others
(RFC 7519 §4.1.3 permits both forms; real IdPs use each).
wardline validate-config attempts to construct the OIDC bootstrapper
when bootstrap_source: oidc — the same construction wardline serve
itself does at startup. The underlying JWKS client
(lestrrat-go/jwx/v3/jwk.Cache) waits for its first successful fetch,
but that wait is bounded by a 10-second internal timeout: an
unreachable, refused, 404, or otherwise-broken jwks_uri produces the
intended soft warning at validate time (or serve’s intended fail-fast
exit) within that bound rather than hanging.
Known limitations
issuerandaudiencemust be configured explicitly.jwks_uriis optional — leave it unset and Wardline resolves it at startup fromissuer’s own/.well-known/openid-configurationdiscovery document (standard OIDC discovery, every major IdP implements it), validating the document’s ownissuerfield matches before trusting itsjwks_uri. Setjwks_uriexplicitly to skip discovery entirely — an IdP with a non-standard or unreachable discovery endpoint, or an operator who prefers to pin the value.- Cross-tenant credential-revoke scoping falls back to requiring a
global
ClusterRoleBindinggrant for every revoke when this bootstrap source is active — and the same fallback also applies to the preshared-secret bootstrap source whenever a target identity name is registered in more than one tenant — see RBAC’s known limitations. - This bootstrap source’s real-load testing (see Benchmarks) was proven against a spec-compliant mock IdP built for that testing pass — real OIDC discovery, real JWKS fetch and signature verification, real issuer/audience checks — not against a specific vendor’s OIDC implementation (Okta, Entra ID, Auth0, etc.). Validating against your actual IdP before go-live remains an operator step; this feature’s correctness against the OIDC spec itself is proven, but a given vendor’s own quirks (clock skew tolerance, non-standard claim names, token lifetime conventions) are not something a mock can stand in for.