Credential Issuance
Issues short-lived RS256-signed JWTs to agents bootstrapping against
Wardline, instead of trusting a raw X-Wardline-Identity header alone.
Enable with:
features:
credential_issuance: true
credential:
identities_file: "identities.yaml"
signing_key_file: "" # optional; see HA deployment for why this matters with >1 replica
access_token_ttl_seconds: 900 # optional, default 900 (15m)
refresh_token_ttl_seconds: 86400 # optional, default 86400 (24h)
Tokens can be revoked; revocation is checked on every request.
POST /credentials/token returns both an access token and a refresh
token. POST /credentials/refresh {"refresh_token": "..."} exchanges a
still-valid, not-yet-used refresh token for a new pair of both –
without re-presenting the original bootstrap credential – until the
refresh token itself expires or its identity is revoked. Refresh tokens
are single-use: each successful refresh rotates to a brand-new refresh
token, and the one just redeemed can never be used again.
Known limitations
- IdP-backed bootstrap is OIDC only (
credential.bootstrap_source: oidc, no discovery-document fetching) — see SSO. No other IdP protocol (SAML, generic non-OIDC federation) is supported, and mTLS/SPIFFE bootstrap (credential.bootstrap_source: mtls) trusts a single static header — see mTLS/SPIFFE Bootstrap for its trust-boundary requirements before enabling it. - Refresh tokens rotate with reuse detection and family revocation:
each bootstrap starts a token family, every rotation carries it
forward, and a redeemed token is kept (marked consumed) rather than
deleted so a later replay is detectable. Replaying an
already-consumed token is treated as a theft signal — the entire
family (the legitimate current token included) is revoked atomically,
a
SECURITY: refresh token reuse detectedline is logged, and the caller gets the same generic401as any other rejection (no oracle). The one residual: access tokens already minted in that family are not force-revoked on reuse; they expire on their own short TTL (access_token_ttl_seconds, default 15m). - Revocation state is per-process unless
postgres_storageis also on (see HA deployment). - Revocation is keyed by
(tenant, identity), with one residual wildcard gap. Identity names are per-tenant-unique (two different IdPs or twocredentials.yamlentries can legitimately both provision “alice”, one per tenant); the revocation store (RevocationList/PostgresRevoker, and theRevoker.IsRevokedcall inVerificationService.Authenticate) now scopes both the write and the check to the target identity’s own tenant, resolved at revoke time — no Postgres schema migration needed (PostgresRevokerencodes(tenant, identity)into its existingidentityprimary-key column via a length-prefixed key rather than adding a column). The gap that remains: when a target identity’s tenant cannot be resolved at revoke time, the revoke falls back to the pre-scoping wildcard behavior — revoking every tenant’s copy of that identity name at once, the same as before this cycle’s fix. This is not an OIDC-only gap: it’s reachable under any of the three bootstrap sources. Withbootstrap_source: oidc, tenant lookup always fails (no static identity registry exists to look a target up in). With the preshared-secret and mtls bootstrappers, lookup normally succeeds fromcredentials.yaml— but fails the same way whenever the target identity name (preshared-secret) or its mapped SPIFFE ID’s identity (mtls) is registered under two or more distinct tenants (Bootstrapper.TenantOfandMTLSBootstrapper.TenantOfboth deliberately fail closed to “unresolved” on that ambiguity, rather than guessing which tenant’s copy to scope to) — precisely the “twocredentials.yamlentries legitimately provision the same name” scenario described above. A caller holding a globalcredential:revokegrant (see RBAC) can trigger this wildcard fallback under any bootstrap source, with no OIDC involved.