Appearance
Identity and IdP bindings — OIDC, claim mapping, API tokens
This document is the authoritative bounded-context reference for the per-Domain IdP binding, User, UserSession, ServiceIdentity, and APIToken aggregates that live under internal/identity/{idp,users,services,tokens,authn,events}. It covers the operator onboarding runbook, the claim-mapping rules the OIDC adapter applies to every id_token, worked acr / amr step-up examples, the API-token format / rotation / revocation contract, and the invariant-to-test matrix that ties every requirement to at least one automated test.
For the bounded-context siblings see tenancy.md (Domain, Project, Resource, Node aggregates under internal/identity/tenancy) and ../../contributing/layout.md (the repository-layout context map). For the persistent schema the identity aggregates write through see the Identity schema section of the internal/platform/db reference. For the OIDC adapter's refresh policy, unknown-kid behaviour, and metrics see internal/identity/idp/oidc/doc.go.
Runbook
This runbook walks an operator through registering, verifying, rotating, and retiring a per-Domain IdP binding. Every step emits either an audit event (identity.IdPBinding* in plexsphere.outbox_events) or a log line carrying the identity feature tag so the procedure is fully traceable.
1. Provision the upstream client
The operator provisions an OIDC code client on the upstream IdP (Entra ID, Okta, Keycloak, Dex, Google Workspace, …) with:
- Redirect URI:
https://<tenant-host>/v1/auth/callback. - Response type:
codewith PKCE (S256). - Required scopes:
openid profile email groups. - ID-token claims:
sub,email,email_verified,groups, andacr/amrif the IdP asserts them (Entraacrvalues, Okta authenticator policies, KeycloakLoAtemplates).
The upstream client secret is stored in OpenBao (or the equivalent secret-store adapter) under a stable path; only the opaque reference (ClientSecretRef) ever enters the plexsphere.idp_bindings row. The aggregate rejects a binding that carries an empty or whitespace-only secret reference so a misconfigured OpenBao mount never silently produces a credentials-less binding.
2. Register the binding
Call POST /v1/admin/idp with the binding body. The handler calls idp.NewIdPBinding which enforces:
DomainIDnon-zero.IssuerandDiscoveryURLare absolutehttp/httpsURLs.ClientIDandClientSecretRefare trimmed, non-empty.ClaimMappingskeys and values are non-empty; an empty map is legal and means "use the default claim names".RequiredACRValuesandRequiredAMRValuesare trimmed and deduplicated.JITPolicy ∈ {allow, deny}, defaulting toallow.Status ∈ {active, inactive, degraded}, defaulting toactive.Aliasis optional. When set it is normalised to lowercase kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$, at most 63 characters) — an operator may submit mixed case such asGitHuband the aggregate storesgithub. An empty alias means "no alias".Primaryis optional and defaults tofalse. It marks the binding as the Domain default a Domain-only login resolves to.
The partial unique index idp_bindings_active_domain_issuer_uq serialises the (DomainID, Issuer) pair at the SQL layer — a concurrent second active binding for the same pair returns 409 Conflict . Two further partial unique indexes, scoped to status = 'active' the same way, enforce the alias and primary invariants: alias is unique per Domain among active bindings (a collision returns 409 alias-conflict), and at most one active binding per Domain may carry is_primary = true (a second returns 409 primary-conflict). Both markers are addressing state, not chain-of-custody material, so a deactivated binding neither blocks a new active alias/primary nor is resolved at login. Successful registration appends identity.IdPBindingRegistered to the outbox in the same transaction.
Alias, primary, and login resolution
A binding can be addressed three ways at login: by its UUID, by its alias, or implicitly through the Domain. The shared resolution helper both login surfaces (POST /v1/auth/sign-in and POST /v1/auth/device-code) route through applies the precedence explicit id > alias > primary > sole active:
- An explicit binding id resolves directly (and is cross-checked against
domain_idwhen both are supplied). - A binding alias resolves the active binding carrying that alias within the Domain; a miss returns
404 alias-not-found. - Otherwise the Domain's primary binding resolves, even when the Domain has several active bindings.
- With no primary designated, the sole active binding resolves; a Domain with two or more active bindings and no primary returns
400 multiple-bindingsuntil the caller pins one or the operator marks a primary.
The primary marker is enforce-only: promoting a second binding while one is already primary returns 409 primary-conflict, so the operator demotes the current primary (PATCH /v1/admin/idp/{id} with primary=false) before promoting another. The device-code surface accepts the alias via the idp_binding_alias request field (and the plexctl login --idp-binding <alias> flag); the browser sign-in surface resolves by id, primary, or sole binding only.
Pre-auth discovery for the dashboard chooser
The dashboard cannot recover from 400 multiple-bindings on its own — it has no session yet, so it cannot read GET /v1/admin/idp to learn a Domain's providers. The unauthenticated, display-safe surface GET /v1/auth/idp-bindings?domain_id=<uuid> closes that gap: it returns the exact candidate set the login resolver would consider on a Domain-only request — the Domain's own active bindings, falling back to the platform-scoped shared bindings only when the Domain owns none, ordered primary-first — so the chooser's buttons map one-to-one to the bindings a sign-in can actually resolve. Both the chooser list and the resolver consume the same in-process helper, so the page can never offer a provider that would dead-end on multiple-bindings or platform-binding-not-allowed.
Each entry carries only id, alias, label, issuer_host, primary, and platform_scoped — the secret-store reference, claim mappings, and ACR/AMR policy never reach this pre-auth surface. The endpoint always answers 200 (never 404) so it cannot be used to probe Domain existence: an unknown Domain id and a Domain with no own bindings both return the applicable platform set (or an empty list). The console then auto-proceeds for a single binding or a designated primary, and renders a chooser only for the two-or-more-with-no-primary case. See the /v1/auth reference for the wire shape.
Platform-scoped (shared) bindings
The same upstream IdP is often shared across many tenants. Rather than duplicating a binding per Domain, a binding can be platform-scoped: it has no owning Domain (domain_id is null) and is usable by any Domain. One GitHub OIDC app, registered once, can then authenticate users into more than one Domain.
The data model carries this as a nullable domain_id on the binding rather than a binding-to-Domain association table. The choice is deliberate. The requirement is "usable by any Domain", not selective sharing to a chosen subset; a join table would add a table, an aggregate, and a management surface for a capability nobody asked for, and the nullable column mirrors the existing blueprint_catalog_sourcesdomain_id idiom. A binding therefore has at most one parent Domain, relaxing the previous "exactly one" invariant. A platform binding cannot be primary — it has no Domain to be the default for — and the aggregate rejects that combination at the Build boundary.
Management is platform-scoped, consistent with how Clouds, Blueprints, and Secrets are platform-scoped: POST /v1/admin/platform-idp and GET /v1/admin/platform-idp (or plexctl platform-idp create / list) gate on the platform manage / read permissions rather than a per-Domain relation. Uniqueness is scoped to the platform rows: at most one active platform binding per issuer and per alias, enforced by the idp_bindings_active_platform_issuer_uq and idp_bindings_active_platform_alias_uq partial unique indexes.
A Domain's effective bindings are the union of its own bindings and the applicable platform bindings. The shared resolution helper and the per-Domain list surface (GET /v1/admin/idp) both fold platform bindings into that union, so a Domain that owns no binding still resolves the shared one on a Domain-only login. Precedence favours the Domain's own IdP — a Domain-only login resolves through the Domain's own active bindings and only falls back to the platform bindings when the Domain owns none. A Domain-owned primary or sole binding therefore wins over a shared binding (the aggregate forbids a platform primary), and registering a shared binding never turns a working single-binding Domain into an ambiguous multiple-bindings rejection. When a Domain-owned alias and a platform alias share a name, the Domain-owned one wins deterministically.
Although the per-Domain effective list folds shared bindings in, it redacts each platform row's client_secret_ref, claim mappings, and required ACR/AMR values: those are platform configuration, visible in full only through the platform-scoped list (GET /v1/admin/platform-idp), which gates on platform#read. A domain:<id>#read reader sees that a shared binding exists (its id, issuer, status, alias), not its secret-store reference or policy.
The four by-id routes — GET, PATCH, PATCH .../status and DELETE on /v1/admin/idp/{id} — serve a binding of either scope behind one URL, because a shared binding is administered through the same routes a Domain binding is. The gate target is therefore a property of the persisted aggregate, not of the route: the handler resolves the binding first, then gates a Domain binding on domain:<bindingDomainID> and a shared one on platform:plexsphere. Reads need read there, the three mutators manage. The emitted audit verb follows the same split, so a shared binding's administration is auditable apart from a Domain binding's even though both arrived through one URL.
A consequence worth stating plainly, because the console surfaces it: a domain:<id>#read reader finds shared bindings in their effective list and cannot open any of them. That is intended — the list answers "what can this Domain authenticate through?", while the detail is platform configuration. The console therefore renders such a row locked rather than as a link into a page that could only refuse.
Because a shared binding decouples "which IdP" from "which tenant", the login Domain stays the tenant selector: the just-in-time provisioned User joins the Domain the login request selected, not the binding's (it has none). The binding still supplies the claim mappings and ACR/AMR policy, which apply uniformly across every Domain that resolves through it — only the User's Domain differs. The same shared binding therefore provisions a distinct per-Domain User for the same upstream subject in each Domain it authenticates into. A platform binding named with no domain_id starts a platform-operator login (see the next section) on both login surfaces: the browser sign-in accepts a pin by id, and the device-code flow accepts a pin by id or by alias — a platform binding's alias is globally unique among active platform rows, so no Domain is needed to resolve it. A platform binding pinned by id against a Domain that owns an active binding of its own is rejected 400 platform-binding-not-allowed — a shared binding may not shadow a Domain's deliberately-configured IdP/ACR/AMR policy. And a deactivated binding pinned by id is rejected 404 binding_not_found, so deactivating a compromised binding closes the pin-by-id login path as well as the alias and Domain-only paths.
Platform-operator sign-in — Domain-independent login
A platform-shared binding also serves a second purpose beyond "one IdP for many Domains": it is the login home for Platform Operators, whose authority is platform-wide rather than tenant-scoped. A login that names a platform binding with no domain_id — via idp_binding_id on the browser sign-in, or via idp_binding_id / idp_binding_alias on the device-code flow — yields a Domain-independent platform session: whoami reports principal_scope: platform with domain_id omitted, and the resolved Principal carries no Domain. The operator's reach continues to come from the Domain-independent platform:plexsphere#admin ReBAC grant — the platform session confers nothing by itself. See cookie-session.md for the session shape and tenancy.md for the reserved platform-operators Domain that stores the operator's user row.
The chooser side is GET /v1/auth/idp-bindings?scope=platform: the same display-safe pre-auth surface, listing the active platform bindings so the console can render an operator chooser with no Domain selected — and so plexctl login --platform can auto-select a sole binding or list the candidates. The scope takes precedence over a stray domain_id; the platform set is the same shared fall-back set any domain_id request already receives, so the listing discloses nothing new.
The path is fail-closed at every layer. A deployment whose composition root has not wired the reserved operator-scope Domain (auth.Deps.PlatformLoginDomainID) keeps the historical 400 — the production binary wires it whenever PLEXSPHERE_DSN is set. The device-code surface additionally demands its own explicit opt-in (auth.Deps.PlatformDeviceLogin, wired from PLEXSPHERE_AUTH_PLATFORM_DEVICE_LOGIN=true, default off): initiation there is anonymous, so without the opt-in any caller could start a platform-scoped authorization and phish an operator into approving it (RFC 8628 §5.4) — an un-opted deployment keeps the platform shapes at the historical 400 on that surface while the browser sign-in stays available. And the device-code surface adds a rejection of its own the browser contract lacks: a per-Domain binding named with no domain_id stays 400 bad-request (the browser sign-in would fall back to the binding's own Domain) — a forgotten --domain-id on the CLI must not silently become a login into whatever Domain the pinned binding belongs to. The device grant is additionally gated by a scope match on approval: the approving cookie session's scope must equal the scope the device authorization requested, in both directions, or the approval is refused 403 device-scope_mismatch — a tenant session can never mint a platform-requested CLI token and vice versa. The /v1/device approval page states clearly when a platform-scoped authorization is being approved and drives the platform sign-in for a signed-out (or scope_mismatched) operator.
Provisioning differs from a tenant login in two deliberate ways. First, the callback maps the operator through claims.MapPlatform, which namespaces the stored ExternalSubject with the resolving binding's id (<binding-id>/<sub>): every platform binding provisions into the same reserved Domain, and OIDC guarantees sub uniqueness only within one issuer, so keying rows on the raw sub alone would let two platform IdPs that emit the same sub string collapse onto one user row — and onto its platform grants. Second, the IdP groups claim is not synced for a platform login: the reserved Domain carries no group memberships by contract, and syncing an IdP-controlled claim into the shared scope would turn the first group-to-role mapping created there into a self-service privilege grant.
For the very first operator on a fresh deployment — before any admin session exists to call POST /v1/admin/platform-idp — the main binary seeds one platform binding from the PLEXSPHERE_PLATFORM_IDP_* environment (see the platform-operator sign-in how-to).
Runbook — share one IdP across two Domains
shell
# 1. Register the shared binding once (no --domain-id, no --primary).
plexctl platform-idp create \
--server "${PLEXSPHERE_URL}" \
--client-id plexsphere-shared \
--client-secret @/run/secrets/github-client.secret \
--discovery-url https://github-idp.example.com/.well-known/openid-configuration \
--issuer https://github-idp.example.com/ \
--jit-policy allow \
--alias github
# 2. Each Domain that owns no binding resolves the shared one on a
# Domain-only login; the user lands in the login Domain.
plexctl login --server "${PLEXSPHERE_URL}" --domain-id "${DOMAIN_A}"
plexctl login --server "${PLEXSPHERE_URL}" --domain-id "${DOMAIN_B}"3. Verify the binding
After registration the adapter issues a discovery probe against DiscoveryURL and a JWKS probe against the published jwks_uri. The outcome is visible in two places:
plexsphere.idp_bindings.status— transitions todegradedwhen the cold-cache refresh fails (operator alert surface).plexsphere_oidc_jwks_refresh_total{outcome=…}counter —success/failure/unknown_kidlabels (dashboard surface).
A degraded binding emits identity.IdPDiscoveryStale into the outbox so the audit log records every operator-visible failure. Re-running the probe by calling PATCH /v1/admin/idp/{id}/status with status=active after the upstream is reachable resets the cache on the next sign-in attempt.
4. Sign a test user in
Drive POST /v1/auth/sign-in followed by GET /v1/auth/callback with a test account. The happy path writes three rows in one transaction:
plexsphere.users— the JIT-provisioned User (whenJITPolicy=allowand the subject is new) or an updatedlast_sign_in_aton the existing row.plexsphere.user_sessions— the server-side SHA-256 of the session cookie plus the assertedacr/amr.plexsphere.outbox_events— one ofUserProvisioned+UserSignedIn(first sign-in) or justUserSignedIn(repeat).
If JITPolicy=deny and the subject is unknown the handler returns 401 without touching plexsphere.users — a pre-sync is required.
JIT acceptance fork
When the OIDC callback has resolved the verified id_token onto a users.User via claims.Map and persisted the row through Users.UpsertWithBinding, the handler asks the InvitationConsumer (internal/identity/invitations/consumer/) whether a pending Invitation exists for the (DomainID, ExternalSubject) pair the upsert just keyed on. The fork runs at the point marked by the // DECISION: block that wires the InvitationConsumer in internal/transport/http/v1/auth/callback.go, strictly between the User upsert and RecordSignIn, and follows the behaviour matrix below:
- Pending invitation, unexpired. The consumer calls
InvitationRepo.AcceptDuringSignInwhich (1) runs the CASUPDATE plexsphere.invitations SET status='accepted', accepted_at=$now, accepted_user_id=$uid WHERE id=$id AND status='pending'on the row and (2) appends a singleInvitationAcceptedoutbox event whose payload carries the denormalisedtuple_objectsarray (one entry perInitialTuplecarried by the aggregate) — all inside the SAME repo transaction. The downstream relation-tuples projector consumes that sameInvitationAcceptedevent, readstuple_objectsoff the payload, and writes one SpiceDB tuple per entry, so the projector observes the status flip and the seed tuples as a single logical batch. There is no per-tupleRelationTupleEnqueuedoutbox event — theInvitationAcceptedrow is the projector's input. TheUserupsert ran in its own earlier transaction (the auth callback'sUsers.UpsertWithBindingopens and commits its own tx); the consumer threads the now-knownAcceptedUserIDinto the CAS predicate so the row records the acceptor without the literal shared-tx refactor that is out of scope at this level (mirrors the review-driven DECISION on the groups syncer). - Pending invitation, already elapsed under the lock. The consumer treats the row as terminal and returns
Accepted=falsewith a nil error so the callback silently falls through toRecordSignIn. The eager flip tostatus=expiredis intentionally deferred to the sweeper — see the deferred-flip DECISION block onconsumer.AcceptIfPendingfor the full rationale (the sweeper is the single source of truth for "row has been observed as expired", andconsumer.AcceptIfPendingis the single source of truth for "this sign-in did not consume an invitation"). The User upsert still persists; the audit trail records the rejected attempt through the failure-leg audit row when the rejection is service- layer (seeinvitations.mdfor the full state machine). - Already-accepted / already-revoked sentinel. Same fall-through posture as the elapsed-pending arm —
Accepted=false, nil error, callback continues with the plain sign-in path. The terminal-state sentinels are absorbed silently because the row's lifecycle has already been decided by an earlier operator action. - No pending row.
GetPendingByDomainSubjectreturnsinvrepo.ErrNotFound; the consumer short-circuits toAccepted=falseand the callback proceeds with the existing plain sign-in path unchanged.
A non-sentinel repo error from the consumer surfaces as 500 invitation-accept so a flaky persistence backend never hides behind a successful sign-in. The fork composes the *invitations/repo.InvitationRepo directly rather than routing through the application service (InvitationService.AcceptDuringSignIn) so the InvitationAccepted audit emission is not bound to the literal "unknown" subject fallback at the OIDC bootstrap moment — see the consumer DECISION block. The InvitationAccepted outbox event already carries accepted_user_id and the timestamp transactionally, so the audit pipeline reconstructs the operator context downstream from there. The full state machine (pending → accepted | revoked | expired), the per-Domain default TTL, the partial-unique-index contract, and the InitialTuples object-scope rule (domain:<this-id> / project:<uuid> / group:<uuid>) live in the bounded-context reference at invitations.md.
5. Rotate the upstream client secret
Rotation is a three-step procedure so the rotation never carries downtime (the "rotate a binding without downtime" scenario):
- Register a replacement binding with the new
ClientSecretRefand a distinctIssuerquery parameter (for example a new Entra app registration). The old binding staysactiveso in-flight sign-ins complete. - Cut traffic over on the IdP's end; watch
plexsphere_oidc_jwks_refresh_total{outcome="success"}against the new binding rise. - Call
PATCH /v1/admin/idp/{id}/statuswithstatus=inactiveon the old binding. The partial unique index now lets a future registration reuse the original(domain_id, issuer)tuple without tripping409 Conflict— deactivated rows are preserved for audit history but excluded from the uniqueness check.
6. Retire a binding
DELETE /v1/admin/idp/{id} is only valid when the binding is already inactive or degraded and no User has referenced it in the session ledger within the retention window. The handler emits identity.IdPBindingDeactivated into the outbox. The row itself is only dropped after audit retention elapses; immediate deletion is rejected to preserve sign-in forensics.
Claim Mapping
Claim mapping is the pure function idp/claims.Map that translates a verified OIDC id_token into a users.User aggregate. Determinism is a sign-in correctness property — the same (Binding, IDTokenClaims) pair always produces the same User fields (the User's freshly-minted UUIDv7 ID is unavoidably per-call).
Lookup order
For each local User field the adapter consults, in order:
- The binding's
ClaimMappingstable. Keys are the local field name (email,email_verified,groups,sub,acr,amr); values name the upstream id_token claim. - If no override exists, the default upstream claim name from
claims/claims.go(sub→sub,email→email, and so on). - When the override does exist, the typed field is ignored and the raw decoded id_token payload (
IDTokenClaims.Raw) is consulted. Non-string / non-bool / non-slice values fall back to the typed field's zero value so a misconfigured binding cannot erase a perfectly good claim.
Worked example — Entra ID with custom group claim
yaml
# binding.claim_mappings
groups: "wids" # Entra emits group object-IDs under the
# wids claim for application-role mapping
email: "preferred_username" # Entra's email is under preferred_username
# on consumer tenantsGiven the id_token payload
json
{
"sub": "ea60…-b5",
"preferred_username": "ada@contoso.com",
"email_verified": true,
"wids": ["62e90394-…", "f28a1f50-…"],
"acr": "phr",
"amr": ["pwd", "mfa"]
}claims.Map produces a users.User with ExternalSubject = "ea60…-b5", Email = "ada@contoso.com", EmailVerified = true, and Groups = ["62e90394-…", "f28a1f50-…"]. The aggregate trims, deduplicates, and rejects whitespace-only entries before the row lands in plexsphere.users.groups.
Error modes
MissingClaimError{Claim:"sub"}— whitespace-only or absent subject; sign-in handler returns401, no row is written.StepUpRequiredError{…}— see the next section. Emitsidentity.UserAuthenticationStepUpRequiredto drive the frontend challenge.users.ErrInvariant— a downstream invariant (e.g. malformed email) rejected byusers.NewUser; surfaced unchanged so the original trace is preserved.
acr/amr Examples
RequiredACRValues and RequiredAMRValues on an IdPBinding apply set semantics: if the list is non-empty, at least one of its entries must appear in the id_token's presented values. Both lists are independent — an ACR-only policy does not imply an AMR check, and vice versa.
Example 1 — ACR-only policy, passing
yaml
binding.required_acr_values: ["phr", "phrh"] # Entra "phishing-resistant"
binding.required_amr_values: []
id_token.acr: "phrh"
id_token.amr: ["pwd", "hwk"] # hardware-bound keySet intersection on ACR is non-empty (phrh) → no step-up. AMR is not checked because the binding list is empty. UserSignedIn fires with acr="phrh" and amr=["pwd","hwk"].
Example 2 — ACR mismatch, step-up required
yaml
binding.required_acr_values: ["http://schemas.openid.net/pape/policies/2007/06/multi-factor"]
binding.required_amr_values: []
id_token.acr: "urn:oasis:names:tc:SAML:2.0:ac:classes:Password"
id_token.amr: ["pwd"]No intersection → StepUpRequiredError with RequiredACR = ["http://schemas.openid.net/pape/policies/2007/06/multi-factor"] and PresentedACR = ["urn:oasis:names:tc:SAML:2.0:ac:classes:Password"]. The handler emits identity.UserAuthenticationStepUpRequired and returns 401 Unauthorized with the RFC 9470 §3 step-up challenge WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="<required-space-joined>" so an OIDC client can drive a correct re-authentication request. AMR-only policies emit the bare Bearer error="insufficient_user_authentication" challenge because RFC 9470 does not define an amr_values parameter. No row is written to plexsphere.users or plexsphere.user_sessions.
Example 3 — AMR-only policy, hardware-bound key
yaml
binding.required_acr_values: []
binding.required_amr_values: ["hwk", "swk"] # hardware or software key
id_token.acr: "phr"
id_token.amr: ["pwd", "mfa"] # password + OTP, no keyNo intersection on AMR → step-up required. The FIDO2 / WebAuthn challenge is driven out-of-band by the frontend; the handler will accept the subsequent sign-in attempt once the IdP re-issues an id_token carrying amr ⊇ {hwk} or {swk}.
Example 4 — multivalued ACR in a single string
Some IdPs (notably Google Workspace) deliver acr as a single space-separated string. The claims adapter accepts either shape: []string, []any, or a single string. In the last case the string is wrapped into a one-element slice before the set intersection check runs, so acr: "urn:mace:incommon:iap:silver" is treated identically to acr: ["urn:mace:incommon:iap:silver"].
Step-up handling
An elevated, ACR-gated action (today: acknowledging an integrity violation) answers an insufficient session with 401 carrying the required acr_values. A browser client implements the step-up round-trip as follows:
acr_valuesis an untyped RFC 9457 extension on theapplication/problem+jsonbody — neither the OpenAPIProblemschema norSignInRequestdeclares it — so the client parses it defensively off the raw error object, preserving unknown Problem members rather than dropping them.- The client inspects each
401. One carryingacr_valuesopens a re-auth confirmation prompt; a bare401(noacr_values) is a normal auth challenge and is ignored. - On confirm the client begins a step-up
POST /v1/auth/sign-incarrying the capturedacr_values(sent as an additive body field at the call boundary), redirects the browser to the returnedauthorization_url, and replays the original operation exactly once after the round-trip.
API Tokens
Plexsphere-issued API tokens (psk_…) are the machine-to-machine credential the dashboard, plexd CLI, and third-party automation use. They are owned by exactly one of {User, ServiceIdentity} — enforced at the aggregate boundary by IdentityRefForUser / IdentityRefForService and at the SQL layer by the api_tokens_exactly_one_owner CHECK.
Plaintext format
text
psk_<env>_<id-base32>_<random-base32>- Constant prefix
psk_so credential scanners grep for a single literal. <env>— lowercase ASCII letter run^[a-z]+$(for exampleprod,staging,dev). The operator picks the value at mint time.<id-base32>— the owning APIToken's UUIDv7, RFC 4648 base32 (no padding) case-folded to lowercase.<random-base32>— 16 cryptographically random bytes (128 bits of entropy, well above the 96-bit floor the security baseline requires) base32-encoded.
The canonical regex ^psk_[a-z]+_[a-z2-7]+_[a-z2-7]{20,}$ is applied on every ValidatePlaintext call so operator CLI tooling and the authn middleware share one source of truth. FormatPlaintext is the single mint path — callers never concatenate the segments by hand.
Hashing and verification
The plaintext is hashed immediately via Argon2id (HashAlgo = "argon2id" is the only accepted value; the SQL CHECK enforces it) using the parameters in tokens/argon2.go. Only the PHC-encoded digest ever reaches plexsphere.api_tokens.token_hash; the plaintext is returned to the caller exactly once in the POST /v1/auth/tokens response and discarded. Every subsequent introspection round-trips through VerifyHash(plaintext, stored) — constant-time by Argon2id's construction.
TTL and rotation window
MaxTTL = 90 days. AnExpiresAtmore than 90 days afterCreatedAtis rejected at Build time.RotationOverlap = 48 hours.APIToken.BeginRotation(now)setsRotationStartedAt = nowandSunsetAt = now + 48hatomically; setting one without the other is rejected so the Sunset header is always computable.- Inside the rotation window both the old and the new tokens authenticate.
POST /v1/auth/tokens/{id}/rotateresponds with the new plaintext and sets the response headerSunset: <RFC 1123 timestamp>on the old token's next use so HTTP clients can surface a deprecation warning (the "rotation creates overlap and sets Sunset header" scenario). - After
SunsetAtthe old token stops being accepted byIsAcceptable(now); subsequent calls fail with401 Unauthorizedand anidentity.APITokenRotatedevent already in the outbox to correlate the traffic shift.
Revocation
DELETE /v1/auth/tokens/{id} is immediate: the handler stamps RevokedAt = now, appends identity.APITokenRevoked to the outbox in the same transaction, and the authn middleware rejects every subsequent presentation within one pool round-trip (the "revocation is immediate" scenario). Revoking an already-revoked token is a no-op — APIToken.Revoke returns the aggregate unchanged rather than resetting the timestamp, so the audit trail remains linear.
Invariant Matrix
Every invariant the identity context enforces is backed by at least one automated test. When a row lists multiple enforcement layers, the later layers are belt-and-braces — the earlier one is authoritative.
| Invariant (REQ-id) | Enforced at | Test |
|---|---|---|
| IdPBinding: non-empty Issuer/DiscoveryURL, ClientID/SecretRef trimmed, ClaimMappings well-formed, JITPolicy ∈ {allow,deny}, Status ∈ | idp.NewIdPBinding + CHECK constraints on plexsphere.idp_bindings | internal/identity/idp/binding_test.go |
Only one active IdPBinding per (DomainID, Issuer) | Partial unique index idp_bindings_active_domain_issuer_uq on plexsphere.idp_bindings | internal/identity/idp/repo/repos_test.go + tests/integration/identity_idp_oidc_test.go |
IdPBinding alias normalised to lowercase kebab-case; unique per Domain among active bindings | idp.NormalizeAlias + partial unique index idp_bindings_active_domain_alias_uq | internal/identity/idp/binding_test.go + internal/identity/idp/repo/errors_test.go + tests/integration/binding_repo_alias_primary_test.go |
At most one active primary IdPBinding per Domain | Partial unique index idp_bindings_active_primary_domain_uq | internal/identity/idp/repo/errors_test.go + tests/integration/binding_repo_alias_primary_test.go |
| Login binding resolution precedence: explicit id > alias > primary > sole active | internal/transport/http/v1/auth/binding_resolution.go + idp/repo ResolveByAlias / GetPrimaryByDomain | internal/transport/http/v1/auth/binding_resolution_test.go + internal/transport/http/v1/auth/device_test.go + tests/e2e/identity/signin-roundtrip/chainsaw-test.yaml |
Platform-scoped binding: a NULL domain_id is a shared binding usable by any Domain; a platform binding cannot be primary | idp.NewIdPBinding (zero DomainID allowed; primary+zero rejected) + migration 0061_idp_binding_platform_scope.sql (domain_id drops NOT NULL) | internal/identity/idp/binding_test.go |
At most one active platform binding per issuer and per alias | Partial unique indexes idp_bindings_active_platform_issuer_uq / idp_bindings_active_platform_alias_uq discriminated by name in classifyPgError | internal/identity/idp/repo/errors_test.go + tests/integration/binding_repo_platform_test.go |
| A Domain's effective bindings = its own + the platform bindings; a Domain-owned alias shadows a platform alias of the same name | idp/repo ListEffectiveByDomain / GetEffectiveIdPBindingByAlias folded into binding_resolution.go and GET /v1/admin/idp | tests/integration/binding_repo_platform_test.go + internal/transport/http/v1/auth/binding_resolution_test.go + internal/transport/http/v1/admin/idp_platform_test.go |
| A shared binding's JIT-provisioned User joins the login-request Domain, not the binding's; the same binding authenticates more than one Domain | claims.Map (loginDomain parameter) threaded from resolveLoginBinding via SignInSession.DomainID | internal/identity/idp/claims/claims_test.go + tests/integration/identity_idp_platform_login_test.go |
Platform IdP binding create/list gate on the platform manage / read permissions | internal/transport/http/v1/admin/idp_platform.go | internal/transport/http/v1/admin/idp_platform_test.go |
The by-id binding routes gate on the binding's RESOLVED scope: domain:<bindingDomainID> for a Domain binding, platform:plexsphere for a shared one — never on the zero Domain a shared binding's NULL domain_id would yield | internal/transport/http/v1/admin/idp_gate.go consumed by the four by-id handlers in idp.go | internal/transport/http/v1/admin/idp_gate_test.go + tests/integration/admin_platform_idp_gate_test.go |
| Discovery/JWKS refresh: TTL-driven, unknown-kid triggers exactly one refresh, outcome counter exposed | internal/identity/idp/oidc/adapter.go + jwks.go + metrics.go | internal/identity/idp/oidc/adapter_test.go, jwks_test.go, discovery_test.go |
Unreachable DiscoveryURL transitions binding to degraded and emits IdPDiscoveryStale | OIDC adapter failure path + repo's DeactivateBinding helper | internal/identity/idp/oidc/adapter_test.go + tests/integration/identity_idp_oidc_test.go |
Claim-map: missing/empty sub is rejected, overrides route through Raw, email downgrade on email_verified=false | idp/claims.Map | internal/identity/idp/claims/claims_test.go |
| ACR/AMR set-intersection step-up policy | idp/claims.enforceStepUp + StepUpRequiredError | internal/identity/idp/claims/claims_test.go + tests/integration/identity_idp_oidc_test.go |
| User: DomainID non-zero, ExternalSubject trimmed/non-empty, Email well-formed when present, Groups deduplicated | users.NewUser + UNIQUE (domain_id, external_subject) on plexsphere.users | internal/identity/users/user_test.go + internal/identity/users/repo/repos_test.go |
JIT provisioning idempotent on repeat sign-in; JITPolicy=deny refuses unknown subject | users/repo UpsertUser + RecordUserSignedIn | tests/integration/identity_idp_oidc_test.go + tests/e2e/identity-idp/chainsaw-test.yaml |
| Session hash storage: only SHA-256 of the cookie value persisted | authn middleware + plexsphere.user_sessions.session_hash bytea UNIQUE | internal/identity/authn/middleware/session_test.go + internal/identity/users/repo/repos_test.go |
Per-route ACR/AMR enforcement via authn.RequireACR / RequireAMR decorators | internal/identity/authn/middleware | internal/identity/authn/middleware/decorators_test.go |
Device-code flow (RFC 8628): authorization_pending, slow_down, expired_token taxonomy | internal/transport/http/v1/auth/device_code.go + device_token.go | tests/integration/identity_device_code_test.go |
Device-flow approval surface: server-hosted GET /v1/device page -> cookie-authenticated, CSRF-guarded POST /v1/auth/device/approve flips DeviceSession.Status=approved, pins UserID, and emits a DeviceApproved outbox row in the same transaction | internal/transport/http/v1/auth/device_page.go + device_approve.go | internal/transport/http/v1/auth/device_page_test.go + device_approve_test.go + tests/integration/auth_device_page_test.go + tests/integration/auth_device_approve_test.go + tests/e2e/identity/signin-roundtrip/chainsaw-test.yaml |
ServiceIdentity FederationKind pin (oidc_cc, spiffe_svid, api_token); mismatched credential rejected without inspection | services.NewServiceIdentity + CHECK on plexsphere.service_identities.federation_kind | internal/identity/services/service_test.go + tests/integration/identity_service_token_test.go |
SPIFFE bundle resolution: per-binding cache keyed on IdPBinding.ID(), JWKS-shaped trust bundle fetched from binding.SPIFFEBundleURL, signature + standard claims (exp/iat/nbf/optional iss/aud) verified with 60s leeway, unknown-kid retries refresh exactly once | internal/identity/spiffe.Adapter + internal/transport/http/v1/auth/service_token.go (serviceTokenJWTBearer) | internal/identity/spiffe/adapter_test.go + internal/transport/http/v1/auth/service_token_test.go (TestServiceToken_SPIFFEJWTBearer_*) + tests/integration/identity_service_token_test.go |
APIToken plaintext format psk_<env>_<id>_<rand> enforced at issuance and verify | tokens.FormatPlaintext + ValidatePlaintext + ParsePlaintext | internal/identity/tokens/token_test.go |
| APIToken XOR ownership (User XOR ServiceIdentity) | IdentityRefForUser / IdentityRefForService + api_tokens_exactly_one_owner CHECK | internal/identity/tokens/token_test.go + tests/integration/identity_api_token_test.go |
| APIToken 90-day MaxTTL cap | tokens.buildAPIToken | internal/identity/tokens/token_test.go |
| APIToken 48-hour rotation overlap with Sunset header | APIToken.BeginRotation + issuer.Rotate + handler sets Sunset response header | internal/identity/tokens/token_test.go + tests/integration/identity_api_token_test.go |
| APIToken immediate revocation | APIToken.Revoke + issuer.Revoke + authn middleware check | internal/identity/tokens/token_test.go + tests/integration/identity_api_token_test.go |
| Every aggregate mutation appends exactly one matching outbox event in the same transaction | idp/repo, users/repo, services/repo, tokens/repo | internal/identity/{idp,users,services,tokens}/repo/repos_test.go + tests/e2e/identity-idp/chainsaw-test.yaml |
Identity aggregate packages free of pgx, sqlcgen, go-jose, net/http imports outside */repo/** and */middleware/** | depguard + tests/workspace/identity_persistence_isolation_test.go | tests/workspace |
All invariant errors carry the (REQ-xxx, PX-0008) suffix | idp.errInvariant, users.errInvariant, services.errInvariant, tokens.errInvariant, oidc.errUnknownKidf, claims.*Error | Every *_test.go in internal/identity/** asserts the suffix |
Identity schema migration up/down idempotent; five tables + FKs to plexsphere.domains | 0003_identity.sql + tests/integration/db_migrations_test.go | TestIdentityMigrations_UpDownIdempotent |
| JIT acceptance: pending invitation consumed atomically with User upsert in OIDC callback | internal/transport/http/v1/auth/callback.go + internal/identity/invitations/consumer/ | internal/transport/http/v1/auth/callback_test.go + internal/identity/invitations/consumer/consumer_test.go + tests/e2e/identity/invitations/chainsaw-test.yaml |
Cross-references
tenancy.md— sibling bounded-context reference for Domain, Project, Resource, Node aggregates underinternal/identity/tenancy.groups.md— sibling bounded-context reference for the Group, GroupMembership, and GroupParent aggregates underinternal/identity/groups. The IdP-synced Group path reuses the IdPBinding defined here to map OIDCgroupsclaims to memberships on every sign-in.invitations.md— bounded-context reference for the Invitation aggregate (pending → accepted | revoked | expired), the per-Domain default TTL, the partial-unique-index contract, and theInitialTuplesobject-scope rule. The JIT acceptance fork wired into the OIDC callback above is documented here;invitations.mdcovers the full state machine plus the expiry sweeper, the audit relations (invitation.create,invitation.list,invitation.read,invitation.revoke,invitation.accept,invitation.expire), and the HTTP surface (POST/GET/DELETE /v1/domains/{id}/invitations(/{invitation_id})) the operator drives Create / Get / List / Revoke through .rebac.md— sibling bounded-context reference for the SpiceDB-backed authorisation layer. Theauthn.Principalan IdPBinding resolves is translated into a SpiceDB subject by themiddleware.PrincipalMapperdocumented there; theacr/amrworked examples above feed therequires_assurancecaveat evaluated on every Check.../../contributing/layout.md— bounded-context map placinginternal/identityinside the repo.../../reference/platform/db.md— pgx pool, goose migrations, sqlc workflow, and the identity schema row the identity context writes through.../../contributing/openapi.md— the OpenAPI spec that hosts/v1/auth/**and/v1/admin/idp/**.internal/identity/idp/oidc/doc.go— godoc for the JWKS cache, unknown-kid policy, and refresh metrics.internal/identity/doc.go— bounded-context package doc forinternal/identity.internal/platform/db/migrations/0003_identity.sql— canonical schema for the five identity tables.tests/e2e/identity-idp/chainsaw-test.yaml— end-to-end suite that registers a binding, signs a user in, and rotates + revokes an API token.