Skip to content

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

MethodPathOperation IDHandler
POST/v1/admin/idpPostAdminIdPPostAdminIdP
GET/v1/admin/idpGetAdminIdPListGetAdminIdPList
GET/v1/admin/idp/{id}GetAdminIdPByIDGetAdminIdPByID
PATCH/v1/admin/idp/{id}PatchAdminIdPPatchAdminIdP
DELETE/v1/admin/idp/{id}DeleteAdminIdPByIDDeleteAdminIdPByID
PATCH/v1/admin/idp/{id}/statusPatchAdminIdPStatusPatchAdminIdPStatus
DELETE/v1/auth/whoamiDeleteAuthSessionDeleteSession
  • Every admin operation rejects requests without a resolved Principal with 401 unauthenticated. On top of authn, every handler runs a Domain-scoped ReBAC Check against the parent Domain (POST/PATCH/ DELETE → domain#manage; GET → domain#read). An unauthorised caller surfaces as 403 PermissionDenied (application/problem+json; charset=utf-8) carrying the canonical reason, relation_path, and correlation_id fields. The 403 emission also records one audit row with Outcome=permission_denied and CaveatContext.missing_relation pinned to the gate that failed — see exercise-admin-surface.md for the (route → relation → object) reference table.
  • DeleteAdminIdPByID is a soft delete: the binding transitions to status=deactivated so the audit trail (including every issued psk_ token bound to it) stays intact.
  • PatchAdminIdPStatus only accepts {active, deactivated}; the degraded value is reserved for background probes and MUST NOT be set manually.
  • PatchAdminIdP rejects an empty body (every field nil) with 400 empty-patch so a client cannot trigger a no-op write. When the patched body equals the persisted state the response is 200 but the repository emits NO outbox event.
  • DeleteAuthSession is idempotent and never discloses whether a session existed; see the Sign-out endpoint section below for the four-scenario behaviour matrix.

Path & query parameters

OperationParameterTypeRequiredNotes
GetAdminIdPByID / PatchAdminIdP / DeleteAdminIdPByID / PatchAdminIdPStatusid (path)string (uuid)yesUUIDv7. Non-zero. Malformed or zero → 400 invalid-id.
GetAdminIdPListdomain_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.

FieldTypeRequiredNotes
domain_idstring (uuid)yesOwning Domain (UUIDv7).
issuerstringyesOIDC issuer URL (absolute http/https).
client_idstringyesOIDC client identifier.
client_secret_refstringyesOpaque reference to the client secret held in the secret store — never the secret value itself.
discovery_urlstringyesOIDC discovery document URL (absolute http/https).
claim_mappingsobject<string,string>noMapping of plexsphere claim name → IdP claim name. An empty map is permitted.
required_acrarray<string>noRequired OIDC ACR values.
required_amrarray<string>noRequired OIDC AMR values.
jit_policystring enum {allow, deny}yesJust-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.

FieldTypeRequiredNotes
discovery_urlstringnoOIDC discovery document URL (absolute http/https).
claim_mappingsobject<string,string>noMapping of plexsphere claim name → IdP claim name. An empty map clears all mappings.
required_acrarray<string>noRequired OIDC ACR values. Caller-provided slice is defensively copied at the handler so subsequent client mutation cannot reach the repo.
required_amrarray<string>noRequired OIDC AMR values. Caller-provided slice is defensively copied at the handler.
jit_policystring enum {allow, deny}noJust-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.

FieldTypeRequiredNotes
statusstring enum {active, deactivated}yesNew 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.

FieldTypeRequiredNotes
idstring (uuid)yesBinding identifier (UUIDv7).
domain_idstring (uuid)yesOwning Domain (UUIDv7).
issuerstringyesOIDC issuer URL.
client_idstringyesOIDC client identifier.
client_secret_refstringyesOpaque reference to the client secret.
discovery_urlstringyesOIDC discovery document URL.
claim_mappingsobject<string,string>noPlexsphere claim → IdP claim mapping. Omitted from the wire when empty.
required_acrarray<string>noRequired OIDC ACR values. Omitted from the wire when empty.
required_amrarray<string>noRequired OIDC AMR values. Omitted from the wire when empty.
jit_policystring enum {allow, deny}yesJust-in-time user-provisioning policy.
statusstring enum {active, inactive, degraded, deactivated}yesAggregate status.
created_atstring (date-time)yesAggregate creation timestamp (UTC).
updated_atstring (date-time)yesLast-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 statusProblem.codeOriginOperationsTrigger
400invalid-idhandlerGetByID, Patch, Delete, PatchStatusPath {id} is not a non-zero UUIDv7.
400invalid-bodyhandlerPOST, Patch, PatchStatusBody is not a valid JSON document for the named schema.
400invalid-jit-policyhandlerPOST, Patchjit_policy is outside {allow, deny}.
400invalid-statushandlerPatchStatusstatus is outside {active, deactivated}.
400empty-patchhandlerPatchEvery field of IdPBindingPatchRequest is nil — no-op writes are rejected. Emitted via writeProblemWithCode so the explicit code field is set.
400invalid-bindinghandlerPOSTThe idp.NewIdPBinding aggregate constructor rejected the input (issuer / discovery URL / claim mapping invariants). The detail interpolates the underlying validation error.
400invalid-inputrepoPOST, Patch, Delete, PatchStatusidprepo.ErrInvalidInput — repo-side rejection of the request shape.
400missing-domainrepoPOST, Patchidprepo.ErrForeignKeyMissingdomain_id references a Domain that does not exist.
400check-violationrepoPOST, Patch, Delete, PatchStatusidprepo.ErrCheckViolation — SQL CHECK constraint rejected the row.
400domain-requiredhandlerListThe domain_id query parameter is missing. The handler REQUIRES it in v1 (see DECISION block in the handler).
401unauthenticatedhandlerevery operation on this surfaceNo resolved Principal on the request context.
403(PermissionDenied)transportevery admin route in the specReBAC denied the admin gate. Body is a PermissionDenied schema (NOT a Problem with code: permission_denied), established by the ReBAC layer.
404binding-not-foundrepoGetByID, Patch, Delete, PatchStatusidprepo.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.
409binding-conflictrepoPOST, Patchidprepo.ErrConflict — another active binding exists for the same (domain_id, issuer) pair, or an optimistic concurrency check failed.
500internalhandlerevery operation on this surfaceUnexpected 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.
501not-provisionedhandlerevery admin operation on this surfaceDeps.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.

ScenarioCookieServer-side TokenSessionHTTP responseCookie clearedUserSignedOut outbox eventAudit log
1absentn/a204yesnono audit row (no session to revoke; a sign-out click on a stale tab still succeeds without polluting the outbox with phantom rows)
2presentfound204yesyes — canonical sign-outsuccess path through SignOutAuditor.RecordSignOut
3presentmissing / stale / unknown204yesnono 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"
4presentfound, but SignOutAuditor errors204yes(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.

FieldTypeRequiredNotes
idp_binding_idstring (uuid)conditionalPin 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_idstring (uuid)conditionalDomain 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_tostringnoPost-sign-in redirect target. Must be an allow-listed relative path or absolute URL.
promptstringnoOptional 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 shapeActive bindings on the DomainHTTP statusProblem.codeTrigger
idp_binding_id set (with or without domain_id)n/a — explicit pin wins200(none)BindingRepo.GetByID returned the row.
idp_binding_id setn/a — id missing404binding-not-foundBindingRepo.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 onlyexactly one200(none)The unique active binding drives the flow.
domain_id onlyzero404binding-not-foundThe 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 onlytwo or more400multiple-bindingsThe 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 setn/a400bad-requestBody 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 unreachable502oidc-discoveryCarried 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.

FieldTypeRequiredNotes
authorization_urlstringyesUpstream IdP authorization URL the caller redirects to (carries state, nonce, code_challenge, code_challenge_method=S256, redirect_uri, scopes, optional prompt).
statestringyesOpaque state value the callback correlates on.
code_verifier_handlestringyesOpaque handle — the server holds the real PKCE verifier under this key for the duration of PLEXSPHERE_AUTH_STATE_TTL.
noncestringyesOIDC 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:

OutcomeAccept matches application/json or application/problem+jsonOtherwise (browser leg — text/html, */*, absent)
Success — token exchange and JIT user provisioning succeededn/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 failureapplication/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.

OperationOutcomeOutbox eventNotes
POST /v1/admin/idpsuccess (201)IdPBindingRegisteredAppended in the same transaction as UpsertBinding.
PATCH /v1/admin/idp/{id}success with at least one field changed (200)IdPBindingUpdatedRepo 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}/statusstatus=active accepted (200 / 204)IdPBindingActivatedAppended atomically with the status flip.
PATCH /v1/admin/idp/{id}/statusstatus=deactivated accepted (200 / 204)IdPBindingDeactivatedAppended atomically with the status flip.
DELETE /v1/admin/idp/{id}success (204)IdPBindingDeactivatedSoft 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/whoamiscenario 2 — session existed (204)UserSignedOutEmitted via SignOutAuditor.RecordSignOut; the event carries the resolved UserID and DomainID.
DELETE /v1/auth/whoamiscenarios 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