Appearance
Auth HTTP API
This is the reference for the /v1/auth HTTP surface. It maps each operation to its OpenAPI schema, the authentication artefacts it consumes or produces, and the closed Problem.code set the handlers emit. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract — for the bounded-context narrative (the per-Domain IdP binding model, JIT user provisioning, session-cookie posture, ServiceIdentity federation) see ../../contexts/identity/idp.md; for operator recipes that exercise the surface see ../../how-to/identity/rotate-service-tokens.md.
The /v1/auth/sign-in, /v1/auth/idp-bindings, /v1/auth/callback, /v1/auth/device-code, /v1/auth/device-token, and /v1/auth/service/token endpoints sit on the authn middleware bypass list — by construction, a caller without credentials must be able to reach them in order to obtain credentials in the first place. The token-management family (/v1/auth/tokens, /v1/auth/tokens/{id}, /v1/auth/tokens/{id}/rotate, /v1/auth/whoami) requires the caller to already be authenticated; DELETE /v1/auth/whoami is unauthenticated and idempotent on purpose. POST /v1/auth/device/approve requires the interactive plexsphere_session cookie (it is the approval leg the server-hosted /v1/device verification page drives); POST /v1/auth/sign-out/global likewise requires an interactive session and rejects api_token principals.
Operations
| Method | Path | Operation ID | Authn requirement | Audit relation | Notes |
|---|---|---|---|---|---|
| POST | /v1/auth/sign-in | PostAuthSignIn | (none — issuance) | auth.sign_in.start | Returns the IdP authorization URL plus the opaque correlation handle the callback consumes. |
| GET | /v1/auth/idp-bindings | GetAuthIdPBindings | (none — discovery) | (none) | Lists a Domain's effective active IdP bindings (its own, falling back to platform-scoped shared bindings when it owns none), display-safe, primary-first. Powers the dashboard sign-in chooser before any session exists. Always 200 with Cache-Control: no-store. See the discovery-surface note below. |
| GET | /v1/auth/callback | GetAuthCallback | (none — issuance) | auth.sign_in.complete | Browser-leg: 303 to / with plexsphere_session cookie. JSON-leg failure: RFC 7807 Problem with original status. |
| POST | /v1/auth/device-code | PostAuthDeviceCode | (none — issuance) | auth.device_code.start | RFC 8628 device-authorization request. idp_binding_id and idp_binding_alias are optional and mutually exclusive; with the precedence explicit id > alias > primary > sole active, an omitted binding resolves the Domain's primary then its single active binding. |
| POST | /v1/auth/device-token | PostAuthDeviceToken | (none — polling) | auth.device_code.poll | RFC 8628 polling with authorization_pending / slow_down / access_denied / expired_token / invalid_grant taxonomy. |
| POST | /v1/auth/device/approve | PostAuthDeviceApprove | plexsphere_session cookie + CSRF | auth.device_code.approve | Flips a pending DeviceSession (looked up by user_code) to approved and pins the authenticated principal; the next device-token poll mints a token bound to that user. |
| POST | /v1/auth/service/token | PostAuthServiceToken | client assertion (RFC 6749 §4.4 client_credentials or RFC 7523 JWT-bearer with a SPIFFE JWT-SVID) | auth.service_token.issue | Mints an access token for a registered ServiceIdentity; both grants emit one identity.ServiceIdentityAuthenticated outbox row. |
| POST | /v1/auth/tokens | PostAuthTokens | session or API token | auth.api_token.issue | Mints a psk_<env>_<id>_<random> API token; plaintext is returned exactly once. |
| GET | /v1/auth/tokens | GetAuthTokens | session or API token | auth.api_token.list | Returns summaries only — plaintext is never re-emitted after issuance. |
| DELETE | /v1/auth/tokens/{id} | DeleteAuthTokenByID | session or API token | auth.api_token.revoke | Immediate revocation, no grace period. |
| POST | /v1/auth/tokens/{id}/rotate | PostAuthTokenRotate | session or API token | auth.api_token.rotate | New plaintext returned exactly once; the rotated-from token carries a Sunset header naming the RFC 8594 retirement deadline. Refuses a revoked or expired token with 410 token-revoked / token-expired and an already-rotated token with 409 token-already-rotated (rotate its successor instead). |
| GET | /v1/auth/whoami | GetAuthWhoami | session or API token | auth.whoami | Returns the resolved principal metadata for the current request. |
| DELETE | /v1/auth/whoami | DeleteAuthSession | (none — idempotent) | auth.session.terminate | Always 204, regardless of whether a session existed; clears the plexsphere_session cookie. |
| POST | /v1/auth/sign-out/global | PostAuthSignOutGlobal | plexsphere_session cookie (interactive only) | auth.session.terminate.global | Revokes every server-side session bound to the caller and clears the current cookie; emits one identity.UserSignedOut event carrying sessions_revoked=N. Rejects api_token principals with 403. |
| POST | /v1/admin/tokens | PostAdminTokens | plexsphere_session cookie (an API-token bearer gets 403 bearer-cannot-mint) + manage on the token owner's Domain | api_token.admin.issue | Cross-owner issuance: mints a psk_… for any identity_ref (user:<uuid> or service:<uuid>). Plaintext is returned exactly once. |
| GET | /v1/admin/tokens?identity_ref= | GetAdminTokens | session or API token + manage on the token owner's Domain | api_token.admin.list | Lists the addressed owner's tokens (summaries only). Gated on manage, not read, because enumeration is a precursor to revocation. |
| DELETE | /v1/admin/tokens/{id} | DeleteAdminTokenByID | session or API token + manage on the token owner's Domain | api_token.admin.revoke | Revoke any token; the lookup runs before the gate so a 404 is uniform across permitted and denied callers. |
| POST | /v1/admin/tokens/{id}/rotate | PostAdminTokenRotate | plexsphere_session cookie (an API-token bearer gets 403 bearer-cannot-mint) + manage on the token owner's Domain | api_token.admin.rotate | Rotate any token; the retiring plaintext is named in the RFC 8594 Sunset header. Refuses a revoked or expired token with 410 token-revoked / token-expired and an already-rotated token with 409 token-already-rotated (rotate its successor instead). |
The /v1/device verification page
GET /v1/device is a server-rendered HTML page, not a JSON operation: it is the RFC 8628 §3.3 verification_uri the device-code flow prints and the in-tree surface a human completes a device-grant approval on. It is deliberately outside the OpenAPI contract and the per-operation authn/authz/CSRF chain — it must be reachable signed out and detects the session itself. The page is presentation only: signed out it drives POST /v1/auth/sign-in (with return_to back to itself) to start the OIDC round-trip; signed in it drives POST /v1/auth/device/approve with the double-submit CSRF token. An external dashboard may override the verification_uri (PLEXSPHERE_AUTH_VERIFICATION_URL), but the self-hosted default ships with the server.
Authentication artefacts
The surface produces and consumes three credential shapes; the authn chain at internal/identity/authn/middleware/ accepts all three on a first-success-wins basis.
| Artefact | Issued by | Consumed as | Persistence |
|---|---|---|---|
plexsphere_session cookie | GetAuthCallback | Cookie request header on subsequent calls | Opaque server-side session record; HttpOnly, SameSite=Strict, Path=/v1/, Secure when TLS or X-Forwarded-Proto: https is honoured. |
| OIDC JWT | the per-Domain IdP via the device or browser flow | Authorization: Bearer <jwt> | Verified against the binding's JWKS; claims are projected onto the request Principal. |
API token (psk_<env>_<id>_<random>) | PostAuthTokens / PostAuthTokenRotate | Authorization: Bearer psk_… | Plaintext shown exactly once on issue/rotate; persistence stores only the prefix and HMAC fingerprint. |
The Sunset header on a rotation response names the deadline by which the rotated-from plaintext stops authenticating; the Sunset contract is RFC 8594. On a first issuance (PostAuthTokens) the header is absent.
Cross-owner admin path (/v1/admin/tokens)
The /v1/admin/tokens surface mirrors /v1/auth/tokens shape-for-shape (same APITokenIssueRequest / APITokenIssueResponse / APITokenSummary / APITokenRotateResponse schemas) but bypasses the self-only gate: an admin who holds manage on a Domain can mint, list, revoke, and rotate tokens on behalf of any user or service identity in that Domain, addressed by identity_ref (user:<uuid> or service:<uuid>). The production binary serves /v1/admin/tokens wherever it serves the rest of the admin surface (/v1/admin/groups, /v1/admin/idp); the endpoints have no configuration key of their own.
| Concern | Behaviour |
|---|---|
| Authz gate | manage against the Domain of the token's owner: the user or service identity identity_ref names on issue and list, the owner of the addressed token on rotate and revoke. A token authenticates as its owner, so the caller's own Domain is not the gate: a deployment always holds several Domains, including the reserved platform-operators Domain, and an admin of one tenant Domain is refused (403) for identities and tokens in any other, platform operators included. The owner is looked up before the gate, so a well-formed identity_ref that names no user or service identity answers 400 invalid-identity-ref. |
| Bearer callers | PostAdminTokens and PostAdminTokenRotate refuse a caller authenticated with an API token (a psk_ bearer) with 403 bearer-cannot-mint. Both operations return a fresh plaintext, so a leaked API token held by an admin could otherwise mint credentials for any identity in the Domain. The refusal happens before the request body or the addressed token is read and writes no audit row. GetAdminTokens and DeleteAdminTokenByID stay open to bearers: neither returns a secret, and revocation is the defensive action an automation needs. |
| Rotation refusals | PostAdminTokenRotate and PostAuthTokenRotate refuse a revoked token (410 token-revoked), an expired token (410 token-expired), and a token that has already been rotated (409 token-already-rotated; rotate its successor instead), in that precedence. The write that marks a token as rotated matches only a token that is still rotatable, so of two concurrent rotations of the same token one succeeds and the other gets 409. A rotation that loses to a concurrent revocation gets 410. |
| Summary state | Each APITokenSummary from GetAdminTokens and GetAuthTokens carries revoked_at once the token is revoked and sunset_at once it is rotated (the instant its plaintext stops authenticating), so a client can tell a revoked or retired token from a live one. A live, never-rotated token has neither key. |
| Audit | Every /v1/admin/tokens operation writes one api_token.admin.{issue,list,rotate,revoke} audit chain row: granted on success, permission_denied on a manage refusal. Rows reside on the chain of the token owner's Domain, the Domain the gate checked, so a platform operator's token rows land on the reserved platform-operators Domain. A bearer-cannot-mint refusal, an issue failure, and a token or owner lookup failure write no row. Successful issue, rotate, and revoke also append the issuer's outbox events (APITokenIssued, APITokenRotated, APITokenRevoked). |
| Listing gate | Uses manage, not read — enumerating another principal's tokens is a precursor to revocation and carries the same operational sensitivity. |
| 404 vs 403 | DeleteAdminTokenByID / PostAdminTokenRotate resolve the token before the gate so an unauthenticated callers cannot distinguish "token exists but I am denied" from "token never existed." The denial-path audit row stamps the resolved token:<id> so reviewers can correlate. |
Device-flow bearer format (final design)
PostAuthDeviceToken returns a ServiceTokenResponse whose access_token is a plexsphere-issued psk_<env>_<id>_<random> — exactly the same format PostAuthTokens returns. The CLI side (plexctl login) persists the value verbatim and presents it on subsequent calls as Authorization: Bearer psk_....
This is the final design for the v1 surface, not a placeholder for an upstream-issued OIDC bearer:
- A single bearer format across
/v1/auth/tokens, the device flow, and the service-token flow means one validation path in the AuthnMiddleware and one revocation surface (DELETE /v1/auth/tokens/{id}). An upstream-issued JWT would need a parallel JWKS-validation path plus a cross-system revocation RPC back to the IdP. - Continuity with the upstream OIDC subject lives at the User aggregate, not at the bearer format: device approval resolves the
Userauthenticated on the/v1/devicepage and thepsk_is bound to thatUser.ID.plexctl whoamireturns the samesubjectvalue that a browserGET /v1/auth/whoamireturns because both resolve back tousers.User.ID. - Deployments that need an upstream-issued bearer at the CLI seam can wire their own
DeviceTokenMinterontoauth.Deps.Tokens— the port is narrow and the composition root is the only switchover point. The default ships*issuer.Issuer.
The DECISION block on DeviceToken restates this in code; reviewers should treat that block as the authoritative rationale.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| GetAuthIdPBindings | domain_id (query) | string (uuid) | yes | Domain whose effective IdP bindings to list. A missing or malformed value surfaces as 400. |
| GetAuthCallback | code (query) | string | yes | Authorization code issued by the IdP. |
| GetAuthCallback | state (query) | string | yes | Opaque correlation value returned by PostAuthSignIn. |
| DeleteAuthTokenByID / PostAuthTokenRotate | id (path) | string (uuid) | yes | Token identifier (UUIDv7). Malformed or zero-UUID surfaces as 400. |
Schemas
The OpenAPI spec is the single source of truth for request/response shapes. The schemas this surface uses are:
- Request:
SignInRequest,DeviceCodeRequest,DeviceTokenRequest,DeviceApproveRequest,ServiceTokenRequest,APITokenIssueRequest. - Response:
SignInResponse,DomainIdPBinding(array element onGetAuthIdPBindings),DeviceCodeResponse,DeviceApproveResponse,ServiceTokenResponse,APITokenIssueResponse,APITokenSummary(array element on list),APITokenRotateResponse,Whoami.
PostAuthSignOutGlobal carries no request or response body — success is a bare 204 with the canonical cookie-clearing Set-Cookie header.
The /v1/auth/idp-bindings discovery surface
GetAuthIdPBindings is an unauthenticated, display-safe read the dashboard sign-in page calls once a Domain id is known (typed, or carried in a ?domain_id= deep link) so it can render a provider chooser. Each DomainIdPBinding carries only id, alias, label, issuer_host, primary, and platform_scoped — never the client_secret_ref, claim_mappings, or required_acr / required_amr material the per-Domain admin view (GET /v1/admin/idp) exposes. The list is exactly the candidate set POST /v1/auth/sign-in would resolve 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.
The endpoint is a deliberate pre-auth enumeration surface and is not a Domain-existence oracle: an unknown Domain id and a Domain that owns no bindings both return the applicable platform bindings (or an empty list), through a single repository read, so the two are indistinguishable. The Domain id is an unguessable UUIDv7, the response is marked Cache-Control: no-store, and the /v1/auth/* rate-limit follow-up covers burst control. Login happens before any session exists, which is why this surface is separate from the authenticated GET /v1/admin/idp rather than reusing it.
For the field-level shapes refer to api/openapi/plexsphere-v1.yaml under components/schemas/. Spectral's plexsphere-write-once-post-must-be-issue-response rule guards the x-plexsphere-once: true markers on the plaintext fields of APITokenIssueResponse and APITokenRotateResponse, so the wire contract pins the one-shot semantics.
Error taxonomy
All error responses use the shared Problem envelope (application/problem+json, RFC 7807). The closed Problem.code set this surface emits:
| Code | Status | Where | Meaning |
|---|---|---|---|
idp_binding_not_found | 404 | PostAuthSignIn, PostAuthDeviceCode | No active IdP binding for the requested Domain — emitted on the Problem.type slug binding_not_found both when an explicit idp_binding_id is unknown and when a domain_id-only request resolves zero active bindings. |
alias-not-found | 404 | PostAuthDeviceCode | An idp_binding_alias was supplied but no active binding in the Domain carries that alias. |
multiple-bindings | 400 | PostAuthSignIn, PostAuthDeviceCode | The Domain has two or more active IdP bindings and no primary is designated; the server cannot disambiguate, so the caller must pin one via idp_binding_id / idp_binding_alias, or the operator must mark a primary. |
domain-mismatch | 400 | PostAuthSignIn, PostAuthDeviceCode | An explicitly supplied idp_binding_id does not belong to the supplied domain_id. |
idp_state_invalid / idp_nonce_mismatch | 400 | GetAuthCallback | OIDC correlation failed; the verifier expired or the IdP returned a tampered state. |
idp_token_exchange_failed | 502 | GetAuthCallback, PostAuthDeviceToken | Upstream IdP rejected the token exchange. |
authorization_pending / slow_down / access_denied / expired_token / invalid_grant | 400 | PostAuthDeviceToken | RFC 8628 device-flow taxonomy. |
federation_kind_mismatch | 400 / 403 | PostAuthServiceToken | The grant type conflicts with the ServiceIdentity's federation_kind (e.g. JWT-bearer assertion submitted for a client_credentials-only ServiceIdentity), or the SPIFFE assertion's verified sub does not match the resolved ServiceIdentity's Subject. |
client_authentication_failed | 401 | PostAuthServiceToken | The client assertion did not verify. |
spiffe_verification_failed | 401 | PostAuthServiceToken | The SPIFFE JWT-SVID's signature, expiry (exp/iat/nbf), trust-domain issuer (when configured), or aud did not satisfy the verifier. Includes the unknown-kid-after-one-refresh case. |
spiffe_bundle_unreachable | 502 | PostAuthServiceToken | The SPIFFE trust bundle endpoint returned a non-2xx response or a network error AND no cached copy was available to fall back on. A cached copy older than the configured TTL but still fetchable serves the cached copy; this code is reached only on a cold cache. |
spiffe_bundle_unconfigured | 502 | PostAuthServiceToken | The IdPBinding has no spiffe_bundle_url recorded — the operator forgot to provision the binding for SPIFFE verification. Distinct from spiffe_bundle_unreachable so an operator can tell "I forgot to set the column" from "the network is broken". |
invalid_env_prefix / identity_ref_required | 400 | PostAuthTokens | API-token issuance pre-conditions. |
csrf_token_mismatch / csrf_origin_mismatch / csrf_origin_not_configured | 403 | PostAuthDeviceApprove | CSRF check failed — the X-Plexsphere-CSRF header did not echo the plexsphere_csrf cookie, or the request Origin did not match. |
device_code_not_found | 404 | PostAuthDeviceApprove | No pending device session matches the supplied user_code. |
device_code_expired / device_code_already_approved | 409 | PostAuthDeviceApprove | The device session is past expires_at, or was already approved by an earlier request. |
unauthorized | 401 | token-management family, PostAuthDeviceApprove | Caller has no resolved principal. |
not-authenticated | 401 | PostAuthSignOutGlobal | No plexsphere_session cookie, or the cookie does not resolve to a live session. The cookie is still cleared on the 401 path. |
not_found | 404 | DeleteAuthTokenByID, PostAuthTokenRotate | Token does not exist or does not belong to the caller — the handler returns 404 rather than 403 so an unauthorised caller cannot probe token ids. |
token_already_rotated | 409 | PostAuthTokenRotate, PostAdminTokenRotate | The token has already been rotated. A token rotates once; rotate its successor instead. |
token_revoked / token_expired | 410 | PostAuthTokenRotate, PostAdminTokenRotate | The token is revoked or expired and cannot be rotated. A revoked token answers token_revoked even when it has also expired. |
forbidden-credential | 403 | PostAuthSignOutGlobal | Caller authenticated as an api_token principal — global sign-out is reserved for interactive sessions, so a leaked API token cannot bulk-revoke its owner's interactive sessions. Revoke the token via DELETE /v1/auth/tokens/{id} instead. |
session_store | 500 | GetAuthCallback, GetAuthWhoami, DeleteAuthWhoami, PostAuthSignOutGlobal, PostAuthDeviceApprove | The session store could not be read or written. Distinct from the 401s above on purpose: the session is not known to be gone, only unreadable, so the caller retries rather than signing in again. No response on this path clears the session cookie. |
device_store | 500 | PostAuthDeviceCode, PostAuthDeviceToken, PostAuthDeviceApprove | The device-session store could not be read or written. |
device-store-full | 503 | PostAuthDeviceCode | The live set of device authorizations is at its ceiling. Carries Retry-After; the surface is unauthenticated, so the ceiling is what keeps a flood bounded. |
auth_misconfigured | 500 | every operation that needs a store | A required dependency is unwired. The session stores have no in-process default, so a deployment missing one fails loudly at the first request instead of running per-replica state nobody notices until a second replica exists. |
internal | 500 | every operation | Server-side failure path. |
DELETE /v1/auth/whoami advertises 204 for every caller by design (see internal/transport/http/v1/auth/signout.go): an absent, unknown or already-removed cookie is a successful sign-out. Its one non-204 path is a store that could not perform the revoke, which answers 500 session_store and deliberately does NOT clear the cookie — clearing it would strand a live session with nothing left to address it.
Beyond this surface: the authentication middleware
The per-request authentication middleware sits in front of every non-bypass /v1/* route, so its refusals reach operations this table does not list. It resolves the plexsphere_session cookie against the same store, and it draws the same distinction:
| Detail | Status | Meaning |
|---|---|---|
session unknown or expired | 401 | The store answered and knows no live session under that cookie. |
session lifetime exceeded | 401 | The session reached its absolute cap. The response clears the cookie. |
session store unavailable | 503 | The store could not answer. The cookie is left alone, so an outage does not sign the operator out; the cause reaches the operator through a log line carrying the response's correlation id. |
Browser-leg vs JSON-leg failure for GetAuthCallback
GetAuthCallback is the only operation that content-negotiates its failure shape:
- Browser leg (
Accept: */*,text/html, or absent): every failure is a303 See Otherto/?auth_error_kind=...&auth_error_status=...&auth_error_detail=.... No session cookie is issued. The SPA renders the error inline next to the sign-in form. - JSON leg (
Accept: application/jsonorapplication/problem+json): the original status (400,500,502) is returned with anapplication/problem+jsonbody so programmatic callers can parse the error with the same envelope as every other/v1surface.
The browser-leg redirect uses the same 303 See Other status as the success path so a generic browser cannot tell the difference at the HTTP layer; the auth_error_* query parameters carry the disambiguation. This is the deliberate trade-off — see the DECISION block on internal/transport/http/v1/auth/callback.go.
Both legs carry the same text, and on both legs that text is a sentence written in the handler. An upstream failure names the step in domain terms ("the identity provider rejected the token exchange"); the OIDC library's own message stays out of the auth_error_detail query parameter and out of the Problem detail, and reaches the server log instead. A redirect target lands in browser history and proxy access logs, so a cause reflected there would outlive the response. The log record carries the request's correlation_id, which the JSON leg also stamps on the Problem body.
Cross-references
../../contexts/identity/idp.md— bounded-context reference for the per-Domain IdP binding model, JIT provisioning policy, ServiceIdentity federation, and thepsk_<env>_<id>_<random>token format.../../how-to/identity/rotate-service-tokens.md— operator recipe that exercises the sign-in / token / whoami family end-to-end against the kind dev.../../how-to/getting-started/log-in.md—plexctl loginwalkthrough that drives the device-code flow.../../../api/openapi/plexsphere-v1.yaml— OpenAPI 3.1 spec; theAuth*operations and theSignInRequest/SignInResponse/DeviceCodeRequest/DeviceCodeResponse/DeviceTokenRequest/DeviceApproveRequest/DeviceApproveResponse/ServiceTokenRequest/ServiceTokenResponse/APITokenIssueRequest/APITokenIssueResponse/APITokenSummary/APITokenRotateResponse/Whoamischemas.../../../internal/transport/http/v1/auth/— handler implementations for the surface, including the callback's content-negotiated failure DECISION block and thesignout.goalways-204 contract.../../../internal/identity/authn/middleware/— the authn chain that consumes the artefacts this surface produces../idp.md— the admin surface that manages the IdP bindings the sign-in / device flows consume.