Skip to content

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 ​

MethodPathOperation IDAuthn requirementAudit relationNotes
POST/v1/auth/sign-inPostAuthSignIn(none — issuance)auth.sign_in.startReturns the IdP authorization URL plus the opaque correlation handle the callback consumes.
GET/v1/auth/idp-bindingsGetAuthIdPBindings(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/callbackGetAuthCallback(none — issuance)auth.sign_in.completeBrowser-leg: 303 to / with plexsphere_session cookie. JSON-leg failure: RFC 7807 Problem with original status.
POST/v1/auth/device-codePostAuthDeviceCode(none — issuance)auth.device_code.startRFC 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-tokenPostAuthDeviceToken(none — polling)auth.device_code.pollRFC 8628 polling with authorization_pending / slow_down / access_denied / expired_token / invalid_grant taxonomy.
POST/v1/auth/device/approvePostAuthDeviceApproveplexsphere_session cookie + CSRFauth.device_code.approveFlips 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/tokenPostAuthServiceTokenclient assertion (RFC 6749 §4.4 client_credentials or RFC 7523 JWT-bearer with a SPIFFE JWT-SVID)auth.service_token.issueMints an access token for a registered ServiceIdentity; both grants emit one identity.ServiceIdentityAuthenticated outbox row.
POST/v1/auth/tokensPostAuthTokenssession or API tokenauth.api_token.issueMints a psk_<env>_<id>_<random> API token; plaintext is returned exactly once.
GET/v1/auth/tokensGetAuthTokenssession or API tokenauth.api_token.listReturns summaries only — plaintext is never re-emitted after issuance.
DELETE/v1/auth/tokens/{id}DeleteAuthTokenByIDsession or API tokenauth.api_token.revokeImmediate revocation, no grace period.
POST/v1/auth/tokens/{id}/rotatePostAuthTokenRotatesession or API tokenauth.api_token.rotateNew 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/whoamiGetAuthWhoamisession or API tokenauth.whoamiReturns the resolved principal metadata for the current request.
DELETE/v1/auth/whoamiDeleteAuthSession(none — idempotent)auth.session.terminateAlways 204, regardless of whether a session existed; clears the plexsphere_session cookie.
POST/v1/auth/sign-out/globalPostAuthSignOutGlobalplexsphere_session cookie (interactive only)auth.session.terminate.globalRevokes 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/tokensPostAdminTokensplexsphere_session cookie (an API-token bearer gets 403 bearer-cannot-mint) + manage on the token owner's Domainapi_token.admin.issueCross-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=GetAdminTokenssession or API token + manage on the token owner's Domainapi_token.admin.listLists the addressed owner's tokens (summaries only). Gated on manage, not read, because enumeration is a precursor to revocation.
DELETE/v1/admin/tokens/{id}DeleteAdminTokenByIDsession or API token + manage on the token owner's Domainapi_token.admin.revokeRevoke any token; the lookup runs before the gate so a 404 is uniform across permitted and denied callers.
POST/v1/admin/tokens/{id}/rotatePostAdminTokenRotateplexsphere_session cookie (an API-token bearer gets 403 bearer-cannot-mint) + manage on the token owner's Domainapi_token.admin.rotateRotate 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.

ArtefactIssued byConsumed asPersistence
plexsphere_session cookieGetAuthCallbackCookie request header on subsequent callsOpaque server-side session record; HttpOnly, SameSite=Strict, Path=/v1/, Secure when TLS or X-Forwarded-Proto: https is honoured.
OIDC JWTthe per-Domain IdP via the device or browser flowAuthorization: Bearer <jwt>Verified against the binding's JWKS; claims are projected onto the request Principal.
API token (psk_<env>_<id>_<random>)PostAuthTokens / PostAuthTokenRotateAuthorization: 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.

ConcernBehaviour
Authz gatemanage 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 callersPostAdminTokens 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 refusalsPostAdminTokenRotate 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 stateEach 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.
AuditEvery /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 gateUses manage, not read — enumerating another principal's tokens is a precursor to revocation and carries the same operational sensitivity.
404 vs 403DeleteAdminTokenByID / 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 User authenticated on the /v1/device page and the psk_ is bound to that User.ID. plexctl whoami returns the same subject value that a browser GET /v1/auth/whoami returns because both resolve back to users.User.ID.
  • Deployments that need an upstream-issued bearer at the CLI seam can wire their own DeviceTokenMinter onto auth.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 ​

OperationParameterTypeRequiredNotes
GetAuthIdPBindingsdomain_id (query)string (uuid)yesDomain whose effective IdP bindings to list. A missing or malformed value surfaces as 400.
GetAuthCallbackcode (query)stringyesAuthorization code issued by the IdP.
GetAuthCallbackstate (query)stringyesOpaque correlation value returned by PostAuthSignIn.
DeleteAuthTokenByID / PostAuthTokenRotateid (path)string (uuid)yesToken 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 on GetAuthIdPBindings), 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:

