Appearance
IdP HTTP API
This is the reference for the per-Domain IdP-binding admin surface (/v1/admin/idp) plus the sign-out endpoint (DELETE /v1/auth/whoami). It maps each operation to its OpenAPI schema, handler entry point, audit emission, and the closed Problem.code taxonomy. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract — for the bounded-context narrative covering the IdP binding aggregate, JIT policy, and the sign-out audit trail see ../../contexts/identity/idp.md.
Operations
| Method | Path | Operation ID | Handler |
|---|---|---|---|
| POST | /v1/admin/idp | PostAdminIdP | PostAdminIdP |
| GET | /v1/admin/idp | GetAdminIdPList | GetAdminIdPList |
| GET | /v1/admin/idp/{id} | GetAdminIdPByID | GetAdminIdPByID |
| PATCH | /v1/admin/idp/{id} | PatchAdminIdP | PatchAdminIdP |
| DELETE | /v1/admin/idp/{id} | DeleteAdminIdPByID | DeleteAdminIdPByID |
| PATCH | /v1/admin/idp/{id}/status | PatchAdminIdPStatus | PatchAdminIdPStatus |
| DELETE | /v1/auth/whoami | DeleteAuthSession | DeleteSession |
- Every admin operation rejects requests without a resolved Principal with
401 unauthenticated. On top of authn, every handler runs a Domain-scoped ReBACCheckagainst the parent Domain (POST/PATCH/ DELETE →domain#manage; GET →domain#read). An unauthorised caller surfaces as403 PermissionDenied(application/problem+json; charset=utf-8) carrying the canonicalreason,relation_path, andcorrelation_idfields. The 403 emission also records one audit row withOutcome=permission_deniedandCaveatContext.missing_relationpinned to the gate that failed — seeexercise-admin-surface.mdfor the (route → relation → object) reference table. DeleteAdminIdPByIDis a soft delete: the binding transitions tostatus=deactivatedso the audit trail (including every issuedpsk_token bound to it) stays intact.PatchAdminIdPStatusonly accepts{active, deactivated}; thedegradedvalue is reserved for background probes and MUST NOT be set manually.PatchAdminIdPrejects an empty body (every field nil) with400 empty-patchso a client cannot trigger a no-op write. When the patched body equals the persisted state the response is200but the repository emits NO outbox event.DeleteAuthSessionis idempotent and never discloses whether a session existed; see the Sign-out endpoint section below for the four-scenario behaviour matrix.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| GetAdminIdPByID / PatchAdminIdP / DeleteAdminIdPByID / PatchAdminIdPStatus | id (path) | string (uuid) | yes | UUIDv7. Non-zero. Malformed or zero → 400 invalid-id. |
| GetAdminIdPList | domain_id (query) | string (uuid) | yes (handler) | The OpenAPI spec marks this filter optional, but the handler REQUIRES it in v1. A missing filter surfaces as 400 domain-required. DECISION lives in internal/transport/http/v1/admin/idp.go — keeping the gate avoids accidentally enumerating every Domain's bindings on a shared control plane until the admin scope is modelled. |
Schemas
IdPBindingRequest
Body for POST /v1/admin/idp. Field set mirrors the IdPBinding aggregate.
| Field | Type | Required | Notes |
|---|---|---|---|
domain_id | string (uuid) | yes | Owning Domain (UUIDv7). |
issuer | string | yes | OIDC issuer URL (absolute http/https). |
client_id | string | yes | OIDC client identifier. |
client_secret_ref | string | yes | Opaque reference to the client secret held in the secret store — never the secret value itself. |
discovery_url | string | yes | OIDC discovery document URL (absolute http/https). |
claim_mappings | object<string,string> | no | Mapping of plexsphere claim name → IdP claim name. An empty map is permitted. |
required_acr | array<string> | no | Required OIDC ACR values. |
required_amr | array<string> | no | Required OIDC AMR values. |
jit_policy | string enum {allow, deny} | yes | Just-in-time user-provisioning policy. |
IdPBindingPatchRequest
Body for PATCH /v1/admin/idp/{id}. All fields are optional; only fields present in the request are mutated. The status field is intentionally absent from this schema — status transitions are performed exclusively through PATCH /v1/admin/idp/{id}/status.
| Field | Type | Required | Notes |
|---|---|---|---|
discovery_url | string | no | OIDC discovery document URL (absolute http/https). |
claim_mappings | object<string,string> | no | Mapping of plexsphere claim name → IdP claim name. An empty map clears all mappings. |
required_acr | array<string> | no | Required OIDC ACR values. Caller-provided slice is defensively copied at the handler so subsequent client mutation cannot reach the repo. |
required_amr | array<string> | no | Required OIDC AMR values. Caller-provided slice is defensively copied at the handler. |
jit_policy | string enum {allow, deny} | no | Just-in-time user-provisioning policy. |
(status) | — | — | INTENTIONALLY ABSENT. Status transitions are owned exclusively by PATCH /v1/admin/idp/{id}/status. A body whose every field is nil is rejected with 400 empty-patch. |
IdPBindingStatusRequest
Body for PATCH /v1/admin/idp/{id}/status.
| Field | Type | Required | Notes |
|---|---|---|---|
status | string enum {active, deactivated} | yes | New binding status. The degraded value is reserved for background probes and is rejected on this endpoint with 400 invalid-status. |
IdPBindingResponse
Response echo of a persisted IdPBinding aggregate. The client_secret_ref field is included because it is an opaque reference, not the secret value itself.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (uuid) | yes | Binding identifier (UUIDv7). |
domain_id | string (uuid) | yes | Owning Domain (UUIDv7). |
issuer | string | yes | OIDC issuer URL. |
client_id | string | yes | OIDC client identifier. |
client_secret_ref | string | yes | Opaque reference to the client secret. |
discovery_url | string | yes | OIDC discovery document URL. |
claim_mappings | object<string,string> | no | Plexsphere claim → IdP claim mapping. Omitted from the wire when empty. |
required_acr | array<string> | no | Required OIDC ACR values. Omitted from the wire when empty. |
required_amr | array<string> | no | Required OIDC AMR values. Omitted from the wire when empty. |
jit_policy | string enum {allow, deny} | yes | Just-in-time user-provisioning policy. |
status | string enum {active, inactive, degraded, deactivated} | yes | Aggregate status. |
created_at | string (date-time) | yes | Aggregate creation timestamp (UTC). |
updated_at | string (date-time) | yes | Last-modified timestamp (UTC). Bumped by every mutator (POST, PATCH body, PATCH status). |
Error taxonomy
The closed Problem.code set this surface emits, exactly as defined in internal/transport/http/v1/admin/idp.go. Rows are grouped by HTTP status, with the operation matrix listing which methods can emit each code. The 403 PermissionDenied body is a SEPARATE schema from Problem — established by the ReBAC layer and reused on every admin route — and carries the ReBAC denial reason, traversed relation_path, and the correlation_id that pairs the response with the audit entry.
| HTTP status | Problem.code | Origin | Operations | Trigger |
|---|---|---|---|---|
| 400 | invalid-id | handler | GetByID, Patch, Delete, PatchStatus | Path {id} is not a non-zero UUIDv7. |
| 400 | invalid-body | handler | POST, Patch, PatchStatus | Body is not a valid JSON document for the named schema. |
| 400 | invalid-jit-policy | handler | POST, Patch | jit_policy is outside {allow, deny}. |
| 400 | invalid-status | handler | PatchStatus | status is outside {active, deactivated}. |
| 400 | empty-patch | handler | Patch | Every field of IdPBindingPatchRequest is nil — no-op writes are rejected. Emitted via writeProblemWithCode so the explicit code field is set. |
| 400 | invalid-binding | handler | POST | The idp.NewIdPBinding aggregate constructor rejected the input (issuer / discovery URL / claim mapping invariants). The detail interpolates the underlying validation error. |
| 400 | invalid-input | repo | POST, Patch, Delete, PatchStatus | idprepo.ErrInvalidInput — repo-side rejection of the request shape. |
| 400 | missing-domain | repo | POST, Patch | idprepo.ErrForeignKeyMissing — domain_id references a Domain that does not exist. |
| 400 | check-violation | repo | POST, Patch, Delete, PatchStatus | idprepo.ErrCheckViolation — SQL CHECK constraint rejected the row. |
| 400 | domain-required | handler | List | The domain_id query parameter is missing. The handler REQUIRES it in v1 (see DECISION block in the handler). |
| 401 | unauthenticated | handler | every operation on this surface | No resolved Principal on the request context. |
| 403 | (PermissionDenied) | transport | every admin route in the spec | ReBAC denied the admin gate. Body is a PermissionDenied schema (NOT a Problem with code: permission_denied), established by the ReBAC layer. |
| 404 | binding-not-found | repo | GetByID, Patch, Delete, PatchStatus | idprepo.ErrNotFound — no binding with that id. The same code is emitted regardless of whether the row never existed or was filtered out by Domain scoping, so the endpoint cannot be used as a cross-Domain enumeration oracle. |
| 409 | binding-conflict | repo | POST, Patch | idprepo.ErrConflict — another active binding exists for the same (domain_id, issuer) pair, or an optimistic concurrency check failed. |
| 500 | internal | handler | every operation on this surface | Unexpected error — fallback branch of writeBindingRepoProblem. The detail interpolates the operation label (create, list, get, patch, delete, activate, deactivate) so log correlation lands on the right endpoint. |
| 501 | not-provisioned | handler | every admin operation on this surface | Deps.Bindings is not wired (control-plane bring-up). The detail names the originating requirement. |
Every Problem detail on this surface carries a structured trailer so reviewers can grep production logs back to the originating requirement.
Sign-out endpoint
DELETE /v1/auth/whoami (DeleteAuthSession) terminates the caller's authenticated session and clears the plexsphere_session cookie. The endpoint is idempotent and the response body NEVER discloses whether a session existed — every outcome below renders to the wire as 204 No Content plus a cookie-clearing Set-Cookie header. The handler entry point lives at internal/transport/http/v1/auth/signout.go; the four-scenario matrix below is mirrored verbatim from the docstring on DeleteSession.
| Scenario | Cookie | Server-side TokenSession | HTTP response | Cookie cleared | UserSignedOut outbox event | Audit log |
|---|---|---|---|---|---|---|
| 1 | absent | n/a | 204 | yes | no | no audit row (no session to revoke; a sign-out click on a stale tab still succeeds without polluting the outbox with phantom rows) |
| 2 | present | found | 204 | yes | yes — canonical sign-out | success path through SignOutAuditor.RecordSignOut |
| 3 | present | missing / stale / unknown | 204 | yes | no | no audit row — emitting UserSignedOut with a zero UserID would fail the events.NewUserSignedOut invariant gate; clearing the cookie matches the user's mental model of "signed in" |
| 4 | present | found, but SignOutAuditor errors | 204 | yes | (event built; persistence failed) | slog.Error is emitted with the originating-feature attributes so operators see the audit gap. The session is already invalidated by the time the audit hop runs, so failing the request would lie to the client about the cookie state. |
The cookie-clearing Set-Cookie header MUST mirror the issuance side in callback.go (Path=/v1/, HttpOnly, Secure when the request arrived over TLS or a trusted X-Forwarded-Proto: https header, SameSite=Strict, MaxAge=-1, Expires=epoch) so the user agent treats the new cookie as the same one it already holds — RFC 6265 §5.3 keys cookies on (name, domain, path) and a mismatch would leave the original entry stranded on disk.
Sign-in endpoint
POST /v1/auth/sign-in (PostAuthSignIn) initiates an OIDC authorization-code + PKCE flow against an IdP binding within a Domain. The handler entry point lives at internal/transport/http/v1/auth/sign_in.go; the OpenAPI schema is SignInRequest in api/openapi/plexsphere-v1.yaml.
SignInRequest schema
Body for POST /v1/auth/sign-in. At least one of domain_id or idp_binding_id must be supplied; the handler resolves them onto the single IdP binding that drives the flow.
| Field | Type | Required | Notes |
|---|---|---|---|
idp_binding_id | string (uuid) | conditional | Pin a specific binding within a Domain. When present, the resolver short-circuits to BindingRepo.GetByID and an unknown id returns 404 binding-not-found. Wins over domain_id when both are set so a client that pins a binding continues to work unchanged from the earlier contract. |
domain_id | string (uuid) | conditional | Domain the user is signing into. When idp_binding_id is omitted the handler runs BindingRepo.ListByDomain, filters to IsActive rows, and returns the unique active binding. Zero matches → 404 binding-not-found; ≥2 matches → 400 multiple-bindings pinning hint. |
return_to | string | no | Post-sign-in redirect target. Must be an allow-listed relative path or absolute URL. |
prompt | string | no | Optional OIDC prompt parameter forwarded to the IdP (e.g. "login", "consent"). |
A request with neither domain_id nor idp_binding_id set is rejected with 400 bad-request so the contract floor an older client expected (a 400 on a body that names neither field) is preserved.
Resolution branches
The handler walks the four branches below in this order. Every Problem detail carries a structured trailer so log greps land on the originating requirement.
| Body shape | Active bindings on the Domain | HTTP status | Problem.code | Trigger |
|---|---|---|---|---|
idp_binding_id set (with or without domain_id) | n/a — explicit pin wins | 200 | (none) | BindingRepo.GetByID returned the row. |
idp_binding_id set | n/a — id missing | 404 | binding-not-found | BindingRepo.GetByID returned idprepo.ErrNotFound. The same code is emitted whether the row was never persisted or the id is from a different Domain — the endpoint is not a cross-Domain enumeration oracle. |
domain_id only | exactly one | 200 | (none) | The unique active binding drives the flow. |
domain_id only | zero | 404 | binding-not-found | The Domain has no active IdP binding. The dashboard sign-in path lands here when a fresh bootstrap Domain has not yet registered a binding — this 404 is the regression the resolver closes. |
domain_id only | two or more | 400 | multiple-bindings | The handler refuses to pick one; the client must pin idp_binding_id. The detail names the count so an operator triaging the failure can correlate against GET /v1/admin/idp?domain_id=…. |
| neither set | n/a | 400 | bad-request | Body has neither idp_binding_id nor domain_id. Detail names both legal fields so a client error is self-correcting. |
| (any) | resolved binding's discovery doc unreachable | 502 | oidc-discovery | Carried over from the earlier contract. |
DECISION: the active-row filter lives in the transport handler, not the repo. BindingRepo.ListByDomain is shared with the operator admin surface that legitimately wants to see deactivated rows; the "only active rows drive sign-in" rule is a sign-in policy and not a persistence invariant. See the BindingRepo doc comment in internal/transport/http/v1/auth/wiring.go for the full rationale.
Degraded-binding asymmetry
The domain_id-only branch filters on IdPBinding.IsActive, which treats degraded rows as not eligible for sign-in resolution: a caller submitting only domain_id will see 404 binding-not-found when the Domain's only binding is degraded, not a 200. The idp_binding_id-pinned branch is asymmetric on purpose — an explicit pin still resolves through BindingRepo.GetByID, so an operator holding the binding id can drive sign-in against a degraded binding to confirm the IdP is reachable again before flipping the row back to active via PATCH /v1/admin/idp/{id}/status. Operators triaging a sign-in regression that lands on a degraded binding should compare GET /v1/admin/idp?domain_id=… (which exposes every status) against the dashboard's runtime config (which only lists active bindings) to confirm the asymmetry is the cause before treating it as a separate defect.
SignInResponse schema
Returned on every 200 response. The PKCE code_verifier itself never leaves the server; code_verifier_handle is the opaque key the callback uses to recover the verifier from the SessionStore.
| Field | Type | Required | Notes |
|---|---|---|---|
authorization_url | string | yes | Upstream IdP authorization URL the caller redirects to (carries state, nonce, code_challenge, code_challenge_method=S256, redirect_uri, scopes, optional prompt). |
state | string | yes | Opaque state value the callback correlates on. |
code_verifier_handle | string | yes | Opaque handle — the server holds the real PKCE verifier under this key for the duration of PLEXSPHERE_AUTH_STATE_TTL. |
nonce | string | yes | OIDC nonce; replayed verbatim into the asserted ID token. |
Callback endpoint
GET /v1/auth/callback (GetAuthCallback) is the OIDC redirect target the IdP returns the user-agent to after sign-in. The handler entry point lives at internal/transport/http/v1/auth/callback.go; the helpers that drive the content-negotiation contract live next to it at internal/transport/http/v1/auth/callback_redirect.go. The endpoint is invoked exclusively via top-level browser navigation per RFC 6749 §4.1.2; clients that need a machine-readable session shape should call GET /v1/auth/whoami once the session cookie is set.
The response shape is content-negotiated against the Accept request header:
| Outcome | Accept matches application/json or application/problem+json | Otherwise (browser leg — text/html, */*, absent) |
|---|---|---|
| Success — token exchange and JIT user provisioning succeeded | n/a — the success path always emits a 303 browser redirect to / (RFC 6749 §4.1.2). | 303 See Other with Location: / and a plexsphere_session cookie (Path=/v1/, HttpOnly, SameSite=Strict, Secure conditional on TLS or trusted X-Forwarded-Proto: https). |
| Failure — state/nonce mismatch, expired verifier, IdP discovery / token exchange / id_token verification rejection, or sync failure | application/problem+json with the original status (400, 500, or 502). NO Set-Cookie header is emitted on any failure leg. | 303 See Other with Location: /?auth_error_kind=<kind>&auth_error_status=<status>&auth_error_detail=<urlencoded(detail)>. NO Set-Cookie header is emitted. The SPA reads the params on mount, dispatches SignInFailed, and clears them via history.replaceState. The auth_error_detail value is bounded to 512 chars before url.QueryEscape. |
The browser-leg failure surface lands on the SPA's sign-in page so the user sees an inline InfoNote rather than a raw Problem body on a blank tab. The JSON-leg failure surface preserves the original status so a programmatic caller (integration test, headless harness, service driving the callback under Accept: application/problem+json) can branch on Problem.code exactly as on every other /v1/auth/* endpoint.
For the operator runbook covering the reverse-proxy deployment posture this contract anchors on (when to set PLEXSPHERE_AUTH_TRUST_PROXY_HEADERS=true, how the Secure flag is chosen, the same-origin requirement between the SPA and /v1/*), see ../../how-to/platform/run-behind-a-reverse-proxy.md.
Audit & outbox contract
Every state-changing admin operation pairs persistence with a domain-event outbox append in the same transaction. The sign-out endpoint emits a separate UserSignedOut event when (and only when) a server-side session was actually invalidated.
| Operation | Outcome | Outbox event | Notes |
|---|---|---|---|
POST /v1/admin/idp | success (201) | IdPBindingRegistered | Appended in the same transaction as UpsertBinding. |
PATCH /v1/admin/idp/{id} | success with at least one field changed (200) | IdPBindingUpdated | Repo emits the event ONLY when the persisted row actually changed; a PATCH whose body equals the persisted state is a 200 no-op with NO outbox row. |
PATCH /v1/admin/idp/{id} | success but body matches persisted state (200) | (none) | No-op write — the repo skips the outbox append. |
PATCH /v1/admin/idp/{id}/status | status=active accepted (200 / 204) | IdPBindingActivated | Appended atomically with the status flip. |
PATCH /v1/admin/idp/{id}/status | status=deactivated accepted (200 / 204) | IdPBindingDeactivated | Appended atomically with the status flip. |
DELETE /v1/admin/idp/{id} | success (204) | IdPBindingDeactivated | Soft delete via the same Deactivate repo verb as the status PATCH; the outbox event is identical so consumers do not need to care which surface triggered the deactivation. |
DELETE /v1/auth/whoami | scenario 2 — session existed (204) | UserSignedOut | Emitted via SignOutAuditor.RecordSignOut; the event carries the resolved UserID and DomainID. |
DELETE /v1/auth/whoami | scenarios 1, 3, 4 (204) | (none) | No event — see the sign-out behaviour matrix above. |
A 4xx / 5xx response NEVER appends an outbox event: every writeBindingRepoProblem and writeProblem call returns BEFORE the repo mutator runs, and the repo itself wraps persistence + outbox append in a single transaction so a failed write rolls back the event.
Cross-references
../../contexts/identity/idp.md— bounded-context narrative for the per-Domain IdP binding aggregate, JIT policy, and the sign-out audit trail../identities.md— sibling reference for the per-Domain identity surface, including the auditor reveal gate that the sign-in callback feeds.../../how-to/getting-started/log-in.md— operator how-to walking through the OIDC sign-in flow that issues theplexsphere_sessioncookie this surface revokes.../../how-to/platform/bootstrap-domains.md— sibling how-to covering the tenancy bootstrap path that seeds the first IdP binding registered through this surface.../../how-to/identity/rotate-service-tokens.md— operator how-to that exercises the four sign-in resolution branches with curl recipes, including thedomain_id-only path the resolver closes.../../how-to/platform/run-behind-a-reverse-proxy.md— operator runbook for the reverse-proxy deployment posture the callback's content-negotiation contract anchors on; pins thePLEXSPHERE_AUTH_TRUST_PROXY_HEADERSflag, theX-Forwarded-Proto: httpsrequirement, and the same-origin rule between the SPA and/v1/*.../api/index.md— platform-wide/v1HTTP surface map.../../../api/openapi/plexsphere-v1.yaml— authoritative OpenAPI 3.1 contract; this doc is a map, not a duplicate (line ranges in the operations table above pin each operation to its block).../../../internal/transport/http/v1/admin/idp.go— admin handler package; closedProblem.codetaxonomy and thedomain_id-required DECISION live here, plus thewriteBindingRepoProblemmapping from repo sentinels to Problem codes.../../../internal/transport/http/v1/auth/signout.go— sign-out handler; the four-scenario behaviour matrix and the cookie-clearing contract live in theDeleteSessiondocstring.