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/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/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.
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.
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/tokensPostAdminTokenssession or API token + manage on caller'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 caller'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 caller'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}/rotatePostAdminTokenRotatesession or API token + manage on caller's Domainapi_token.admin.rotateRotate any token; the retiring plaintext is named in the RFC 8594 Sunset header.

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 their authenticated Domain can mint, list, revoke, and rotate tokens on behalf of any identity_ref (user:<uuid> or service:<uuid>).

ConcernBehaviour
Authz gatemanage against domain:<caller.DomainID>. v1 ships a single-Domain composition root so the gate target is the caller's Domain rather than the target principal's Domain. Multi-Domain admin is a follow-up — when it lands the gate target moves to the target's Domain; the audit Object already carries token:<id> so the move is observable.
AuditEvery refusal emits an api_token.admin.{issue,list,rotate,revoke} row with outcome=permission_denied. Successful operations rely on the issuer / repo's outbox events (APITokenIssued, APITokenRotated, APITokenRevoked) — there is no separate transport-layer audit row for success, mirroring /v1/auth/tokens.
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
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, 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.

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.
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.
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.
internal500every operationServer-side failure path.

DELETE /v1/auth/whoami is the single exception: it advertises only 204 because the handler treats every caller as a successful sign-out by design (see internal/transport/http/v1/auth/signout.go). No 401 or 500 path is reachable.

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.

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.mdplexctl 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.