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/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/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. |
| 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. |
| 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 | session or API token + manage on caller'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 caller'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 caller'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 | session or API token + manage on caller's Domain | api_token.admin.rotate | Rotate 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.
| 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 their authenticated Domain can mint, list, revoke, and rotate tokens on behalf of any identity_ref (user:<uuid> or service:<uuid>).
| Concern | Behaviour |
|---|---|
| Authz gate | manage 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. |
| Audit | Every 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 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 |
|---|---|---|---|---|
| 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,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:
| Code | Status | Where | Meaning |
|---|---|---|---|
idp_binding_not_found | 404 | PostAuthSignIn, PostAuthDeviceCode | No active IdP binding for the requested Domain. |
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. |
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. |
internal | 500 | every operation | Server-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 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.
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.