0055. Enterprise identity: verified domains, OIDC and SAML single sign-on, SCIM provisioning
- Status: Proposed for owner review
- Date: 2026-11-01
- Extends: ADR 0017, ADR 0030, ADR 0036, ADR 0040, ADR 0041, ADR 0053
Observable acceptance criteria
Section titled “Observable acceptance criteria”- An organization owner or administrator adds a domain, publishes the TXT record GuestCraft shows, and verifies it. A domain belongs to one organization at a time. GuestCraft checks the record again every 24 hours; when it is missing, the domain is failing with a visible warning for 7 days, then lapses: single sign-on for its addresses stops and the sessions it began end. Releasing a domain has the same effect at once. Every step is audited.
- They create an OIDC connection (issuer, client ID, secret) or a SAML connection (IdP entity ID, SSO URL, signing certificates), choose the proved domains it serves, the workspace roles of a first sign-in, and whether the identity provider’s second factor counts. GuestCraft shows the redirect URI, or the SAML entity ID, ACS and metadata URLs, to configure the identity provider.
- A person types their e-mail address on the sign-in page; an address of a domain served by an
enabled connection continues at that organization’s identity provider, any other address gets
the same answer as an unknown one. No list of connections or organizations exists, and an
answer’s timing does not tell either:
- Every discovery answer comes 300 ms after its request at the earliest (a hit usually takes 15 to 25 ms). A timer can wake early, so the deadline is checked again after each wait, on the monotonic clock GuestCraft injects, and waited on again (at most 8 waits, so a broken timer cannot hold an answer).
- A miss stores what a hit stores, through the same path and the same parameterized
statements, so a server that stores slowly under load delays both alike, beyond the floor
too. Every start, OIDC or SAML, runs: Better Auth’s read of the provider row and its write of
the sign-in’s state (a verification), each in a transaction of its own; then one GuestCraft
transaction that inserts the start’s flow (
sso_flows) and drops expired flows. A miss calls a decoy endpoint of Better Auth (/sign-in/sso-decoy, closed to clients like/sign-in/sso): it reads a provider no connection has, keeps its state through Better Auth’sgenerateStatewith a provider reference of a real one’s size and fields, and builds an authorization URL. Discovery then inserts the decoy’s flow, under a connection identifier no row has, and the statement that drops expired flows also removes the decoy’s flow and state; a hit runs that statement with nothing of its own to remove. - Why no foreign key binds a flow to its connection (migration 0075). The nonce’s foreign key made every OIDC start take a key-share lock on its connection’s row, and gave a decoy no row to name but a real one: the first decoy named the oldest connection, another organization’s, so a lock on that row delayed misses only, and with no connection at all a miss inserted nothing. A sentinel connection would have needed a sentinel organization in every organization’s tables. The binding is already checked where it matters: the callback takes a nonce only for the connection whose callback presents its state, and the ACS refuses a binding of another connection. A deleted connection’s flows sign nobody in and expire within 10 minutes. Without the key, no start locks a connection’s row, its own or another organization’s.
- Failures. A decoy fails where a hit fails, the same way: Better Auth logs a failure of
its own storage and answers 500, discovery then rethrows the store’s
InfrastructureError(authOperation); a failure of the flow’s transaction is thrown as it is. Nothing is swallowed on either side. - The decoy is never sent, and no other transaction ever sees its flow. No browser holds its state, nor the signed cookie a callback also needs, and a crash before the flow’s transaction leaves only that state, which expires after 10 minutes as a hit’s does. It runs after the client’s discovery budget is spent, and is neither logged nor audited.
- Better Auth limits neither
/sign-in/ssonor the decoy’s endpoint per client: only discovery reaches them, after spending the client’s budget, and that limit was a write a miss would not have made. - SAML starts store what OIDC starts store: Better Auth keeps no record of the AuthnRequest
(
enableInResponseToValidation: false). That record duplicated GuestCraft’s browser binding, which keeps the request’s ID with its connection and RelayState and refuses a response that answers another request. The tradeoff: Better Auth’s independent request-ID row, its check of the provider reference stored with it, and its 5-minute lifetime are gone; GuestCraft’s binding (connection, request ID, RelayState, one-time__Host-cookie) and its 10-minute lifetime are now the only replay binding of a response to its request. Better Auth still checks the signature, audience, recipient, timestamps, the response’s shape and the provider reference in the RelayState’s own state, and reserves each assertion ID once. Integration tests refuse a response that answers another request, carries another RelayState, or is posted to another connection’s ACS; each fails when GuestCraft’s check of it is removed. - The auth tests check it at three levels. Real PostgreSQL traces: an OIDC hit, a SAML hit and a miss run the same normalized SQL in the same 23 round trips, on an instance without any connection, with disabled connections only, and among several organizations; a miss’s state has a hit’s fields and size, and nothing of it remains. No start waits on a lock of every connection’s row, or on their uncommitted deletion. A lock on the provider, verification or flow table fails a hit and a miss with the same error and the same log lines. With every write of a state or flow slowed to 400 ms, a miss and a hit take the same time (a unit test on discovery’s timer, and the integration test). The integration test also measures 30 hits, 30 misses and 30 misses of another domain in turns: their medians differ by under 5 ms beyond what the two groups of misses differ by; a one-sided Mann-Whitney test at a 0.001 level does not find hits slower than misses by more than 5 ms; and a one-sided Fisher exact test at a 0.001 level does not find more hits than misses over the floor plus 10 ms, the tail where storage that outlasts the floor shows.
- Measured: medians within 0.3 ms; without the floor, hits took about 7.5 ms more at the median and the rank test’s z was over 6. Before misses stored anything, a 300 ms stall in 18% of the writes of a state or nonce put 10 hits and no miss after the floor plus 10 ms: the tail test failed (p = 0.0004) while the median and rank tests passed. With the decoy, the tail test passes (9 hits and 18 misses, p = 0.996). With every write of a state or flow taking 400 ms, hits took 0.7 ms more than misses at the median: what remains of a hit’s in-process work (an OIDC start’s provider configuration and secret; a SAML start’s AuthnRequest) once storage outlasts the floor.
- A first sign-in makes the person a
memberof the organization with the connection’s workspace roles. An existing account with a password is linked only after its owner signs in with that password and confirms. The organization’s owner keeps their own sign-in whatever happens to SSO, DNS or SCIM. - An organization creates SCIM tokens (shown once, hashed, expiring, revocable, audited) and gives its identity provider the SCIM 2.0 base URL. Provisioned people become members; provisioned groups map to workspace roles; deactivating or deleting a person ends every way they have in: membership, browser and device sessions, pending device codes, remembered browsers, the API keys and organization tokens they created there, and the OAuth applications they authorized there (ADR 0047), with their tokens.
Decisions
Section titled “Decisions”Better Auth’s plugins, GuestCraft’s rules
Section titled “Better Auth’s plugins, GuestCraft’s rules”@better-auth/sso and @better-auth/scim 1.7.10 (MIT, the catalog’s Better Auth version) serve
the protocol endpoints: the OIDC callback, the SAML service provider (metadata, ACS), and SCIM 2.0
/Users, /Groups and discovery. GuestCraft keeps every decision that involves organizations,
roles, assurance or credentials, and closes the rest with the guard (ADR 0017):
| Route | Status |
|---|---|
POST /api/auth/sso/discover, POST /api/auth/sso/link-preview, POST /api/auth/sso/link |
GuestCraft’s own |
GET /api/auth/sso/callback/{sso_id}, POST /api/auth/sso/saml2/sp/acs/{sso_id}, GET /api/auth/sso/saml2/sp/metadata |
Better Auth, wrapped |
| `/api/auth/scim/v2/(Users | Groups)[/{id}], ServiceProviderConfig, Schemas, ResourceTypes` |
/sign-in/sso, /sso/register, /sso/providers, /sso/get-provider, /sso/update-provider, /sso/delete-provider, /sso/request-domain-verification, /sso/verify-domain, SLO |
closed (404) |
Connections, domains and SCIM tokens are administered through the public API
(enterprise_identity tag) by organization owners and administrators, from a browser session
that meets the organization’s policy (auth.step_up_required otherwise), like ADR 0053’s policy.
Domains
Section titled “Domains”- Only a registrable domain is stored (one label below a public suffix, private suffixes such as
shared hosting included;
tldts), so one organization holds a domain and all its subdomains. A partial unique index (organization_domains_owner) holds the fence: a proved domain (verified or failing) has one organization; proofs of one domain are serialized by an advisory lock. - The record is
_guestcraft-verification.<domain> TXT "guestcraft-verification=<token>", the token 128 random bits; the strings of a TXT record are joined before the exact comparison (RFC 7208 §3.3). The runtime resolves throughnode:dnswith a 3-second timeout; a name without the record proves nothing, a failed lookup changes nothing but the time of the attempt: the domain is looked up again an hour later, so one unreachable name server cannot hold the head of every run. - Checks: every proved domain whose last check is 24 hours old, at most once an hour per process
on ordinary traffic, daily from Vercel’s cron, and from
GET /api/internal/domains/check. Missing record:failingfrom then on (audited once), 7 days of grace, thenlapsed(audited). A record found again during the grace days restoresverified(audited). - Release or lapse: the connection stops serving the domain (Better Auth’s provider row lists only proved domains) and the browser sessions and devices that the organization’s SSO began for people on that domain end. Handover to another organization happens only after release or lapse, with that organization’s own new proof. Existing accounts and their links never move.
Single sign-on
Section titled “Single sign-on”- Identity. An account is bound to
(connection, subject): Better Auth’s account key is the connection’sproviderIdand the OIDCsubor SAML NameID. Implicit linking by e-mail stays off. A connection’s identity provider (OIDC issuer, SAML entity ID) never changes: another provider could reuse a subject. Deleting a connection removes its links and the SSO sessions they began. - Refusals (
?error=on the sign-in page): an address outside the connection’s proved domains; a person the organization’s SCIM deactivated or deleted; a linked person who is no longer a member (an administrator removed them: single sign-on never undoes that, an invitation does); an account another subject of the same connection holds; any other existing account, unless it has a password (then the link waits) or the organization’s SCIM created it with no other credential and no other organization (then it is linked at once). - Linking an existing password account: GuestCraft keeps the request (digest of a 256-bit
token, 15 minutes, one per person) and sends the browser to
/sign-in/sso-link#token=. The account’s owner signs in there with their current password and second factor; only a session created after the request confirms it (POST /api/auth/sso/link). The page first shows which organization and connection ask (POST /api/auth/sso/link-preview, nothing about the account). Confirmation checks again that the connection is enabled and serves a proved domain of the address, and that the organization’s SCIM has not deprovisioned the person since. Its times are the database’s, since Better Auth stamps sessions with the system clock. - OIDC. Every provider row is written by GuestCraft with PKCE (S256), the endpoints of the
issuer’s discovery document (fetched at configuration, HTTPS on a public host or a trusted
origin),
openid email profile(no refresh token),client_secret_basicor_postonly, no identity-provider-initiated flow. Better Auth sends no nonce and reserves the parameter, so discovery adds a fresh 256-bit nonce to the authorization URL and keeps it by the digest of the flow’sstatefor 10 minutes; the callback takes it once andresolveUsercompares it in constant time with the verified ID token’snonce(OIDC Core §3.1.2.1, §3.1.3.7). Better Auth checks issuer, audience,azp, signature and the UserInfo subject. Identity-provider tokens are stored encrypted with the auth secret. - SAML. Signed assertions required, pinned IdP certificates,
requireTimestamps, InResponseTo checked against GuestCraft’s browser binding (below), one assertion per response, audience, recipient and destination checks, replay refused by Better Auth’s single-use reservation of the assertion ID, deprecated algorithms rejected, 60 seconds of clock skew. Identity-provider-initiated SSO is not supported: an unsolicited response, or one without the RelayState of a sign-in GuestCraft started, is refused. Better Auth keys that reservation by a digest, not a UUID:verification.idbecomes text. - SAML login CSRF. The ACS is a cross-site POST, which Better Auth does not bind to a browser
(its RelayState cookie is SameSite=Lax, so it skips the check). When discovery starts a SAML
sign-in, GuestCraft sets a one-time 256-bit token in a
__Host-guestcraft.saml_flowcookie (Secure,HttpOnly,SameSite=None,Path=/, 10 minutes) and keeps its digest with the digest of the AuthnRequest’s RelayState, the request’s ID and the connection (sso_flows). The ACS takes that binding once, whatever it then answers, and refuses (SSO_STARTED_ELSEWHERE, before Better Auth) a response without the cookie, of another connection, with another RelayState, or answering another request (InResponseTo). A response a sign-in started in one browser, posted from another, signs nobody in; the answer clears the cookie. A browser keeps one SAML sign-in in flight: starting another replaces the first. - SAML Destination and time. Core §3.2.2 makes a response’s
Destinationoptional, and Better Auth checks it only when present. GuestCraft requires it and compares it exactly with the connection’s ACS URL. Better Auth checksNotBefore/NotOnOrAfteron its own wall clock (Date.now(), no injection point in 1.7.10), so GuestCraft first checks the assertion’s Conditions and every bearerSubjectConfirmationDataNotOnOrAfteron its injected clock, with the same 60 seconds of skew: valid fromNotBefore− 60 s, until beforeNotOnOrAfter+ 60 s. Either refusal isSSO_RESPONSE_INVALID. Better Auth’s wall-clock check then still runs: an instance whose clock disagrees with its database’s by more than the skew refuses responses. - Assurance (on ADR 0053’s model). A session records
sso, orsso_mfawith the claim that justified it (amr:<value>oracr:<value>), and the organization whose connection signed it in.sso_mfarequires the connection to trust its identity provider’s second factor (off by default) and a listed value: OIDCamr(RFC 8176) oracr, SAMLAuthnContextClassRef. It meetssecond_factorin that organization only, neverpasskey; in another organization it is a plainsso. A device paired by such a session holds the same organization. - Provisioning. After Better Auth’s own transaction, GuestCraft makes the person a
member(neveradminorowner) with the connection’s workspace roles (built-in or custom, of active workspaces of the organization, at every property or at selected ones, re-checked when granted), in one transaction under the organization’s lock, audited assso.member_provisioned; every sign-in is audited assso.signed_inwith its assurance. A failure ends the new session. An existing member keeps their roles. A person SSO created has no password; ADR 0053’s reset by the CLI gives them one, so break-glass covers them too.
- One connection per organization: its id and provisioning domain are the organization’s.
Tokens:
gc_scim_and 256 random bits, SHA-256 digest only, 1–730 days, at most 5 live, revocable, last use written at most every five minutes; issuance, revocation and refused uses are audited. - Scopes. A token holds the scopes chosen when it is created, never changed: one or more of
scim.users.read,scim.users.write,scim.groups.read,scim.groups.write(a database check keeps them a non-empty subset;scim.token_createdrecords them). They are the only scopes Better Auth’s plugin sees, and it refuses every resource route they do not cover (403); the discovery endpoints need none. The API requires the choice, with no default: a token’s power is an explicit decision. The screen proposes all four, which Microsoft Entra ID and Okta need. - Every SCIM request runs in one GuestCraft transaction that Better Auth’s queries join
(
joinTransaction: its transactions become savepoints). The plugin’s writes and GuestCraft’s reconciliation of each person it changes commit together; a refused request changes nothing. Nothing else may take a connection meanwhile (a transaction pooler’s only connection is held): Better Auth’s per-client limit is off for/scim/v2/**and GuestCraft spends its own budgets before the transaction; Better Auth’s schema check is settled before it. - Budgets, each 600 requests a minute and independent of the others: the client address’s,
spent first and before anything is looked up; then, for a token GuestCraft issued (found by its
digest in a short read before the request’s transaction), the token’s, so one token behind many
addresses is limited; and, for a live token, its organization’s, across all its tokens. A
revoked or expired token spends its own budget only, so a leaked old token cannot exhaust its
organization’s. A spent budget answers 429 in SCIM’s error format with
Retry-After, before the transaction, and changes nothing. - Addresses. A person whose address is on a domain another organization proves is refused
(400
invalidValue), whether created or changed: SCIM of one organization never provisions the people another organization signs in. - Reconciliation of an active person:
memberif they are not one; exactly the workspace roles of their active groups among the memberships SCIM granted (scim_managed_memberships), the strongest role per workspace when groups disagree (most permissions, then built-in, then order); a membership an administrator granted is never changed. Changing a group’s mapping reconciles its members. - Properties. A connection’s default grant and a group’s mapping hold their role at every
property of the workspace or at selected properties, as a member’s (ADR 0036): stored as
property_scopeand rows of the workspace’s properties (composite keys, a deleted property leaving the grant; a selection left empty grants nothing). Up to 100 properties, each once, of the grant’s workspace. When several of a person’s groups grant one workspace, the strongest role wins and holds the properties of every group that grants that same role, every property absorbing a list; a weaker role’s properties never widen it. A first sign-in and SCIM reconciliation write the membership’sproperty_scopeand selected properties; a change of a mapping’s properties changes the memberships SCIM granted, never an administrator’s. The configuring owner or administrator holds every property, so a grant never exceeds its author’s access; SSO and SCIM never change an existing administrator-granted membership, so they never widen one either. - Deactivation and deletion (
active: false,DELETE): the membership with every workspace role, every browser session and remembered browser of the person, their device sessions and pending codes in the organization, the API keys and organization tokens they created there, and their OAuth applications’ grants there, with the provider’s access and refresh tokens and consents under them, end in the same transaction (scim.user_deprovisioned, with counts): a reactivated person, even one a group grants the workspace again, must authorize an application again. That holds in archived workspaces too, which no runtime policy reaches: a narrowSECURITY DEFINERfunction (migration 0074,revoke_person_credentials, granted to the API role only) revokes the person’s keys and grants in every workspace of the organization, so restoring a workspace never brings one back.DELETEkeeps the account and Better Auth’s tombstone of the SCIM link (re-provisioning with the sameexternalIdlinks it again); it never erases anything: erasing a staff account is a separate workflow. A deprovisioned person cannot come back through a first SSO sign-in. - SCIM never deactivates or deletes the organization’s owner: either answers 400
mutabilityand changes nothing. It still keeps the name and e-mail address of an owner it created up to date. With the owner’s own password (and ADR 0053’s reset by the CLI), this is the break-glass way in that SSO, DNS and SCIM cannot remove.
Personal data
Section titled “Personal data”The guest personal-data catalog covers the guestcraft workspace tables; these tables are staff
identity in guestcraft_auth and hold no guest data. Their classification:
| Table | Holds |
|---|---|
organization_domains |
Organization identifiers: a registrable domain and its verification token (not a secret: it is published in DNS) |
sso_connections, ssoProvider, sso_connection_grants and their properties |
Configuration; the OIDC client secret only sealed with ENCRYPTION_KEYS, SAML certificates are public |
sso_flows, sso_link_requests |
Short-lived secrets, stored as digests, deleted when used or expired |
scim_tokens |
Secrets as SHA-256 digests, with name, scopes and use times |
scimUser, scimSubject, scimConnectionBinding, scimIdentityTombstone, scimProjectionGrant, scim_managed_memberships |
Staff identifiers: the link between a staff account and its identity provider’s externalId |
scimGroup, scimGroupMember, scim_group_grants and their properties |
Directory group names and memberships of staff |
Erasing a staff account is not part of this decision (SCIM DELETE deprovisions and keeps a
tombstone).
Consequences
Section titled “Consequences”- Migration 0070 adds the plugins’ tables with Better Auth’s names, GuestCraft’s tables
(
sso_connections,organization_domains,sso_connection_grants,sso_nonces,sso_saml_flows,sso_link_requests,scim_tokens,scim_group_grants,scim_managed_memberships), the session columnsssoOrganizationIdandssoAssuranceClaim,device_sessions.sso_organization_id, and changesverification.idto text: a contract migration (a runtime before it compares invitation identifiers with it as UUIDs). - Migration 0075 replaces
sso_noncesandsso_saml_flowswithsso_flows, without a foreign key to the connection: a contract migration (a runtime before it writes the dropped tables). Sign-ins in flight when it runs start again. - Tests: the independent identity provider simulator (
@guestcraft/identity-provider-simulator, written from OIDC Core/Discovery, RFC 6749/7636 and SAML 2.0, sharing no code with the verifier) drives OIDC and SAML sign-ins and every malformed response in PostgreSQL integration tests and the browser test; SCIM requests run against the real handler and database. - An internal identity provider on a private network must be listed in
GUESTCRAFT_TRUSTED_ORIGINS: Better Auth fetches token, JWKS and UserInfo endpoints only on public hosts or trusted origins. - The OIDC client secret is sealed with
ENCRYPTION_KEYSby the integration vault (ADR 0021, ADR 0040: a per-value data key wrapped by a key derived for this kind of value, AES-256-GCM), bound to the instance, the organization and the connection’ssso_identifier. The provider row holds only the envelope (clientSecretSealed), and the database refuses a plaintextclientSecret(ssoProvider_client_secret_sealed). Better Auth reads the row through an output transform of that column: the secret is opened only while the OIDC callback of that same connection runs, for the code exchange, and every other read (discovery, sign-in start) carries no secret. An envelope that does not open under the callback’s binding fails closed: the identity provider refuses the exchange. The API never answers the secret (client_secret_setonly), and nothing logs it. An instance withoutENCRYPTION_KEYSanswersserver.unavailableto creating or changing an OIDC connection. - Rotation and earlier plaintext. The credential maintenance (
reencrypt, ADR 0044) rewrites the sealed secrets to the current key version with every other integration secret. A database that ran an earlier version of migration 0070 may hold plaintext secrets: the rule is added unvalidated, every write obeys it at once, and the maintenance first seals what remains and then validates it. Until then, such a connection signs nobody in: a plaintext secret is never passed to Better Auth.
Not done, with reasons
Section titled “Not done, with reasons”- Enforcing SSO (refusing password sign-in for members on a proved domain) is not built: it needs the owner to choose who is exempt beyond the owner, and members keep their passwords until then. SSO is offered, never imposed.
- Encrypted SAML assertions, signed AuthnRequests, SAML Single Logout, IdP metadata upload, and private-key client authentication are not offered.
Alternatives considered
Section titled “Alternatives considered”- Better Auth’s own provider registration and domain verification routes. They verify per
provider with the system clock and resolver, accept a bare token, never re-check, and let a
provider start a sign-in by
providerId, domain or organization slug (enumeration). GuestCraft owns domains and writes the provider rows. - The plugin’s organization provisioning. It runs after Better Auth’s transaction, grants only
Better Auth’s
member/admin, and knows no workspace roles or audit. - Reconciling SCIM after the plugin commits. A failure between the commit and the reconciliation would leave a deactivated person with their devices and keys; the joined transaction makes it all or nothing.
- Triggers maintaining workspace memberships from staging rows. Atomic too, but business rules in PL/pgSQL, apart from the stores and their tests.