sso
The enterprise-SSO deployment surface: the half of enterprise single sign-on that RECEIVES a request. oidc is a relying party with no callback, saml a service provider with no assertion consumer service, scim provisioning semantics with no route, and session holds the result of a login that nothing starts; this module is that remainder, written once, as MOUNTABLE handler verbs. Every verb takes the code.md section 10.3.3 request and answers a response, so it mounts unchanged on an http-service resource, on http's serve loop, or on the XAP host's route surface — which is what makes the graded in-tree deployment and a customer's deployment the same code. login begins an OpenID Connect authorization and parks the request in session's store seam, single-use and TTL-bounded, with the return-to the user asked for parked beside it so it never rides the wire; callback takes it back once and runs session attach-oidc, which redeems the code in-path so no verb anywhere attaches a claim set it did not verify; acs reads an application/x-www-form-urlencoded body with url query-parse — the body IS a query string, so there is no second parser — base64-decodes the SAMLResponse and hands it to attach-saml; metadata emits this service provider's own descriptor. Three logout routes: the relying party's own, which detaches and then revokes under RFC 7009 best-effort, the front channel a hidden iframe drives, and the back channel whose logout token names a session through the issuer-and-sid binding callback wrote — deleted with the session, which is what makes a replayed token inert. The SCIM route set serves RFC 7644 over the same store behind bearer-ok, a constant-time guard built out of HMAC because CX has no constant-time string equality and this module adds no native. Every request-shaped refusal is ANSWERED as a response carrying its code and never raised, and nothing here re-codes a fault from a module below it.
sso:login
[$sso:login] -> element — Begin an OpenID Connect login: check the requested return-to against the allow-list, generate state, nonce and the PKCE verifier together, park them, and answer a 302 to the provider. A return-to outside cfg.return-to-allow is refused before anything is parked.
[?lib 'cx-platform/sso']
[?let [= $r [$sso:login [request method=GET path='/login' [query-params [return-to 'https://evil.example']]] {}]]
[$string $r/body/err@code]]
cx-err:CXER6002
sso:callback
[$sso:callback] -> element — The return leg: take the parked authorization request back ONCE by state, redeem the code through session attach-oidc, and answer a 302 to the parked return-to. A state naming no takeable pending row — never issued, already taken, or expired — is one refusal, deliberately.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/oidc']
[?lib 'cx-stdlib/time']
[?let [= $p [oidc-provider issuer="https://idp.example" authorization-endpoint="https://idp.example/authorize" token-endpoint="https://idp.example/token" jwks-uri="https://idp.example/jwks"]]
[= $c [$oidc:config $p {client-id: "my-client", redirect-uri: "https://rp.example/cb"}]]
[= $r [$sso:callback [request method=GET path='/cb' [query-params [code 'abc'] [state 'NEVER-ISSUED']]] {config: $c, now: [$time:datetime 2023 11 14 22 13 20]}]]
[$string $r/body/err@code]]
cx-err:CXER6001
sso:acs
[$sso:acs] -> element — The SAML assertion consumer service: parse the form body with url query-parse, refuse a RelayState outside the allow-list BEFORE the document is looked at, base64-decode SAMLResponse, and hand it to session attach-saml.
[?lib 'cx-platform/sso']
[?let [= $r [$sso:acs [request method=POST path='/acs' [body 'SAMLResponse=PHNhbWw%2B&RelayState=https%3A%2F%2Fevil.example']] {}]]
[$string $r/body/err@code]]
cx-err:CXER6002
sso:metadata
[$sso:metadata] -> element — Answer this service provider's own md:EntityDescriptor, byte-deterministically, so an identity provider that re-imports it sees no diff unless the deployment changed.
[?lib 'cx-platform/sso']
[?let [= $r [$sso:metadata {sp: [sp entity-id="https://rp.example" [acs binding=HTTP-POST location="https://rp.example/saml/acs" index=0 default=true]]}]]
$r@status]
200
sso:logout
[$sso:logout] -> element — Relying-party-initiated logout: detach the session, revoke the filed refresh token under RFC 7009 when the provider advertises a revocation endpoint, clear the cookie, and redirect. The revocation is best-effort and never fails the logout.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/http-client' as=http]
[?lib 'cx-stdlib/strings']
[?let [= $r [$sso:logout [request method=GET path='/logout'] {}]]
[= $sc [$string [$first [$http:headers-named $r 'Set-Cookie']]@value]]
([$http:status $r], [$strings:contains $sc 'Max-Age=0'])]
(302, true)
sso:front-channel-logout
[$sso:front-channel-logout] -> element — OpenID Connect Front-Channel Logout: a GET in a hidden iframe naming a session by sid and iss. An unbound pair still answers 200 — an iframe cannot act on a refusal — and says so in the body.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/http-client' as=http]
[?let [= $r [$sso:front-channel-logout [request method=GET path='/fc' [query-params [sid 'S1'] [iss 'https://idp.example']]] {}]]
([$http:status $r], [$string $r/body/err@code])]
(200, 'cx-err:CXER6005')
sso:back-channel-logout
[$sso:back-channel-logout] -> element — OpenID Connect Back-Channel Logout: a POST whose logout_token is verified before its sid names a session, and whose replay is inert because the binding went with the session. A body carrying no logout_token is refused as the wrong request shape.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/oidc']
[?lib 'cx-stdlib/time']
[?let [= $p [oidc-provider issuer="https://idp.example" authorization-endpoint="https://idp.example/authorize" token-endpoint="https://idp.example/token" jwks-uri="https://idp.example/jwks"]]
[= $c [$oidc:config $p {client-id: "my-client", redirect-uri: "https://rp.example/cb"}]]
[= $r [$sso:back-channel-logout [request method=POST path='/bc' [body 'nothing=here']] {config: $c, now: [$time:datetime 2023 11 14 22 13 20]}]]
[$string $r/body/err@code]]
cx-err:CXER6000
sso:scim-route
[$sso:scim-route] -> element — The RFC 7644 route set over the deployment's store: Users and Groups with create, read, replace, patch, delete and a filtered, paginated list, plus the three discovery documents. A method and path pair it does not serve is refused by name.
[?lib 'cx-platform/sso']
[?let [= $r [$sso:scim-route [request method=DELETE path='/scim/Schemas'] {}]]
[$string $r/body/err@code]]
cx-err:CXER6007
sso:bearer-ok
[$sso:bearer-ok] -> element — The auth guard the SCIM routes mount: a constant-time comparison of the presented bearer against cfg.scim-token, built out of HMAC. An unset cfg.scim-token is not open — it refuses every request.
[?lib 'cx-platform/sso']
[?let [= $e [$sso:bearer-ok [request method=GET path='/scim/Users' [headers [header name='Authorization' value='Bearer wrong']]] {scim-token: 'right'}]]
[$string $e@code]]
cx-err:CXER6004
sso:form
[$sso:form] -> map — The one body reader: an application/x-www-form-urlencoded body IS a query string, so url query-parse reads it and no second parser exists here. A body past cfg.max-body-bytes is refused before it is parsed.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/map']
[?let [= $f [$sso:form [request method=POST path='/acs' [body 'RelayState=%2Finvoices&SAMLResponse=PHg%2B']] {}]]
[$string [$map:get $f 'RelayState']]]
'/invoices'
sso:saml-login
[$sso:saml-login] -> element — SP-initiated SAML: mint an AuthnRequest, sign it when the tenant publishes AuthnRequestsSigned, park its id with the return-to beside it, and answer a 302 on the Redirect binding whose RelayState is the parked key and nothing else. A return-to outside the allow-list is refused before anything is parked.
[?lib 'cx-platform/sso']
[?let [= $r [$sso:saml-login [request method=GET path='/saml/login' [query-params [return-to 'https://evil.example']]] {}]]
[$string $r/body/err@code]]
'cx-err:CXER6002'
sso:slo
[$sso:slo] -> element — Single Logout in both directions and on both bindings: a signed LogoutRequest detaches the sessions its SessionIndex names and is answered with a LogoutResponse even when it matched nothing, and a returning LogoutResponse is accepted whatever its Status. A request carrying neither parameter is not this route's shape.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/time']
[?let [= $r [$sso:slo [request method=GET path='/saml/slo'] {now: [$time:datetime 2026 9 12 12 0 0]}]]
[$string $r/body/err@code]]
'cx-err:CXER6000'
sso:token
[$sso:token] -> element — The machine grant: client credentials become a session for a SERVICE IDENTITY, resolved through the link table and answered as a BEARER rather than a cookie, because a machine client has no browser and must not be handed one. An authorization code is redeemed by the callback route and never here.
[?lib 'cx-platform/sso']
[?let [= $r [$sso:token [request method=POST path='/auth/token' [body 'grant_type=authorization_code&code=abc']] {}]]
[$string $r/body/err@code]]
'cx-err:CXER6000'
sso:device-begin
[$sso:device-begin] -> element — RFC 8628 device authorization: ask the provider for a user code, file the provider's OWN device code here, and answer an opaque reference in its place — a command-line client that held the real device code could poll the provider directly and bypass every policy this deployment applies.
[?lib 'cx-platform/sso']
[?let [= $r [$sso:device-begin [request method=GET path='/auth/device'] {}]]
[$string $r/body/err@code]]
'cx-err:CXER6000'
sso:device-poll
[$sso:device-poll] -> element — The other half: take the filed row by its reference WITHOUT spending it, refuse a poll inside the advertised interval, and mint the session when the person has authorized. Waiting answers a 202 value and not a refusal, because waiting is the normal state of a device flow.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/time']
[?let [= $r [$sso:device-poll [request method=POST path='/auth/device/poll' [body 'device_code_ref=NEVER-ISSUED']] {now: [$time:datetime 2026 9 12 12 0 0]}]]
[$string $r/body/err@code]]
'cx-err:CXER6001'
sso:health
[$sso:health] -> element — The deployment is serving. Pure and capability-free: it reads no store, dials nothing and depends on no tenant, so a load balancer removing a node never depends on an identity provider being reachable.
[?lib 'cx-platform/sso']
[?let [= $r [$sso:health [request method=GET path='/health'] {}]]
($r@status, [$string $r/body/health@ok])]
(200, 'true')
sso:ready
[$sso:ready] -> element — The deployment is CONFIGURED: every tenant has a usable identity-provider configuration, the store is reachable, the published base URL is https, and an audit journal exists. Each shortfall is a named reason, so "we had no audit trail" is a condition a deployment can see rather than discover.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/http-client' as=http]
[?let [= $r [$sso:ready [request method=GET path='/ready'] {}]]
([$http:status $r], [$string $r/body/ready@ok], [$string $r/body/ready/reason@kind])]
(503, 'false', no-journal)
sso:list-sessions
[$sso:list-sessions] -> element — The operator view of who is signed in: one session-record per live session, carrying the session id as a HASH so an operator can correlate two records without holding a credential. Behind the policy enforcement point, and a denial is authz's own deny value rather than an sso code.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/http-client' as=http]
[?let [= $r [$sso:list-sessions [request method=GET path='/operator/sessions'] {}]]
([$http:status $r], [$name [$first $r/body/*]])]
(403, deny)
sso:revoke
[$sso:revoke] -> element — Retire a session, a principal's sessions or a tenant's, and record who did it and why. SCIM setting active:false on a directory resource calls this with that principal, so a directory action and an operator action retire sessions through ONE path.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/http-client' as=http]
[?let [= $r [$sso:revoke [request method=POST path='/operator/revoke' [body 'principal=dana']] {}]]
[$http:status $r]]
403
sso:refresh-metadata
[$sso:refresh-metadata] -> element — Read or refresh the identity-provider configuration. With dry-run it answers the idp-record per tenant — which providers are configured, with which keys — and fetches nothing; without it, a tenant that named no metadata source cannot be refreshed and the previous configuration stands.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/http-client' as=http]
[?let [= $r [$sso:refresh-metadata [request method=POST path='/operator/refresh-metadata' [body 'dry-run=true']] {}]]
[$http:status $r]]
403
sso:rotate
[$sso:rotate] -> element — Open a rotation window: two valid values and a deadline. A value this deployment VERIFIES against cannot rotate with a zero overlap, because a message captured under the retiring value is still in flight; one it only presents may, because the identity provider is the side holding both.
[?lib 'cx-platform/sso']
[?lib 'cx-stdlib/http-client' as=http]
[?let [= $r [$sso:rotate [request method=POST path='/operator/rotate' [body 'value=scim-token&next=n2']] {}]]
[$http:status $r]]
403