CodeStatusWhereMeaning
idp_binding_not_found404PostAuthSignIn, PostAuthDeviceCodeNo 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-found404PostAuthDeviceCodeAn idp_binding_alias was supplied but no active binding in the Domain carries that alias.
multiple-bindings400PostAuthSignIn, PostAuthDeviceCodeThe 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-mismatch400PostAuthSignIn, PostAuthDeviceCodeAn explicitly supplied idp_binding_id does not belong to the supplied domain_id.
idp_state_invalid / idp_nonce_mismatch400GetAuthCallbackOIDC correlation failed; the verifier expired or the IdP returned a tampered state.
idp_token_exchange_failed502GetAuthCallback, PostAuthDeviceTokenUpstream IdP rejected the token exchange.
authorization_pending / slow_down / access_denied / expired_token / invalid_grant400PostAuthDeviceTokenRFC 8628 device-flow taxonomy.
federation_kind_mismatch400 / 403PostAuthServiceTokenThe 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_failed401PostAuthServiceTokenThe client assertion did not verify.
spiffe_verification_failed401PostAuthServiceTokenThe 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_unreachable502PostAuthServiceTokenThe 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_unconfigured502PostAuthServiceTokenThe 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_required400PostAuthTokensAPI-token issuance pre-conditions.
csrf_token_mismatch / csrf_origin_mismatch / csrf_origin_not_configured403PostAuthDeviceApproveCSRF check failed — the X-Plexsphere-CSRF header did not echo the plexsphere_csrf cookie, or the request Origin did not match.
device_code_not_found404PostAuthDeviceApproveNo pending device session matches the supplied user_code.
device_code_expired / device_code_already_approved409PostAuthDeviceApproveThe device session is past expires_at, or was already approved by an earlier request.
unauthorized401token-management family, PostAuthDeviceApproveCaller has no resolved principal.
not-authenticated401PostAuthSignOutGlobalNo plexsphere_session cookie, or the cookie does not resolve to a live session. The cookie is still cleared on the 401 path.
not_found404DeleteAuthTokenByID, PostAuthTokenRotateToken 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_rotated409PostAuthTokenRotate, PostAdminTokenRotateThe token has already been rotated. A token rotates once; rotate its successor instead.
token_revoked / token_expired410PostAuthTokenRotate, PostAdminTokenRotateThe token is revoked or expired and cannot be rotated. A revoked token answers token_revoked even when it has also expired.
forbidden-credential403PostAuthSignOutGlobalCaller 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_store500GetAuthCallback, GetAuthWhoami, DeleteAuthWhoami, PostAuthSignOutGlobal, PostAuthDeviceApproveThe 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_store500PostAuthDeviceCode, PostAuthDeviceToken, PostAuthDeviceApproveThe device-session store could not be read or written.
device-store-full503PostAuthDeviceCodeThe 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_misconfigured500every operation that needs a storeA 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.
internal500every operationServer-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:

DetailStatusMeaning
session unknown or expired401The store answered and knows no live session under that cookie.
session lifetime exceeded401The session reached its absolute cap. The response clears the cookie.
session store unavailable503The 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 a 303 See Other to /?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/json or application/problem+json): the original status (400, 500, 502) is returned with an application/problem+json body so programmatic callers can parse the error with the same envelope as every other /v1 surface.

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 the psk_<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 login walkthrough that drives the device-code flow.
  • ../../../api/openapi/plexsphere-v1.yaml — OpenAPI 3.1 spec; the Auth* operations and the SignInRequest / SignInResponse / DeviceCodeRequest / DeviceCodeResponse / DeviceTokenRequest / DeviceApproveRequest / DeviceApproveResponse / ServiceTokenRequest / ServiceTokenResponse / APITokenIssueRequest / APITokenIssueResponse / APITokenSummary / APITokenRotateResponse / Whoami schemas.
  • ../../../internal/transport/http/v1/auth/ — handler implementations for the surface, including the callback's content-negotiated failure DECISION block and the signout.go always-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.