diff --git a/docs/sso/plan/oauth-sso-platform-integration.md b/docs/sso/plan/oauth-sso-platform-integration.md new file mode 100644 index 0000000000..f181d20c92 --- /dev/null +++ b/docs/sso/plan/oauth-sso-platform-integration.md @@ -0,0 +1,732 @@ +# OAuth and Cross-Domain SSO Requirements + +## Purpose + +Constructive must provide a complete OAuth sign-in experience and a unified +SSO entry point for applications that may live on different parent domains. + +This document defines the final CNC behavior. The work may be delivered through +several package-focused changes, but those changes must converge on the same +requirements rather than becoming independent OAuth implementations. + +Dashboard hosts the unified authentication pages, branding, and browser +interaction. Unified authentication is an independent platform capability: it +must continue to work without entering or depending on Dashboard's database, +organization, or other management features. + +## User outcome + +A user can select an OAuth provider configured by the current tenant, complete +the provider's authentication flow, and return to the application as the same +Constructive user on every subsequent login. + +After authenticating through the unified auth entry, the user can establish a +session on another registered Constructive application even when that +application uses a different parent domain. The user must not need to repeat +the upstream provider login while the unified authentication session remains +valid and applicable policy permits SSO. + +Creating an account through OAuth authenticates an identity; it does not grant +tenant roles, schema access, API access, or data permissions. Existing +Constructive authorization remains authoritative. + +The unified authentication page always offers Constructive account/password +sign-in, registration, and password recovery. In the initial release, successful +local account/password registration immediately establishes the browser's +unified authentication session and continues the originating Site login flow. +Email verification is not a prerequisite for that session or later password +sign-in. + +## Tenant, Site, and callback registration + +A Tenant manages the Sites that may use unified authentication. + +- Each Site has a stable Site identifier within its Tenant. +- A Site may register multiple allowed callback URLs. +- Each callback URL is registered individually and matched exactly, including + scheme, host, port, path, and any registered fixed components. +- Wildcards, parent-domain suffix matching, similar hostnames, and temporary or + request-supplied callback URLs are not trusted. +- Browser-supplied Site identifiers and callback URLs are request inputs only. + They become trusted targets only after exact validation against current Tenant + configuration. +- A disabled Site or removed callback cannot start a new login transaction. + The Site and callback are revalidated before the final result or handoff is + issued, so an in-progress flow fails safely after reassignment or removal. + +Each Site has one Tenant-managed authentication mode: + +- **Confirm before sign-in (default):** when the browser already has a valid + unified session, the authentication center shows a lightweight account + confirmation page before issuing a Site handoff. +- **Silent sign-in:** when the browser has a valid unified session and no other + interaction is required, the center may complete the handoff without showing + the confirmation page. + +An absent mode always resolves to confirm before sign-in. A request parameter, +Site type, or historical behavior cannot enable silent sign-in. + +## Provider requirements + +Provider availability is registry- and configuration-driven. + +- Every provider exposed by the `packages/oauth` provider registry must use the + common OAuth flow. The current registry contains Google, GitHub, Facebook, + and LinkedIn. +- A tenant sees and can use exactly the supported providers that it has enabled + and configured. +- An unconfigured or disabled provider is not advertised and cannot be used. +- Adding another provider to the package registry must not require a separate + server workflow or provider-specific route design. +- The common SSO flow uses a protocol-neutral Provider Adapter. Provider-specific + OAuth or OIDC endpoint, request, token, verification, and profile-retrieval + behavior stays inside its adapter and must not create a separate SSO state, + identity, or session workflow. +- The Adapter pattern is a protocol-neutral interface or contract covering + authorization initiation and callback/code-to-normalized-identity completion. + Google and GitHub each implement it, and future Providers add another adapter; + no abstract base class or inheritance hierarchy is required. +- Login-transaction and OAuth-state validation, account matching or provisioning, + and shared post-authentication handoff orchestration remain in the common + Constructive service rather than any Provider adapter. +- Every adapter normalizes success to the same minimal external identity: + Provider service key, stable Provider user identifier or subject, email when + available, and safe profile details. Constructive consumes only that normalized + identity outside the adapter. +- The Google/OIDC adapter exchanges the authorization code server-side, + validates the returned identity token as identity proof, and normalizes the + resulting user data. An access token may also be returned, but v1 neither + retains nor uses it for SSO when the validated identity data is sufficient. +- The GitHub/OAuth adapter exchanges the authorization code server-side for an + access token, uses it server-side to retrieve the GitHub user and, when needed, + email data, and then normalizes that result. +- Provider callbacks return an authorization code plus OAuth state, or an error; + they do not return Provider tokens to the browser. Provider tokens remain + inside the server-to-Provider adapter boundary. +- Unsupported provider capabilities or token authentication methods fail + explicitly; they must not silently downgrade the security flow. +- Provider credentials and metadata are resolved for the current tenant and + database. There is no platform default, cross-tenant, or old-version fallback. + +Provider discovery is exposed through GraphQL. The old HTTP provider-list or +landing endpoint is not part of the target API. The unified authentication page +reads the current Tenant's enabled providers through the existing registry and +configuration surfaces; page code must not contain a fixed provider list. +Provider names used as concrete adapter or test examples, including Google and +GitHub, never form a release-specific product allowlist. Any Provider supported +by the running server's adapter registry and enabled with complete configuration +for the current Tenant appears through the same discovery and UI flow without a +Dashboard workflow change. + +## Unified authentication experience + +The unified authentication page always provides: + +- Constructive account/password sign-in; +- Constructive account registration; +- Constructive password recovery; and +- the third-party providers currently enabled and configured for the Tenant. + +When confirmation is required, the page displays only the current account's +basic identifying information, such as name and avatar, together with: + +- continue; +- switch account; and +- cancel. + +The confirmation page is not a verbose permissions or authorization-consent +page. Site roles, permissions, and data access remain owned by existing +Constructive authorization. + +The browser enters through the current Tenant's canonical, Tenant-scoped +unified-authentication origin with a Site identifier, optional exact callback +URL, optional Site-internal application-relative `returnTo`, and a Site-created +`site_state` correlation value. The authentication origin must resolve through +the existing authoritative Host routing and full Express request context; the +flow does not accept a browser-supplied Tenant/database selector or search +another Tenant's authentication state. When the callback is supplied, it must +exactly match an active registered callback. When it is omitted, the center +selects the earliest registered callback by `created_at` ascending and then ID +ascending. Exact public route names are not fixed by this requirements +document. + +Before navigation, the Site creates a cryptographically random, short-lived, +one-time `site_state` and records it under the current browser in its own +first-party server-side session boundary. Constructive binds that value to the +server-side login transaction and returns it beside the handoff code at the +exact Site callback. The Site must match the callback value to the same +browser's pending login before redemption and consume it after successful +redemption. Missing, expired, mismatched, or replayed `site_state` fails safely. +The value is public correlation only: it contains no identity, session, +credential, callback, or `returnTo` data. + +## OAuth flow requirements + +- OAuth is an explicitly enabled server capability and is disabled by default. +- When disabled, provider discovery returns no providers and browser OAuth + initiation/callback routes are not mounted. +- Enabling OAuth requires valid server configuration at options/startup time. +- Before any authentication begins, the center validates the Site and exact + callback against current Tenant configuration and creates a ten-minute, + single-use login transaction bound to that Site, callback, and browser. +- Every provider uses Authorization Code flow with S256 PKCE. +- Each initiation creates a one-time verifier/challenge pair. The verifier is + never placed in a redirect URL and is bound to the corresponding callback. +- Dashboard sends the unified login transaction identifier and selected Provider + only to Constructive. Constructive creates the existing server-side OAuth + authorization-request state, associates it with that transaction, and gives + that Provider request its own ten-minute expiry. +- The browser and external Provider receive only a cryptographically random, + opaque OAuth state value, never the unified login transaction identifier. + Server-side state binds the Provider, Tenant, database, Site, exact callback, + originating API/host, browser, PKCE relation, and original login transaction. +- The callback re-resolves the current route and target application. Any + expired, modified, replayed, or mismatched state, provider, tenant, database, + Site, callback, browser, host, API, or PKCE value is rejected. +- The technical callback must be registered and exactly validated. `returnTo` + is a separately validated Site-internal, application-relative destination; + it cannot name another Site or origin. Open redirects are forbidden. +- Provider authorization, token, and user-info endpoints must use HTTPS and + reject loopback, private, link-local, and reserved network destinations. +- Server-to-provider requests use bounded timeouts and do not automatically + follow redirects. +- Provider token and profile responses are strictly validated before use and + normalized to the minimum identity data required by the owned database + contract. Raw provider payloads are not persisted. + +The browser HTTP surface is limited to semantics that GraphQL cannot replace: +authorization initiation, provider callback, and the redirects or cookie +operations required to complete authentication. + +The Provider callback may contain a code or a Provider error. Constructive first +validates and consumes the opaque OAuth state, restores the configured Provider +and original unified login transaction server-side, and then asks the selected +adapter to exchange or verify the Provider result. + +Provider cancellation, a Provider-reported error, invalid state, or Provider +exchange or verification failure produces a clear, safe user-facing failure at +the authentication center. No handoff is issued and the failed Provider flow is +not resumed; the user restarts from the Site login entry with a new login +transaction. Raw Provider error fields are not forwarded. Every failed callback +performs the required transient-state cleanup and partial-work rollback. + +## Login transaction and result routing + +The exact Site/callback allowlist is the trust root for browser return routing. +Validation must happen before creating a login transaction, not after +authentication has already completed. + +The authentication-center server stores the login transaction. The browser +holds only an opaque, cryptographically random transaction identifier; it does +not carry transaction contents, credentials, tokens, Site trust data, or other +sensitive state. + +The server-side transaction expires ten minutes after creation and is +single-use. It binds the Tenant, Site, exact callback, and current browser. +Every confirmation action, provider callback, authentication result, handoff +issuance, and handoff consumption must match the same live, unused transaction. + +When the transaction, Site, and callback remain trusted, the same registered +Site callback may receive: + +- **success:** an opaque, short-lived, single-use handoff code for server-side + consumption as a query parameter on a top-level browser `GET` navigation; +- **non-Provider user cancellation:** a stable cancellation result without a + handoff when that interaction explicitly supports returning to the Site; or +- **a safely classified non-Provider failure:** a registered, non-sensitive + result category when its routing remains trustworthy. + +Provider cancellation and Provider-flow failures do not use this callback result +path; they follow the restart behavior in the OAuth flow requirements above. + +The Site owns the presentation of these results on its callback page. The +callback never receives raw provider errors, provider authorization or access +tokens, Constructive access/session tokens, cookies, secrets, provider payloads, +or user-sensitive data. + +If the login transaction is expired or used, state or callback binding is +modified, browser binding does not match, or the center cannot prove a trusted +target Site, the browser stays at a generic authentication-center failure page. +The center must not attempt a best-effort redirect using untrusted request data. + +## Browser and session security + +- The authentication center and every Site use only first-party session cookies + within their own host or explicitly configured local-domain boundary. The + design does not depend on third-party cookies or cookies shared across + unrelated parent domains. +- Login, unified-session, and Site-session cookies are transmitted only over + HTTPS, marked `Secure` and `HttpOnly`, restricted from unnecessary + cross-site sending through an appropriate `SameSite` policy, and combined + with the existing CSRF protections. +- Cookie domain, path, and lifetime are scoped as narrowly as the owning flow + permits. Tenant configuration cannot broaden or weaken required protections. +- OAuth authorization-request state and PKCE verifier associations remain + short-lived and server-side and are consumed on callback success or failure; + they are not implemented as browser state/PKCE cookies. +- OAuth responses prevent caching and referrer leakage. Shared request logging + redacts `/auth/*` query strings containing codes, state, or provider errors. +- The Site handoff callback redeems its query-carried code before rendering a + page or loading third-party resources, prevents caching and referrer leakage, + and immediately redirects to a clean `returnTo` after success. Proxy, access, + APM, analytics, and error logging must redact the raw handoff and `site_state` + query values. +- Site-side pending `site_state` records must be process-independent and support + more than one concurrent login attempt for the same browser. A single + overwrite-prone Cookie value or process-local replay map is not sufficient. +- A stale or existing application session must not change the pre-authentication + privilege boundary used for identity lookup, linking, or session creation. +- OAuth and SSO do not bypass the target application's existing CORS or CSRF + enforcement. Redirect validation, CORS, and CSRF remain separate controls. +- A login transaction and handoff are rejected after expiry, use, replay, or + copying to another browser, Site, callback, Tenant, API, or database. +- The initial release does not provide a browser-session experience when the + user disables all cookies. This is distinct from third-party-cookie blocking: + the supported flow continues to use each domain's own first-party cookies. + +## Identity lifecycle + +- `constructive_user_identifiers_private.connected_accounts` is the durable + Provider-identity association. Its Provider service key plus stable external + identifier resolves the linked `owner_id`; safe additional Provider profile + attributes remain in `details`. +- A Provider authorization code is transient protocol input and is never stored + or treated as an identity. A stable Provider identifier or subject, not email, + recognizes a returning Provider account. +- If the Provider identity is already linked, sign-in authenticates the linked + local Constructive user and never creates a duplicate user or association. +- If the Provider identity is unlinked and its normalized email is not owned by + a local account, the existing `sign_up_identity` path automatically provisions + the application user, email, and connected-account association atomically. +- If the Provider identity is unlinked but its normalized email belongs to a + different local account, sign-in fails explicitly with guidance to use the + account's existing sign-in method. This flow does not automatically merge or + bind accounts and does not ask for password-confirmation linking. +- Provider email verification is retained as metadata but does not block first + login and does not authorize automatic account merging, recovery, or access. +- Identity creation and linking must be atomic: a failed flow must not leave a + partial user or association. + +Provider-owned MFA remains entirely within the Provider flow. Sites or flows +that require Constructive `strictAuth`, local MFA, or step-up authentication are +outside v1 SSO integration and fail closed rather than downgrading or bypassing +that policy. Their integration is deferred to a separate future design based on +an actual use case. + +### Constructive local accounts + +- Account/password sign-in, registration, and password recovery are permanent + unified-auth page capabilities and do not depend on third-party provider + configuration. +- Successful local authentication enters the same unified-session, Site-mode, + handoff, and Site-local-session lifecycle as third-party authentication. +- In the initial release, successful account/password registration immediately + establishes the unified session and continues the originating Site's login + transaction. +- Email verification is not required before establishing that session or + signing in later with the account/password credential. + +## Unified SSO requirements + +- The authentication entry and target application may use unrelated parent + domains. +- Cross-domain SSO must not depend on a shared parent-domain cookie. +- The unified auth entry maintains a central session for the current browser. + Each target Site maintains its own first-party local session cookie according + to the browser and session security requirements above. +- Authentication may be handed from the unified entry to a verified target + only through a short-lived, replay-resistant, server-consumed exchange. +- After a successful Provider callback, Constructive issues an HTTP `303` + redirect to the validated technical Site callback with the one-time handoff + code as a query parameter. Dashboard-mediated success paths perform the + equivalent top-level `GET` navigation using a minimal, Constructive-validated + continuation; Dashboard does not construct or alter the callback target. +- The target Site redeems the handoff through the Constructive GraphQL mutation + backed by the handoff-redemption database function. Redemption additionally + requires authoritative runtime `site_id`, `api_id`, and `principal_id` from + trusted routing/runtime authentication. The transaction Site and the exact + tuple authorized by `site_runtime_clients` must match before credential + issuance or handoff consumption. Site is never inferred from API, and + `Origin`/`Referer` are auxiliary checks rather than identity sources. This + does not add an SSO-specific secret or credential system. Successful + redemption consumes the handoff and returns a distinct Site-local credential + result. +- The Site callback owns its first-party Bearer/Cookie completion and then + redirects to the verified Site-internal, application-relative `returnTo`. +- The handoff is an opaque one-time authorization code only; it contains no + identity, session, or long-lived credential. Reusable access tokens, session + tokens, Provider tokens, PKCE verifiers, secrets, user details, raw `returnTo`, + and unified login transaction identifiers must not appear in the Site callback + URL or URL fragments. +- The target Site, API, service principal, host, tenant, and database are + revalidated before a target session is established. Multiple Sites may use + the same API; API identity alone never selects a Site. +- A handoff issued for one Site/runtime tuple, host, tenant, or database cannot + be consumed through another Site, API, or service principal. +- Each Site-local session is bound to the unified session that established it. + Every protected Site request validates that the bound unified session remains + active. Revoking the unified session causes old Site-local sessions to be + rejected even when their local cookies have not naturally expired. + +The exact persistence and database procedure used for the handoff are design +decisions, but the observable security behavior above is required. + +Route removal or reassignment during an in-progress login fails safely. Similar +hostnames, parent-domain suffixes, CORS allowlists, client-supplied database IDs, +and default-database routing do not establish SSO trust. + +### Current-browser logout + +- Unified logout revokes the current browser's unified session and the authority + of every Site-local session bound to it. +- Sessions in other browsers are outside this operation's scope. +- Sites reject bound local sessions on the next protected request after central + revocation. +- Revocation does not depend on Site logout callback URLs, browser redirects to + every Site, or per-Site notification webhooks. + +### Switch account + +Switching from account A to account B first performs current-browser unified +logout for account A. The current Site then completes the active login +transaction for account B and establishes a B Site-local session. Other Sites +remain signed out. When the user later visits another Site, that Site follows +its Tenant-configured confirmation or silent mode to establish a new B session. + +Account switching does not preserve usable A Site sessions and does not +background-sign-in every Site as B. + +## Product interaction sequences + +### Main Login Diagram 1: Start and Existing Unified Authentication + +```mermaid +sequenceDiagram + participant U as User + participant S as Site + participant B as Browser + participant D as Dashboard unified-auth UI + participant A as Common Constructive SSO service + participant DB as Constructive DB + participant P as Shared post-authentication continuation + + U->>S: Choose sign-in + S->>S: Create one-time site_state under this browser's first-party session + S-->>B: Navigate to Tenant auth origin with Site ID, optional exact callback, returnTo, and site_state + B->>D: Load unified-auth page + D->>A: Start-login mutation with Site inputs and site_state + A->>A: Resolve Tenant/API/database from canonical Host routing and Express Context + A->>DB: Resolve exact callback, validate Tenant/Site/callback/returnTo/site_state, and create transaction + A->>DB: Resolve enabled Providers and existing unified identity from Bearer or auth-domain Cookie + DB-->>A: Opaque transaction ID + safe display context + authentication decision + A-->>D: Opaque ID, safe context, and decision + alt Existing identity and silent sign-in + A->>P: Continue as the authenticated identity + else Existing identity and confirm-before-sign-in + D-->>U: Show identity with continue, switch, or cancel + U->>D: Continue + D->>A: Confirm transaction + A->>P: Continue as the authenticated identity + else No reusable unified identity + D-->>U: Show local account and enabled Provider options + end +``` + +Constructive, not Dashboard, owns transaction creation, existing-authentication +validation, and the effective silent/confirm decision. The registered technical +callback is distinct from the validated application-relative `returnTo`; both +are retained in server-side transaction context. After login start, neither the +transaction ID nor raw `returnTo` is carried in browser navigation. + +### Main Login Diagram 2: Local Username/Password Branch + +```mermaid +sequenceDiagram + participant U as User + participant D as Dashboard unified-auth UI + participant A as Common Constructive SSO service + participant DB as Constructive DB + participant P as Shared post-authentication continuation + + U->>D: Submit local credentials + D->>A: Password mutation with opaque transaction ID + credentials + A->>DB: Invoke SSO password wrapper and validate transaction boundaries + alt Invalid transaction or password failure + DB-->>A: Existing safe authentication error + A-->>D: Safe failure + Note over U,D: User may manually resubmit while the transaction remains active; no automatic retry + else Successful local authentication + DB-->>A: Identity + existing Dashboard credential outcome + A->>P: Continue as the authenticated identity + end +``` + +The SSO wrapper calls the existing Tenant-local +`constructive_auth_public.sign_in` email/password primitive once and does not +extend that primitive with SSO concerns. The separate `sign_in_identity` +primitive remains owned by Provider external-identity authentication. Any +Dashboard Bearer result and auth-domain first-party Cookie are local to the +authentication center; they are not the target Site's credential. + +### Main Login Diagram 3: External Provider Branch + +```mermaid +sequenceDiagram + participant U as User + participant D as Dashboard unified-auth UI + participant B as Browser + participant A as Common Constructive SSO service + participant DB as Constructive DB + participant PA as Protocol-neutral Provider Adapter + participant IP as External Identity Provider + participant P as Shared post-authentication continuation + + U->>D: Choose an enabled configured Provider + D->>A: Provider-start operation with opaque transaction ID + Provider key + Note over D,A: Dashboard gives the unified transaction ID only to Constructive + A->>DB: Validate transaction/Provider and create linked OAuth authorization request + DB-->>A: Random OAuth state; PKCE/nonce remain server-side + A->>PA: Build Provider-specific authorization request + A-->>B: Redirect with random OAuth state only + B->>IP: Complete Provider interaction + IP-->>B: Callback with code or error + random OAuth state + B->>A: Provider callback + A->>DB: Validate/consume OAuth state and restore Provider + original transaction + alt Invalid, expired, or replayed OAuth state + DB-->>A: Classified state failure + A-->>D: Safe failure; restart from Site login entry + else State restores configured Provider and original transaction + DB-->>A: Restored transaction and Provider context + alt Callback contains Provider cancellation/error + A-->>D: Safe Provider failure; restart from Site login entry + else Callback contains authorization code + A->>PA: Complete callback through the selected configured adapter + alt Google/OIDC adapter example + PA->>IP: Exchange authorization code server-side + IP-->>PA: Identity token + optional access token + user data + PA->>PA: Validate identity token and normalize user data + else GitHub/OAuth adapter example + PA->>IP: Exchange authorization code server-side + IP-->>PA: Access token + PA->>IP: Query user and optional email endpoints + IP-->>PA: User and optional email data + PA->>PA: Normalize GitHub user data + else Another supported adapter + PA->>PA: Run adapter-specific verification + end + PA-->>A: Normalized Provider key + stable identifier + optional email + safe profile + Note over B,IP: Browser sees code/state or error, never Provider tokens or the unified transaction ID + A->>DB: Resolve connected_accounts by Provider + stable identifier + alt Existing association + DB-->>A: Linked local user + A->>P: Continue as the linked identity + else Unlinked and email is unowned + A->>DB: Use existing sign_up_identity provisioning path + DB-->>A: New local user + email + connected account + A->>P: Continue as the provisioned identity + else Unlinked and email belongs to another local account + DB-->>A: Explicit account conflict + A-->>D: Use existing sign-in method; restart login + end + end + end +``` + +The authorization code is never an identity or durable association. Provider +adapters keep Google/OIDC, GitHub/OAuth, and other protocol details internal; +only the normalized external identity crosses into the common identity lifecycle. + +### Shared Successful Completion + +```mermaid +sequenceDiagram + participant P as Shared post-authentication continuation + participant A as Common Constructive SSO service + participant DB as Constructive DB + participant D as Dashboard unified-auth UI + participant B as Browser + participant S as Target Site + + P->>A: Authenticated identity + active transaction + A->>DB: Preserve/establish auth-center credential outcome and create one-time Site handoff + DB-->>A: Auth-center-local outcome + plaintext handoff emitted once + alt Provider callback terminates at Constructive + A-->>B: 303 to exact callback with handoff code + site_state query + else Dashboard-mediated successful branch + A-->>D: Minimal validated GET continuation + D-->>B: Navigate to exact callback with handoff code + site_state query + end + B->>S: GET exact registered callback + S->>S: Match site_state to this browser's pending login + S->>A: Redeem-handoff GraphQL mutation + A->>DB: Invoke handoff-redemption function + DB-->>A: Distinct Site-local credential + verified returnTo; handoff consumed + A-->>S: Site-local credential result + verified returnTo + S->>S: Set its first-party Cookie and/or deliver its Bearer result + S-->>B: Redirect to verified Site-internal returnTo +``` + +All successful routes in the three main diagrams enter this one continuation. +The handoff is carried only as an opaque, one-minute, one-time query value on a +top-level `GET` navigation to the fixed, registered technical callback. The Site +first matches `site_state` to the current browser, then redeems the handoff +before rendering or loading third-party resources. After successful server-side +redemption it consumes the pending Site state and immediately redirects to its +separately validated, Site-internal `returnTo` so the browser leaves the +code-bearing URL. +Authentication-center credentials and Site-local credentials remain distinct. + +### Existing unified session + +This case is the first diagram's silent or confirm branch. Silent sign-in skips +only the confirmation UI; neither branch skips Site/callback validation, +transaction creation, unified-session validation, handoff, or Site-local +credential establishment. + +### Switch from account A to account B + +```mermaid +sequenceDiagram + participant U as Browser + participant A as Common Constructive SSO service + participant P as Shared post-authentication continuation + participant SO as Other Sites + + U->>A: Select switch account + A->>A: Revoke the browser's account A unified session + U->>A: Authenticate account B + A->>P: Continue B for the current Site transaction + Note over P: Use the single shared handoff, callback, redemption, credential, and returnTo path + Note over SO: Old A local sessions are rejected; no B session is created + U->>SO: Visit later + SO->>A: Start a new Site login + Note over SO,A: Follow Main Login Diagram 1 and the shared completion for B +``` + +## Administrative management + +- An authorized administrator can create, update, enable, and disable tenant + provider configuration and rotate its client secret through the existing + owned configuration surface. +- A Tenant administrator can create, update, enable, and disable Sites; manage + each Site's stable identifier and multiple exact callback URLs; and select + confirm-before-sign-in or silent sign-in. +- New Sites default to confirm before sign-in. +- Secret values are accepted only through the secret-management boundary and + are never returned by provider configuration reads. +- Known configuration writes and secret rotations invalidate the relevant + runtime cache after a successful write. External changes have a defined, + bounded staleness period and do not require a server restart. +- Provider and auth-setting writes preserve authorization, validation, audit, + and failure semantics; a new OAuth-specific admin REST surface is not required. + +## Configuration and secret boundaries + +- Server/platform OAuth settings are owned and validated by `graphql/env`. +- Tenant provider metadata is owned by tenant configuration. +- Tenant provider secrets use the existing internal-secrets lifecycle; OAuth + does not store a second secret copy. +- Middleware consumes validated options and request-scoped configuration. It + does not read `process.env` directly. +- Secrets and raw provider errors never enter browser responses, redirect + parameters, ordinary configuration APIs, or logs. + +## Errors and observability + +- Public failures use registered, stable `UPPER_SNAKE_CASE` business error + codes rather than package, route, middleware, or internal class names. +- Security validation failures identify the rejected business object without + exposing sensitive values or database details. +- Every caught exception is deliberately mapped to a domain/protocol failure or + rethrown with its cause preserved. Logging is not a substitute for failure. +- Logs may include safe request, provider identifier, tenant, API, and error + classification context, but never credentials, tokens, cookies, codes, or + provider response bodies. + +## Protocol boundary + +The common SSO orchestration and normalized external-identity contract are +protocol-neutral. Current OAuth-based adapters use OAuth 2.0 Authorization Code +with mandatory S256 PKCE. An OIDC-capable adapter must not claim complete OpenID +Connect verification unless issuer, audience, nonce, discovery, and JWKS +validation are separately defined and implemented. Provider-specific +OIDC-shaped responses do not by themselves make CNC an OIDC provider or make +the generic SSO flow an OIDC relying party. + +## Completion criteria + +The CNC implementation is complete when: + +1. All providers registered by `packages/oauth` use the protocol-neutral adapter + boundary and shared secure SSO flow, while Tenant configuration controls + their availability. +2. Dashboard can host the unified-auth pages without making authentication + depend on its management features. +3. Tenant-managed Sites support stable identifiers, multiple exactly matched + callbacks, and confirm-before-sign-in by default or explicit silent sign-in. +4. The unified page always offers Constructive sign-in, registration, and + password recovery, and obtains third-party providers dynamically. +5. Successful local account registration immediately establishes the unified + session and continues the originating Site flow without an email-verification + prerequisite. +6. Provider login resolves durable identity through existing + `connected_accounts`; linked identities return their owner, eligible unlinked + identities use `sign_up_identity`, and an email owned by another account + fails without automatic merge or password-confirmation linking. +7. Unified SSO establishes sessions across registered applications on different + parent domains without exposing reusable credentials. +8. Tenant/Site/callback/database/host boundaries are enforced during initiation, + callback, identity resolution, and target-session establishment. +9. OAuth-disabled, missing configuration, Provider cancellation or rejection, + malformed state, replay, Provider verification failure, routing mismatch, + identity conflict, and exclusion of strict-auth/MFA/step-up flows have + explicit fail-closed behavior and automated coverage at their owning layers. +10. Every Provider-flow failure is safely presented at the authentication center + and requires a fresh login from the Site entry; it cannot resume the failed + flow or expose raw Provider or sensitive data. +11. Login transactions and OAuth authorization requests are stored server-side + and each expires ten minutes after its own creation. Dashboard receives only + the active opaque transaction identifier, while the browser/Provider + boundary receives only unrelated random OAuth state. +12. Login transactions and handoffs are single-use and bound to the correct + browser, Site, callback, Tenant, API, and database; handoffs retain their + separately confirmed one-minute lifetime. +13. The auth center and Sites use only narrowly scoped, protected first-party + session cookies and retain the existing CSRF boundary; neither third-party + cookies nor cross-parent-domain cookie sharing are required. +14. Endpoint validation, transient-cookie cleanup, cache/referrer protection, + safe provider failure, and stale-session privilege boundaries are tested. +15. Current-browser logout causes every bound Site-local session to be rejected + on protected requests, and account switching establishes the new account + only for the currently active Site transaction. +16. Tenant provider changes and secret rotation take effect through the owned + cache/invalidation lifecycle without leaking the secret. +17. Existing `connected_accounts` and identity procedures are reused; no parallel + private identity table, configuration reader, request context, or duplicated + Provider workflow is introduced by server middleware. + +## Future consideration (not current scope) + +An already authenticated local user may later be allowed to bind Google, GitHub, +or another Provider from account settings. Account-settings binding is not part +of the current unified-login flow or release scope. + +## Implementation-owned details + +The following choices do not require additional product decisions. Their owning +PRs select repository-conventional representations while preserving this +document and the formal Spec: + +- login-transaction physical storage, opaque-identifier hashing, and cleanup; +- handoff indexes, cleanup scheduling, and bounded operational retention; and +- database constraint, locking, and migration mechanics that do not alter the + confirmed security or product behavior. + +## References + +- OAuth PR [#1303](https://github.com/constructive-io/constructive/pull/1303) +- SSO PR [#1493](https://github.com/constructive-io/constructive/pull/1493) +- Latest-baseline OAuth PR [#1669](https://github.com/constructive-io/constructive/pull/1669) + +These are behavioral, security, and regression references. Their implementation +structure and compatibility workarounds are not requirements. diff --git a/docs/sso/spec/oauth-sso-platform-integration-constructive-db-design.md b/docs/sso/spec/oauth-sso-platform-integration-constructive-db-design.md new file mode 100644 index 0000000000..3214af2106 --- /dev/null +++ b/docs/sso/spec/oauth-sso-platform-integration-constructive-db-design.md @@ -0,0 +1,531 @@ +# OAuth/SSO Platform Integration — Constructive DB Detailed Design + +## Document Status + +This document records the detailed database design and implementation evidence for the Constructive DB runtime. It derives its behavioral requirements from: + +- [`docs/sso/plan/oauth-sso-platform-integration.md`](../plan/oauth-sso-platform-integration.md); and +- [`docs/sso/spec/oauth-sso-platform-integration.md`](./oauth-sso-platform-integration.md). + +It does not supersede either document. The product requirements define user-visible behavior, and the formal Spec defines the confirmed cross-component design. This document translates those decisions into database responsibilities and delegates repository-local physical choices to the implementing DB PR. No user-facing product/security questions remain in this DB design. + +The original Constructive DB evidence was inspected at `origin/main` commit `bd59a523c17e542fbbed3ef3463c720feb29c97c`. Final convergence evidence uses the combined Constructive DB runtime commit `ffc87bb07ede49a0734f676d0eb06042f0565eef`, the seven-PR Constructive stack head `87061435f59a1410b19161c2ae2365cc0891da5d`, and Dashboard implementation commit `a6611fbcab677e91c431f275bd02930b39828421`. Paths under `application/constructive/` are generated evidence only; implementation changes belong in the owning source modules or generators and must regenerate the application output. + +### Decision labels + +- **Confirmed:** required by the product requirements or formal Spec. +- **Existing:** verified in the Constructive DB baseline above. +- **DB PR decision:** a physical naming, data type, constraint, index, locking, cleanup, or migration choice that does not change confirmed cross-system behavior. The implementing PR resolves it from live repository conventions and evidence; it is not a user-facing open question. + +## Scope + +This design covers the Constructive DB work needed to support: + +- per-Site unified-authentication configuration and exact callback registration; +- short-lived server-side unified login transactions; +- Provider-specific OAuth authorization requests linked to those transactions; +- reuse of existing local-password and external-identity primitives; +- short-lived, one-time Site handoff creation and atomic redemption; +- issuance and validation of a distinct local credential for the target Site; +- Tenant and optional SSO-group isolation; +- trusted Site runtime identity and Site-to-API/service-principal authorization; +- database authorization, retention, cleanup, migration, and test boundaries; and +- the GraphQL operations whose authoritative state transition is owned by PostgreSQL. + +## Non-Goals + +- Replacing `connected_accounts`, `sign_in_identity`, `sign_up_identity`, existing session storage, or the existing identity-provider configuration model. +- Defining Dashboard UI, Provider adapter HTTP behavior, Cookie headers, browser navigation, or Site frontend storage. +- Implementing cross-parent-domain SSO through a shared Cookie or carrying long-lived credentials in URLs. +- Adding a public login-transaction status, resume, or recovery query. An interrupted v1 flow restarts at the Site login entry. +- Reusing authorization groups or permission scopes as SSO groups. +- Storing Provider authorization codes, Provider tokens, raw Provider responses, password credentials, plaintext handoff codes, or other browser secrets as durable identity data. +- Prescribing unknown physical SQL or GraphQL names before the owning Constructive DB module and deployment topology are confirmed. +- Backward-compatible fallbacks for legacy Tenant, schema, database, secret, callback, or shared-cookie behavior. +- Treating `Origin`, `Referer`, a caller-supplied Site ID, or an API-to-Site reverse lookup as authoritative Site identity. +- Integrating Sites or flows that require Constructive `strictAuth`, local MFA, or step-up authentication. Those policies are not bypassed; support requires a separate future design based on an actual use case. + +## Confirmed Database Invariants + +1. Tenant is the hard isolation boundary for every Site, unified login transaction, reusable authentication state, Provider OAuth authorization request, handoff, and bound session. Every read, write, consume, redeem, and session-reuse transition must derive Tenant scope from authoritative Site/configuration/transaction context and validate it inside the owning DB function. Cross-Tenant state, handoff, or session reuse is forbidden regardless of physical database topology. When SSO is enabled for a Tenant, the SSO module provisions that Tenant's own prefixed private schema; `sso_private` is the module/template logical name, not one global schema shared by all Tenants. No SSO schema is assumed for a Tenant where the module is not enabled and provisioned. +2. A Site has exactly one logical authentication configuration: `enabled`, `sign_in_mode`, and nullable normalized `sso_group_key`. +3. `sign_in_mode` is `confirm` by default; `silent` is the only other v1 mode. +4. A null `sso_group_key` means the Tenant default SSO group. A non-null key contains only lowercase letters, digits, and hyphens. +5. A Site may register multiple callbacks. Only an active, full, exact callback match is accepted. There are no wildcards, suffix matches, or arbitrary subdomains. +6. If the Site omits a callback at login start, the effective callback is the earliest active registration ordered by `created_at ASC`, then internal ID `ASC`. +7. The technical callback and Site-internal application-relative `returnTo` are distinct values. Both are validated before being bound to server-side transaction context. +8. The unified login transaction is active-flow-only, server-side orchestration state and expires ten minutes after creation. The browser receives only an opaque identifier for current Dashboard-to-Constructive operations. +9. Provider OAuth authorization-request state is separate from the unified login transaction, has a server-side link back to it, and expires ten minutes after creation. The browser and Provider receive only opaque OAuth state, never the unified transaction identifier or PKCE verifier. +10. An SSO handoff is a dedicated minimal logical entity containing only an internal ID, a secure code hash, a login-transaction reference, and creation, expiry, and consumption timestamps. +11. A handoff expires one minute after creation. Plaintext is emitted once, never persisted, and the handoff is marked consumed only as part of successful redemption and Site-local credential issuance. +12. A transient redemption failure before consumption may retry the same handoff during its one-minute lifetime. Replay after successful consumption fails. Redemption additionally requires authoritative Site/runtime context and an exact `site_runtime_clients` tuple; it never introduces an SSO-specific parallel secret or credential system. +13. The browser carries the plaintext handoff as a query parameter on a top-level `GET` navigation to the exact registered Site callback. The handoff contains no identity, session, or long-lived credential; raw `returnTo` and unified transaction state remain server-side. +14. External identity association remains owned by `connected_accounts`, keyed by Provider `service` plus stable external `identifier`, never email or an authorization code. +15. `sign_in_identity` and `sign_up_identity` remain unchanged general Tenant **external-identity** authentication primitives. SSO-specific validation and orchestration belong in wrappers or coordinating functions. +16. Every successful authentication method converges on the same handoff and Site-local credential path. +17. Caught database errors are either mapped to a registered stable domain error or rethrown with their cause preserved. Logging or fallback behavior does not replace failure semantics. +18. Site/browser correlation uses a Site-generated, cryptographically random, short-lived, one-time `site_state`. The Site keeps its expected value in a process-independent first-party session boundary; the unified transaction binds the public value and returns it beside the handoff. It does not replace handoff validation, Tenant isolation, or target Site/runtime authentication. +19. `site_id` is a first-class trusted runtime security fact. Site-originated redemption, Site credential issuance, and later Site-session authentication must receive authoritative `(site_id, api_id, principal_id)` context from the routing/runtime-authentication boundary and validate the complete tuple in PostgreSQL. `site_id` is never inferred from `api_id`; multiple Sites may intentionally share one API. +20. The logical `site_runtime_clients` relation defines which exact API/service-principal pairs may act for each Site. Possession of a handoff, an otherwise valid API key, or a matching API alone is insufficient. `Origin` and `Referer` may support defense-in-depth checks but never establish Site identity or replace the tuple authorization. + +## Existing Owners and Reuse + +| Capability | Verified current owner | Reuse decision | Evidence | +| --- | --- | --- | --- | +| Site identity and Tenant/database association | **Existing:** `catalog_private.sites`, including `id`, `database_id`, `created_at`, and `updated_at` | Extend around the existing Site; do not create a parallel Site registry | `platform-schema/catalog/deploy/schemas/catalog_private/tables/sites/` | +| API, Site, and service-principal runtime facts | **Implemented in the final Constructive stack:** canonical routing resolves `runtime_site_id`, Express Context carries it as `siteId`, and pgSettings forwards `site_id` beside `api_id` and `principal_id` | Preserve the complete trusted tuple; never infer Site from API or accept it from GraphQL input | `graphql/server/src/middleware/routing.ts`, `packages/express-context/src/context.ts`, `packages/express-context/src/pg-settings.ts`, and `graphql/server/src/auth/sso/service.ts` | +| Site administration authorization | **Existing:** RLS policies on `catalog_private.sites` based on Constructive membership permissions | Reuse the current permission model for Site-auth configuration administration; the DB PR selects the exact predicates from the live permission registry | `platform-schema/catalog-security/deploy/schemas/catalog_private/tables/sites/policies/` | +| Tenant Provider configuration | **Existing:** generated `identity_providers_module`; the current Constructive relation includes enabled status, endpoints, scopes, client identifier, secret reference, PKCE setting, OIDC metadata, and policy flags | Reuse; no SSO-only Provider registry or secret store | `packages/metaschema-generators/.../identity_providers_module.sql` and generated `constructive_auth_private.identity_providers` | +| Provider secret storage | **Existing:** identity-provider rows refer to the existing internal-secrets capability | Reuse its ownership, resolution, rotation, and access boundary | `identity_providers_module` plus `internal_secrets_module` | +| External identity association | **Existing:** `constructive_user_identifiers_private.connected_accounts` with unique `(service, identifier)`, `owner_id`, `details`, and verification state | Reuse unchanged as the durable Provider-to-user mapping | generated relation and `connected_accounts_module` | +| Tenant-local external-identity sign-in | **Existing:** generated `sign_in_identity` accepts Provider `service` + stable `identifier`, validates the enabled Provider, finds `connected_accounts`, applies account and rate-limit policy, and creates a session credential | Call unchanged through external-identity SSO orchestration | `packages/ast-actions/.../sign_in_identity.sql` and generated procedure | +| Tenant-local external-identity sign-up | **Existing:** generated `sign_up_identity` accepts Provider identity data and provisions the user, email, connected account, and session credential | Call unchanged for an unlinked Provider identity whose email is unowned | `packages/ast-actions/.../sign_up_identity.sql` and generated procedure | +| Local password sign-in and registration | **Existing:** generated `constructive_auth_public.sign_in(email, password, ...)` and `sign_up(...)`; password recovery/reset functions also exist | Reuse unchanged behind transaction-aware SSO wrappers where continuation is required | generated `constructive_auth_public` procedures | +| Sessions and credentials | **Existing:** `sessions_module`, including `sessions` and `session_credentials` | Reuse credential issuance and validation primitives; add the required central-to-Site revocation binding in the correct session/SSO owner | `packages/metaschema-generators/.../sessions_module.sql` and generated tables | +| Provider OAuth authorization request | **Existing:** `integrations/sso/sql` owns the logical-template `sso_private.oauth_authorization_requests` with opaque state, server-held verifier/nonce, expiry, consumption, and purge functions | **Confirmed:** evolve/reuse each enabled Tenant's provisioned instance and add its unified-login-transaction association. Legacy `lane` is not used; `returnTo` remains only on the unified transaction | `integrations/sso/sql/deploy/schemas/sso_private/` | +| Pending identity-link ticket | **Existing:** `sso_private.pending_identity_links` supports a post-verification account-linking flow | Do not use for unified pre-login transactions or v1 email-conflict handling | `integrations/sso/sql/deploy/schemas/sso_private/tables/pending_identity_links/` | +| Database test infrastructure | **Existing:** the repository depends on `pgsql-test`; the SSO integration also has a Jest/ts-jest flow against a provisioned database and emulated Provider | Reuse the owning DB test infrastructure; mock only the external Provider boundary in higher-layer flow tests | root `package.json` and `integrations/sso/__tests__/` | + +References to `sso_private` in the existing integration evidence identify the SSO module's logical/template schema. Runtime functions must resolve the current enabled Tenant's provisioned, Tenant-prefixed private SSO schema through the authoritative module/context path. They must not hardcode a global `sso_private` schema, query another Tenant's provisioned schema, or fall back when the current Tenant has no provisioned SSO module. + +### Verified authentication primitive mapping + +The formal Spec and verified Constructive DB contracts use the same primitive mapping. `sign_in_identity` does not accept a username/password: it accepts external identity `service`, `identifier`, optional Provider `details`/`email`, and credential options. The local password primitive is `constructive_auth_public.sign_in(email, password, ...)`. + +The confirmed mapping is: + +- local password wrapper → unchanged Tenant-local `sign_in`; +- local registration wrapper → unchanged Tenant-local `sign_up`; +- returning external Provider identity → unchanged `sign_in_identity`; and +- first-time external Provider identity → unchanged `sign_up_identity`. + +Adding password parameters or SSO transaction concerns to `sign_in_identity` is not an acceptable compatibility workaround. + +## Logical-to-Physical Inventory + +| Logical capability | Confirmed logical owner | Current physical object | DB PR action | Physical decision status | +| --- | --- | --- | --- | --- | +| Site authentication configuration | Existing Site (**Confirmed**) | None verified | Add the confirmed one-to-one Site-owned model | Physical placement, naming, and migration mechanics are DB PR decisions | +| Exact Site callbacks | Existing Site (**Confirmed**) | None verified for unified-auth callbacks | Add the confirmed one-to-many Site-owned model | Physical placement, naming, and migration mechanics are DB PR decisions | +| Site runtime client authorization | Existing Site/runtime-auth boundary (**Confirmed**) | No exact Site/API/principal relation verified | Add the logical `site_runtime_clients` relation and validate exact `(site_id, api_id, principal_id)` tuples | Logical authorization is confirmed; physical placement and identifiers are DB PR decisions | +| Unified login transaction | Constructive SSO | None | Add a dedicated short-lived model inside each enabled Tenant's provisioned private SSO schema | Per-Tenant ownership is confirmed; representation is a DB PR decision | +| Provider OAuth authorization request | Constructive SSO Provider subflow | Logical template relation `sso_private.oauth_authorization_requests` exists | **Confirmed:** evolve/reuse the Tenant-local table and link each Provider request to its Tenant-local unified transaction | Legacy column/data migration timing is a DB PR decision | +| External identity association | Connected-accounts module | `constructive_user_identifiers_private.connected_accounts` | Reuse unchanged | **Confirmed reuse** | +| Local password authentication/registration | User-auth module | `sign_in`, `sign_up` | Reuse unchanged behind transaction-aware SSO coordination | **Confirmed reuse** | +| External identity authentication/provisioning | User-auth and connected-accounts modules | `sign_in_identity`, `sign_up_identity` | Reuse unchanged behind SSO-specific coordination | **Confirmed reuse** | +| SSO handoff | Constructive SSO | None | Add a dedicated minimal one-time artifact inside each enabled Tenant's provisioned private SSO schema | Logical shape and per-Tenant ownership are confirmed; representation is a DB PR decision | +| Authentication-center session | Sessions module | `sessions`, `session_credentials` | Reuse current credential outcome | Classification/tagging is a DB PR decision | +| Site-local session bound to unified session | Sessions/SSO boundary | No verified parent/unified-session link exists in current `sessions` columns | Add an explicit binding in the correct sessions/SSO owner so each protected request can reject a revoked unified session | Physical model and topology integration are DB PR decisions | +| Expired-artifact cleanup | Constructive SSO | Legacy OAuth purge function exists | Reuse the expiry/purge pattern for all new transient entities | Scheduler, retention grace, and function names are DB PR decisions | + +## Persistence Models and DB PR Choices + +The following sections specify confirmed logical columns and constraints. Unless an existing physical object is named explicitly, the implementing DB PR selects SQL identifiers and data types from current repository conventions; those choices are not product decisions. + +### Site Authentication Configuration + +**Logical owner and cardinality:** **Confirmed.** This is a Site-owned one-to-one model anchored to the existing Site. It is not a parallel Site registry and cannot be owned independently by a Tenant auth row. + +**Physical implementation:** the DB PR selects the source module, schema, table, and column names from live repository conventions. That implementation choice does not reopen logical ownership or cardinality. + +| Logical column | Requirement | Status | +| --- | --- | --- | +| Site reference | References exactly one existing Site | **Confirmed**; FK target/type is a DB PR decision | +| `enabled` | Controls whether the Site may start unified authentication | **Confirmed** | +| `sign_in_mode` | `confirm` by default; `silent` is the alternative | **Confirmed** | +| `sso_group_key` | Nullable lowercase key; null means Tenant default group | **Confirmed** | + +Required constraints and behavior: + +- One configuration per Site, enforced by the physical Site relationship (**Confirmed cardinality**). +- `sign_in_mode` accepts only `confirm` and `silent` (**Confirmed**). +- A non-null `sso_group_key` matches `^[a-z0-9-]+$` and is stored in normalized lowercase form (**Confirmed**). +- Tenant is derived from the owning Site and cannot be supplied independently to create a cross-Tenant association (**Confirmed**). +- Backfill behavior for pre-existing Sites and cascade/cleanup mechanics are implementation-time migration decisions. They do not change the confirmed one-to-one Site ownership. + +Lifecycle: created and updated only through permission-controlled Tenant administration. Disabling prevents new starts; the Site configuration is revalidated before handoff creation or redemption so a flow cannot complete under stale trust. + +### Site Callback Address + +**Logical owner and cardinality:** **Confirmed.** Callback registrations are Site-owned one-to-many children of the existing Site. + +**Physical implementation:** the DB PR selects the source module, schema, table, and column names from live repository conventions. That implementation choice does not reopen logical ownership or cardinality. + +| Logical column | Requirement | Status | +| --- | --- | --- | +| Internal ID | Stable deterministic tie-breaker for default callback selection | **Confirmed behavior**; type/name is a DB PR decision | +| Site reference | Owning Site | **Confirmed** | +| Exact callback URL | Full registered technical callback | **Confirmed** | +| Active/allowed status | Only active callbacks may start or complete a flow | **Confirmed** | +| `created_at` | Primary ordering key for default callback selection | **Confirmed behavior** | + +Required constraints and behavior: + +- Explicit callback input matches the stored full URL exactly; no wildcard, suffix, parent-domain, similar-host, or arbitrary-subdomain matching (**Confirmed**). +- Omitted callback selects one active row ordered by `created_at ASC`, then internal ID `ASC` (**Confirmed**). +- A Site with no active callback cannot start unified login (**Confirmed consequence**). +- Each registration stores one full callback URL and its own active/allowed state; exact comparison uses the validated stored value (**Confirmed**). +- Tenant is derived through the Site reference and must be checked on every lookup (**Confirmed**). + +Lifecycle: administrators add, activate, deactivate, or remove callbacks. A removed or disabled callback invalidates an in-flight flow at the required final revalidation boundary. The DB must not infer or backfill callbacks from CORS configuration, current Host headers, routing suffixes, or legacy redirect values. + +### Site Runtime Client Authorization + +**Logical owner:** **Confirmed.** `site_runtime_clients` is the Site/runtime-authentication authorization relation. It is distinct from Site callbacks, CORS/origin configuration, Tenant membership, API routing, and SSO groups. + +Each row authorizes one exact tuple: + +| Logical column | Requirement | Status | +| --- | --- | --- | +| Site reference | The Site whose runtime is being authenticated | **Confirmed** | +| API reference | An API this Site may use; the same API may be referenced by multiple Sites | **Confirmed** | +| Service-principal reference | The authenticated runtime principal allowed to act for that Site through that API | **Confirmed** | + +The exact `(site_id, api_id, principal_id)` tuple is unique. There is no wildcard Site, API, or principal authorization in v1. The DB PR selects the owning source module, physical schema/table name, key types, foreign-key targets, and administration surface from live repository conventions; those choices do not reopen the tuple security model. + +Lifecycle: an authorized administrator creates or removes mappings. Removing a mapping must make later handoff redemption, Site credential issuance, and protected Site authentication fail closed for that tuple. Existing Sites receive no inferred mapping from their API, domain, callback, `Origin`, or `Referer`; rollout provisions explicit mappings before enabling unified authentication. + +### Unified Login Transaction + +**Schema ownership:** **Confirmed.** This is a dedicated model in the current enabled Tenant's provisioned, Tenant-prefixed private SSO schema. The DB PR selects its physical table/column names. It must not overload the Provider-specific OAuth request or pending identity-link ticket. + +| Logical column or binding | Requirement | Status | +| --- | --- | --- | +| Internal ID | Private row identity | **Confirmed logical need**; type/name is a DB PR decision | +| Opaque active-flow identifier | Value Dashboard returns only to Constructive during the active flow | **Confirmed**; at-rest representation is a DB PR security choice | +| Tenant reference | Hard isolation boundary | **Confirmed**; physical source/FK is a DB PR decision | +| Site reference | Validated initiating/target Site | **Confirmed** | +| Callback binding | Validated exact callback, by reference and/or immutable snapshot | **Confirmed binding**; representation is a DB PR decision | +| `returnTo` binding | Validated Site-internal application-relative target | **Confirmed**; representation is a DB PR decision | +| Authentication-center browser binding | Binds Dashboard operations to the authentication center's current first-party session/request context | **Confirmed behavior**; storage details are a DB PR decision | +| Site `site_state` binding | Returns the Site-created one-time public correlation value at the exact callback so the Site can match the initiating browser before redemption | **Confirmed behavior**; representation is a DB PR decision | +| Effective SSO group | Tenant default group or normalized Site group used for reuse decisions | **Confirmed behavior**; derive/store strategy is a DB PR decision | +| Sign-in decision context | Site mode and authenticated-state decision used by the active flow | **Confirmed behavior**; snapshot strategy is a DB PR decision and cannot replace final live revalidation | +| Authenticated identity/session outcome | Server-side association after reused auth, password, or Provider success | **Confirmed behavior**; physical reference is a DB PR decision | +| Creation and expiry | Expires ten minutes after creation | **Confirmed** | +| Completion/consumption state | Prevents reuse and represents terminal success/failure | **Confirmed lifecycle**; timestamp/state representation is a DB PR decision | + +Required constraints and behavior: + +- The opaque identifier is unique, unguessable, and never treated as an identity or long-lived credential (**Confirmed**). +- Site, callback, Tenant, group, authentication-center browser binding, Site `site_state`, and `returnTo` cannot be replaced by later browser input (**Confirmed**). +- The Site, callback, and applicable SSO boundary are revalidated before issuing a handoff (**Confirmed**). +- Only one authenticated identity outcome may be associated with a transaction (**Confirmed consequence of the single shared post-authentication continuation**). +- No public table grant or public GraphQL row query exposes transaction state (**Confirmed**). +- Private callback lists, group data, raw `returnTo`, browser-binding material, and identity outcome are not returned by the start operation. It returns only the opaque identifier and safe display/decision context (**Confirmed**). + +Lifecycle: start creates an active row; local password, existing unified auth, or a Provider callback may associate one authenticated outcome; shared completion creates a handoff and terminates the active orchestration path. Expired, interrupted, invalid, or failed Provider flows are not resumed. The DB PR selects the terminal-state representation and bounded audit retention without changing those semantics. + +### Provider OAuth Authorization Request + +**Confirmed physical evolution:** evolve/reuse the logical-template relation `sso_private.oauth_authorization_requests` in each enabled Tenant's provisioned, Tenant-prefixed private SSO schema. It already stores `provider_slug`, opaque `state`, server-side `code_verifier`, optional `nonce`, `redirect_uri`, timestamps, and a consumed marker; the new design adds the association to that Tenant's unified login transaction. It is not a shared global schema. + +The legacy `lane` column has no replacement and is not used by the new flow. The legacy `return_to` column is also not used: validated Site-internal `returnTo` is owned and stored only by the unified login transaction. The deploy/drop/compatibility timing for legacy columns and in-flight legacy rows remains an implementation-time migration decision. + +The new logical record requires: + +| Logical column or binding | Requirement | Status | +| --- | --- | --- | +| Internal ID | Private request identity | **Existing** in the candidate table | +| Unified login transaction reference | Restores the original transaction server-side | **Confirmed new requirement** | +| Configured Provider reference/key | Binds start and callback to the same enabled Tenant Provider | **Confirmed**; key versus FK is a DB PR decision | +| Opaque random OAuth state | Browser/Provider correlation only | **Confirmed**; at-rest representation is a DB PR security choice | +| PKCE verifier | High-entropy server-side verifier; never browser/URL/log | **Confirmed** | +| OIDC nonce | Server-side when required by the selected Provider adapter | **Confirmed conditional behavior** | +| Provider redirect URI | Exact URI used at authorization and code exchange | **Confirmed behavior** | +| Creation, expiry, consumption | Expires ten minutes after creation and is consumed once | **Confirmed** | + +Required constraints and behavior: + +- OAuth state is unique, cryptographically random, expires, and is consumed atomically before Provider error or code handling (**Confirmed**). +- The Provider, OAuth request, and unified transaction must all belong to the same Tenant/database boundary (**Confirmed**). +- `returnTo`, Site callback, and SSO group remain owned by the unified transaction, not duplicated into the Provider request (**Confirmed ownership**). In particular, the evolved OAuth request table does not write or read its legacy `return_to` column for the new flow. +- Provider tokens and authorization codes are not persisted in this relation (**Confirmed**). +- The current legacy `lane` distinction is not part of the new platform design, has no replacement, and must not leak into the evolved flow (**Confirmed architectural exclusion**). +- Reuse/evolution of the existing per-Tenant table is **Confirmed**. The PR must still determine legacy column cleanup and compatibility timing after verifying whether the old integration is installed anywhere with in-flight rows. + +Lifecycle: Provider start persists state, verifier, nonce, redirect, Provider, and unified-transaction association before redirect. Callback locks and consumes the matching unexpired request exactly once, restores server-held context, then performs Provider exchange outside the database. A failed Provider exchange does not reactivate or resume the consumed request; the user restarts from Site login. + +### SSO Handoff + +**Schema ownership:** **Confirmed.** The dedicated handoff belongs in the current enabled Tenant's provisioned, Tenant-prefixed private SSO schema. The DB PR selects physical table/column names. Do not store a handoff in Provider OAuth state, `session_credentials`, or a browser session. + +| Logical column | Requirement | Status | +| --- | --- | --- | +| Internal ID | Private row identity | **Confirmed**; type/name is a DB PR decision | +| Secure code hash | Unique lookup material; plaintext is never stored | **Confirmed**; approved hash algorithm/encoding is a DB PR security choice | +| Unified login transaction reference | The sole reference to Tenant, group, Site, callback, identity, and `returnTo` context | **Confirmed** | +| `created_at` | Creation time | **Confirmed** | +| `expires_at` | Exactly one minute after creation | **Confirmed** | +| `consumed_at` | Null until successful redemption and Site credential issuance | **Confirmed** | + +Required constraints and behavior: + +- The handoff does not duplicate Tenant, group, target Site, callback, identity, or `returnTo` columns (**Confirmed**). +- Code hash is unique and indexed; plaintext is returned once to the common service and never logged or persisted (**Confirmed behavior**). +- A completed transaction has at most one active handoff. A transient redemption failure may retry that same unconsumed code within its one-minute lifetime; v1 does not mint a replacement handoff for the same transaction (**Confirmed consequence of the one-time shared completion flow**). +- Redemption verifies the unexpired, unconsumed handoff and its referenced transaction, then revalidates the Tenant, target Site, callback, and SSO boundary (**Confirmed**). +- The row is marked consumed only after Site-local credential issuance succeeds in the same database transaction (**Confirmed atomicity**). +- Locking must make concurrent redemption attempts deterministic: exactly one can succeed; all later attempts receive a stable already-used failure (**Confirmed outcome; locking implementation is a DB PR decision**). + +Lifecycle: create after a successful authentication path and final target revalidation; carry the plaintext code as a query parameter on a top-level browser `GET` to the exact callback; redeem server-to-server; consume on successful issuance; purge after expiry plus a bounded operational grace period selected by the DB PR. Cleanup retention never extends redeemability beyond one minute. + +### Session and Unified-Session Binding + +The current generated `sessions` table has session identity, expiry, revocation, origin, authentication-method, and security timestamps, but no verified parent/unified-session or Site reference. Product requirements state that a Site-local session is bound to the current-browser unified session and must become unusable after that unified session is revoked. + +This capability is **Confirmed**. The DB PR must implement it in the correct sessions/SSO owner, either by extending the current session model or through a dedicated private relation, following current repository ownership and deployment topology. That physical choice is not user-facing and must preserve: + +- distinct authentication-center and Site credentials; +- no cross-parent-domain Cookie sharing; +- same-Tenant and effective-group validation; +- revocation checks on every protected Site request; +- no cross-Tenant session link; +- indexable lookup without exposing central session identifiers to the browser; and +- an atomic Site-credential issue path during handoff redemption. + +The DB PR cannot be considered complete until it implements and verifies this binding for the deployment topology in scope. + +## PostgreSQL Function Design + +All names below are logical operation names. The DB PR selects exact Tenant-prefix naming, SQL function names, argument and return types, volatility, execution roles, and generated GraphQL exposure from current repository conventions. The schema owner is fixed: SSO functions operate on the current enabled Tenant's provisioned private SSO schema. They must resolve that schema through the authoritative Tenant/module context, never hardcode a global `sso_private`, never read or write another Tenant's schema, and fail explicitly when SSO is not provisioned for the current Tenant. Implementations must use the canonical `errors.raise_error` system, repository security conventions, explicit Tenant scoping, and transaction-safe locking. They must not catch and discard failures. + +| Logical DB operation | Purpose and logical input | Logical output | Atomicity and failure boundary | +| --- | --- | --- | --- | +| Start unified login | Tenant/request context, untrusted Site ID, optional callback, validated-candidate `returnTo`, authentication-center browser binding, and Site-created `site_state` | New opaque transaction identifier plus safe Site/config context; Provider display options are merged by Constructive from the existing loader | In one transaction: resolve Site/Tenant; require enabled config; exact-match or deterministically select callback; validate `returnTo` and `site_state`; persist all bindings. No row is created on any validation failure. | +| Confirm existing unified identity | Active transaction ID plus current authentication-center session/identity context | Authenticated transaction ready for shared completion | Lock transaction; validate active/expiry/browser/Tenant/group/Site mode; associate the existing identity once. Reject mismatched or already-completed rows. | +| SSO local-password wrapper | Active transaction ID plus local credentials and existing credential options | The unchanged local `sign_in` credential result plus minimal continuation state | Lock/validate transaction and SSO boundaries; invoke the existing local-password primitive exactly once; on success associate its identity/session outcome. Password failure preserves the existing safe error and leaves an unexpired active transaction available for manual resubmission; there is no automatic retry. | +| SSO local-registration wrapper | Active transaction ID plus the existing local-registration input | The unchanged local `sign_up` credential result plus minimal continuation state | Validate transaction and boundaries, call existing registration once, associate the new authenticated identity/session, and enter common completion atomically where the existing procedure contract permits. The DB PR selects the narrow wrapper composition without extending `sign_up` with SSO concerns. | +| Create Provider OAuth request | Active transaction ID, selected enabled Provider, Constructive-server-generated random state, PKCE verifier, optional nonce, and exact Provider redirect URI | Private OAuth request ID; opaque OAuth state remains with the service for browser navigation | Validate transaction/Provider/Tenant before insert, persist the unified-transaction link before redirect, and enforce the confirmed ten-minute expiry. Constructive generates the cryptographic values through the owning OAuth package; only state, challenge, and protocol-required public inputs cross the browser boundary. | +| Read Provider OAuth request for authorization | OAuth state and current authorization-route context | Provider key, verifier/challenge inputs, optional nonce, redirect URI, and linked transaction facts needed by Constructive | Resolve only inside the current Tenant schema; require exact current route/Tenant, active transaction, matching enabled Provider, unexpired request, and unused state. This read does not consume state. A repeated authorize navigation may reread the same active row, but only callback consumption can win once. | +| Consume Provider OAuth request | Callback OAuth state and current callback route context | Provider key/config reference, verifier, nonce, redirect URI, and unified transaction reference | Lock by state; follow the linked transaction; require the same authoritative Tenant/route, exact match, unexpired, and unused; stamp consumption before any Provider error/code handling. Invalid, expired, replayed, cross-Tenant, or mismatched state raises a stable safe error. | +| Apply normalized external identity | Active transaction plus normalized `service`, stable `identifier`, optional email, and safe details | Existing or newly provisioned local identity and authentication-center credential outcome | In one transaction: resolve `(service, identifier)`; existing association uses unchanged `sign_in_identity`; unlinked/unowned email uses unchanged `sign_up_identity`; email owned by another account raises explicit conflict. Unique email and connected-account constraints must arbitrate concurrency. The DB PR selects the narrow wrapper composition without changing the general identity primitives. | +| Create SSO handoff | Authenticated transaction plus a Constructive-server-generated secure code hash | Handoff creation result and expiry; the service emits its plaintext exactly once | Lock transaction; revalidate its authoritative Tenant/Site/callback/group; insert only the secure hash and confirmed minimal fields; enforce one-minute expiry. Plaintext generation and hashing use Constructive's owning service and never enter durable storage. | +| Authorize Site runtime | Authoritative `site_id`, `api_id`, and `principal_id` from request pgSettings/runtime context | Authorized Site/runtime tuple or one safe authorization failure | Require an exact `site_runtime_clients` match inside the current Tenant/database boundary. Never derive Site from API, caller input, `Origin`, or `Referer`. This check is a reusable internal boundary for redemption, issuance, and later Site authentication, not a public discovery API. | +| Redeem SSO handoff | Constructive-server-computed code hash plus authoritative `site_id`, `api_id`, and `principal_id` request context | Distinct Site-local credential result and verified Site-internal `returnTo` | Lock the candidate row by hash; follow its transaction; require the transaction Site to equal runtime `site_id`; require the exact runtime tuple through `site_runtime_clients`; validate Tenant, callback, group, unified session, expiry, and unused state; issue the Site-local credential; stamp `consumed_at` only after issuance succeeds. The whole success path commits or rolls back together. | +| Validate Site-local session | Current Site credential plus authoritative `site_id`, `api_id`, and `principal_id` request context | Principal/session facts or a classified invalid/revoked result | On every protected request, require an exact authorized runtime tuple and require the Site, Site-local session, and bound unified session to share the same Tenant, then validate local expiry/revocation. A valid credential presented through another Site, API, or principal fails closed. | +| Purge transient SSO state | Operational retention cutoff(s) | Deleted-row counts per entity | Delete only artifacts no longer redeemable, respecting operational/audit grace. Cleanup failure is observable and retryable; it never extends authentication validity. | + +### Existing Function Contracts That Must Remain Unchanged + +- `sign_in(email, password, remember_me, credential_kind, csrf_token, ...)` remains the Tenant-local password sign-in primitive. The generated implementation already verifies password and policy, applies rate limiting/audit behavior, and issues a session credential. +- `sign_up(...)` remains the Tenant-local password registration primitive. Its current contract must be inspected and reused unchanged when the registration continuation wrapper is finalized. +- `sign_in_identity(service, identifier, details, email, credential_kind, remember_me, ...)` remains the Tenant-local external-identity sign-in primitive. The generated implementation already resolves `connected_accounts`, checks Provider enablement and account policy, applies rate limiting/audit behavior, and issues a session credential. +- `sign_up_identity(service, identifier, email, details, credential_kind, remember_me, ...)` remains the Tenant-local external-identity provisioning primitive. The generated implementation already provisions the user/email, inserts the connected-account association, and issues a session credential. +- The new SSO wrappers must discover the Tenant-specific physical procedure locations through the existing Constructive request-context/auth-surface mechanism. They must not hardcode generated schema names or add `loginTransactionId`/Site arguments to the general procedures. +- Existing safe error semantics and reason chains are preserved. The wrappers add only stable SSO-domain errors needed for transaction, callback, group, handoff, and account-conflict boundaries. + +## GraphQL and HTTP Interface Mapping + +Names in this section describe logical operations. The DB PR selects exact GraphQL field names, input/payload types, and generated annotations according to current repository conventions. + +| Interface | Caller | Authentication/authorization | DB responsibility | +| --- | --- | --- | --- | +| Site auth public discovery query | Dashboard before or around start | Public but Tenant/Site scoped; returns only enabled public branding/mode/provider-display facts | May reuse the same authoritative Site/callback validation logic, but is advisory and cannot replace start validation. Never returns secrets, callback lists, group internals, or redeemable data. | +| Start unified login mutation | Dashboard, before local authentication | Anonymous/public entry with current Tenant route context; Site/callback are untrusted inputs | Calls the start function and returns opaque transaction ID plus safe context. Provider display options come from enabled Tenant Provider configuration. | +| Confirm current account mutation | Authenticated Dashboard user | Valid auth-center Bearer/Cookie plus active transaction/browser binding | Calls the confirm-existing-identity function, then shared handoff continuation. | +| Unified local-password mutation | Dashboard | Anonymous active transaction plus credentials; existing rate limits apply | Calls the SSO password wrapper; returns the normal Dashboard-local credential outcome and only minimal SSO continuation information. | +| Unified local-registration mutation | Dashboard | Anonymous active transaction plus existing registration inputs; existing registration policy/rate limits apply | Calls the SSO registration wrapper; successful registration immediately establishes auth-center state and enters shared completion. | +| Password recovery/reset operations | Dashboard | Existing public recovery proof and policy | Reuse existing operations. Completing recovery does not resume the old unified transaction; v1 restarts at the Site login entry, consistent with the active-flow-only rule. | +| Provider discovery query | Dashboard | Tenant-scoped public read | Returns enabled safe Provider display options only; legacy `/auth/providers` is not restored. | +| Start Provider authentication mutation | Dashboard | Active unified transaction, selected Provider, and current Tenant request context | Creates the linked OAuth request with Constructive-generated state/verifier/nonce, then returns only the opaque state or same-origin authorization-initiation continuation needed by Constructive. | +| Provider authorization initiation | Browser HTTP operation coordinated by Constructive | Opaque OAuth state plus current Tenant route/context; no unified transaction ID | Reads and revalidates the already persisted linked OAuth request without consuming it, then redirects to the configured Provider. This remains HTTP because redirect semantics are not replaceable by GraphQL. | +| Provider callback | Browser HTTP callback to Constructive | Opaque OAuth state plus Provider code/error; no unified transaction ID | Atomically consumes OAuth state, restores transaction/Provider, and passes the normalized identity to DB identity orchestration. | +| Redeem Site handoff mutation | Target Site server | Handoff proof plus authoritative runtime `site_id`, `api_id`, and `principal_id`; the complete tuple must be registered for the Site | Calls the atomic redemption function and returns only the distinct Site-local credential result plus verified `returnTo`. Site identity is supplied by trusted routing/runtime authentication, never by mutation input or API reverse lookup. | +| Site auth configuration/callback administration | Authorized Tenant administrator | Existing membership/permission model; the DB PR selects exact permission bits/scopes from the live registry | CRUD through the correct owner with RLS and audit behavior. Full callback lists are administrative, not public discovery data. | +| Login transaction status/recovery query | No caller | Not exposed | **Explicitly absent in v1.** | + +The Site callback itself remains an exact registered browser `GET` destination. Its query carries the opaque one-time handoff code and transaction-bound public `site_state`, but no identity, session credential, long-lived token, raw `returnTo`, or unified transaction state. Before redemption, the Site matches `site_state` against the same browser's process-independent pending-login state; the Site consumes that local state after successful redemption. Constructive cannot set a Cookie for the Site's parent domain; after server-side redemption, the Site response owns its first-party Cookie and frontend Bearer-storage behavior and immediately redirects to the verified clean `returnTo`. + +## Operation-to-Storage Mapping + +| Flow step | Read | Write/function | Notes | +| --- | --- | --- | --- | +| Site starts login | Existing Site, Site auth config, active callbacks | Start-login function inserts unified transaction | Explicit callback exact-matches; absent callback uses deterministic order. | +| Start response composition | Site public config, effective mode/group, enabled identity providers, current auth-center session | No additional public transaction read | Constructive returns safe context only. | +| Silent reuse | Transaction, current unified session, effective group | Confirm/reuse function associates identity outcome | Cross-Tenant or cross-group reuse fails. | +| Confirm-before-sign-in | Same as silent plus explicit user action | Confirm/reuse function | Same authenticated outcome as silent after confirmation. | +| Local password | Transaction | SSO password wrapper calls unchanged local `sign_in` | One call per user submission; no automatic retry. | +| Local registration | Transaction | SSO registration wrapper calls unchanged local `sign_up` | Successful registration establishes auth-center state and enters the shared completion path. | +| Provider start | Transaction, enabled Provider config | Create linked OAuth request | State, verifier, and nonce persist server-side before redirect. | +| Provider authorization redirect | OAuth request by state, current Tenant route, enabled Provider config | Read active OAuth request without consuming it | Repeated browser navigation may repeat the Provider redirect; callback state consumption still has exactly one winner. | +| Provider callback | OAuth request by state, linked transaction | Consume OAuth request | Consume before Provider code exchange/error handling. | +| Existing Provider identity | `connected_accounts(service, identifier)` | Unchanged `sign_in_identity` through SSO orchestration | Stable Provider identifier is authoritative. | +| New Provider identity | Connected accounts plus email ownership | Unchanged `sign_up_identity` through SSO orchestration | Only when email is unowned; no automatic merge/link. | +| Shared post-auth completion | Authenticated transaction plus live Site/callback/group config | Create handoff | All successful methods converge here. | +| Browser callback delivery | No private DB read in browser | No DB write | Constructive issues `303` after Provider success; Dashboard-mediated successes perform the equivalent top-level `GET`. The exact callback query carries the plaintext one-time code and public `site_state`, but no identity or reusable credential. | +| Site redemption | Handoff hash, referenced transaction, session state, exact runtime tuple | Redeem function authorizes `(site_id, api_id, principal_id)`, issues Site credential, and consumes handoff | One atomic success; Site must equal the transaction Site, and retry is allowed only before consumption and before one-minute expiry. | +| Protected Site request | Site session/credential, exact runtime tuple, and unified-session binding | Session validation/touch behavior as owned by current auth system | The exact runtime tuple remains authorized; unified revocation or mapping removal invalidates the Site-local session. | +| Logout/switch account | Current-browser unified session and its Site bindings | Revoke the current unified session and make all of its bound Site sessions unusable | Current-browser scope only; no Site notification callback and no “all devices” feature. | +| Cleanup | Expired transaction, OAuth request, and handoff rows | Purge functions or existing scheduler integration | The DB PR selects cadence, bounded batches, and post-expiry operational retention. | + +## Authorization and RLS + +### Tenant isolation enforcement + +- Tenant isolation is a **hard invariant**, not an implementation option. Unified login transactions, Provider OAuth authorization requests, SSO handoffs, and bound sessions can be created, read, changed, consumed, redeemed, or reused only within their authoritative Tenant. +- Start derives Tenant from the validated Site and Site-owned authentication configuration. Later operations derive it from the already-bound unified transaction and revalidate it against the current authoritative Site/request context; callers cannot replace it with a Tenant input. +- A Provider OAuth request must reference a unified transaction in the same Tenant. State consumption restores that relationship server-side and rejects a callback whose current route/context resolves to another Tenant. +- A handoff references its unified transaction rather than duplicating Tenant. Redemption follows that reference, validates the target Site belongs to the same Tenant, and rejects cross-Tenant code presentation before credential issuance or consumption. +- Site-originated security decisions use the authoritative `(site_id, api_id, principal_id)` runtime tuple. `site_id` is propagated independently and must equal the transaction/session Site; it is never recovered by searching for a Site that happens to use `api_id`. +- `site_runtime_clients` is the authorization source for the tuple. Multiple Sites may share the same API without becoming interchangeable, and the same API key/principal cannot act for an unregistered Site. +- Authentication-center and Site-local sessions cannot be linked or reused across Tenants. Globally unique identifiers, opaque state, a valid handoff code, or physical co-location in one database do not establish Tenant authorization. +- Every owning DB function must enforce this boundary in its predicates and locked transition. RLS is defense in depth and does not replace function-level validation. +- The DB PR resolves concrete Tenant-prefix naming, module discovery, and one-database versus cross-database routing according to the deployed repository topology. Every enabled Tenant still receives its own provisioned private SSO schema; Tenant/schema isolation is not open for reconsideration. A Tenant without enabled/provisioned SSO must fail explicitly rather than falling back to another or global schema. + +### Private transient state + +- Unified-transaction rows, OAuth authorization-request associations, handoff rows and hashes, authentication-center browser bindings, and identity outcomes are server-only private state. The plaintext handoff code and public transaction-bound `site_state` are the explicit browser transport values; the handoff plaintext is never persisted. +- No direct GraphQL CRUD or broad table grants are permitted for those relations. +- RLS remains enabled as defense in depth. All policies and function predicates must include the authoritative Tenant/database boundary; a browser-supplied Tenant or Site value is never sufficient. +- If reviewed `SECURITY DEFINER` functions are used for anonymous login operations, they must expose only narrow transitions, revoke default `PUBLIC` execution, grant only the required roles, follow repository function conventions, and validate every Tenant/Site/transaction boundary internally. +- SSO group is an authentication-reuse boundary, not an authorization group. It must not grant Tenant roles, Site access, schema access, or API/data permissions. + +### Administrative state + +- Site authentication configuration and callback administration reuse the existing Site/Tenant membership permission system. +- The DB PR selects exact required permission bits or named scopes from the current authorization registry rather than inventing them in SQL. +- Public discovery returns only safe display configuration and enabled sign-in methods. It never exposes client secrets, full callback registrations, private group topology, transaction rows, code hashes, session identifiers, or internal database details. + +### Runtime callers + +- Dashboard start/password/confirm operations run under the current resolved Tenant/database/request context. +- Provider callback restores its Tenant, Provider, and unified transaction exclusively from consumed server-side OAuth state and revalidated route context. +- Site redemption must prove possession of the one-time code and receive authoritative `site_id`, `api_id`, and `principal_id` from the trusted routing/runtime-authentication path. PostgreSQL requires the transaction Site and an exact `site_runtime_clients` tuple match before issuing credentials or consuming the code. +- `Origin` and `Referer` may be checked against registered Site data as auxiliary browser-request evidence, but missing or matching headers never create Site authority and cannot compensate for a missing/mismatched runtime tuple. +- No operation may bypass RLS through a manually inferred Tenant/database, direct secret query, legacy schema fallback, or alternate auth context. + +## Indexes and Constraints + +The DB PR should include query-plan tests or inspection for the following access patterns. It selects exact index names and shapes using current repository conventions. + +At minimum, `site_runtime_clients` requires an exact unique lookup path for `(site_id, api_id, principal_id)` and owner-oriented lookup paths needed for administration and revocation. No index or alternate query may collapse this boundary to API-only Site selection. + +| Relation | Required/proposed index or constraint | Status | +| --- | --- | --- | +| Site auth config | Unique Site reference; check `sign_in_mode`; check normalized `sso_group_key` | Cardinality/value behavior **Confirmed** | +| Site callbacks | Site + active + `created_at` + ID ordering index | Required to support confirmed deterministic selection | +| Unified transactions | Unique opaque identifier or identifier hash | Uniqueness/security **Confirmed**; representation is a DB PR security choice | +| Unified transactions | Expiry index; Site/Tenant lookup as required by cleanup/revalidation | Required access pattern; exact shape is a DB PR decision | +| OAuth requests | Unique state or state hash; expiry index; unified-transaction FK index | Lifecycle/link **Confirmed**; representation is a DB PR security choice | +| Handoffs | Unique code hash; expiry index; unified-transaction FK index | **Confirmed access needs** | +| Handoffs | Enforce at most one active handoff per transaction | **Confirmed lifecycle requirement**; constraint shape is a DB PR decision | +| Session binding | Lookup from Site-local session to unified session and revocation state | **Confirmed access need**; physical index is a DB PR decision | + +Foreign keys must prevent cross-owner orphaning while respecting short-lived cleanup. The DB PR selects cascade/restrict behavior for Site deletion, callback deletion, transactions, OAuth requests, handoffs, sessions, and audit retention as one internally consistent lifecycle policy. + +## Retention and Cleanup + +- Handoff redeemability is exactly one minute (**Confirmed**). +- Unified transactions and Provider OAuth authorization requests each expire ten minutes after creation (**Confirmed**). Expiry is enforced independently of cleanup. +- Expiry is enforced during every consume/redeem operation; cleanup timing is not part of the security check. +- Purge operations delete expired transient rows after an operational/audit grace period. The DB PR selects bounded grace periods and batch sizes; they do not extend validity. +- Cleanup must be bounded and indexed, return observable row counts, tolerate retries, and avoid long locks on authentication paths. +- Existing `sso_private.purge_expired_oauth_requests` is a useful lifecycle pattern, not an automatic decision to keep its schema or retention value. +- Provider tokens, authorization codes, raw credentials, plaintext handoff codes, and raw browser-binding secrets have no retention window because they must not be persisted. +- Security audit events use the existing audit/error ownership and contain only safe identifiers and classified outcomes. They do not include Provider payloads, tokens, secrets, password values, callback code, or raw handoff code. + +## Migrations and Backfill + +### Source ownership + +- Do not edit generated files under `application/constructive/` directly. +- Add or evolve the correct platform/metaschema/integration source module, its generator inputs, deploy/revert/verify units, and then regenerate committed application artifacts according to Constructive DB conventions. +- The physical source module/schema placement for the confirmed Site-owned configuration/callback and logical `site_runtime_clients` models, plus physical table/function names and source layout for SSO transient state, must be selected before writing migrations. Site logical ownership/cardinality, exact runtime-tuple authorization, and per-enabled-Tenant SSO schema ownership are already fixed. +- SSO module provisioning creates a separate Tenant-prefixed private SSO schema for each Tenant that enables SSO. Migration and module-discovery changes must target only that Tenant's provisioned schema, must not cross schemas, and must not assume the schema exists for a Tenant where SSO is not enabled/provisioned. + +### Rollout shape + +1. Add private persistence models, constraints, indexes, registered errors, and deploy/revert/verify coverage without enabling public login. +2. Add narrow PostgreSQL functions and their grants/RLS policies. +3. Expose permission-controlled administrative GraphQL operations and safe public start/discovery operations. +4. Enable Constructive orchestration only after the DB version, session-binding mechanism, and server options are pinned together. +5. Validate baseline, local password, Provider OAuth, handoff redemption, Site-local session, and unified revocation in that order. + +### Existing data + +- `connected_accounts`, current users/emails, identity providers, sessions, and session credentials remain authoritative and are not copied into new SSO tables. +- Existing Sites are backfilled with unified authentication disabled and `confirm` as the stored/default sign-in mode. This follows the confirmed default-off feature policy and avoids enabling a new trust path during migration. The DB PR selects the migration mechanics. +- Existing Sites and API/service-principal credentials do not receive inferred `site_runtime_clients` rows. Administrators provision explicit exact tuples before enabling unified authentication for a Site; until then Site redemption and Site-session authentication fail closed. +- Callback rows must not be inferred from existing routes, domains, CORS origins, Provider redirect URIs, or Host history. Administrators register each exact trusted callback explicitly. +- Existing `integrations/sso` OAuth requests are short-lived. Before changing that table, the DB PR verifies whether the integration is installed and selects a repository-compatible rollout for in-flight rows (for example, drain before deploy or a bounded migration). The new flow does not read `lane` or legacy `return_to`, and no compatibility fallback survives rollout. +- `pending_identity_links` data is not migrated into unified transactions or handoffs. +- No migration adds legacy multi-version lookup, old-schema fallback, parent-domain shared-cookie behavior, or long-lived URL credential transport. The confirmed one-minute, one-time handoff query is the only Site callback credential exception. + +## Testing Strategy + +### Database-owned tests + +Use the repository's `pgsql-test`/Jest infrastructure or the owning module's equivalent existing harness. Tests should provision a real isolated database and exercise the actual generated objects rather than mocking PostgreSQL. + +Required coverage: + +- module provisioning creates an isolated Tenant-prefixed private SSO schema only for each enabled/provisioned Tenant; runtime discovery selects the current Tenant's schema, rejects cross-schema substitution, and fails explicitly when the current Tenant has no SSO module; +- Site configuration one-to-one constraint, mode default/check, group normalization/check, and Tenant isolation; +- exact callback acceptance and rejection, disabled callbacks, no-callback deterministic selection, and stable tie-breaking; +- exact `site_runtime_clients` tuple authorization, duplicate prevention, removal/revocation, wrong Site/API/principal rejection, and two Sites safely sharing one API without identity ambiguity; +- transaction creation rollback on any invalid Site/callback/`returnTo`/Tenant input; +- no public read/status path for transaction rows; +- transaction expiry, wrong browser/Tenant/Site/group, duplicate completion, and concurrent transitions; +- Provider OAuth request linkage to the correct transaction, state mismatch/expiry/replay rejection, verifier server-side persistence, and atomic consumption; +- SSO password wrapper boundary validation, exactly-one local `sign_in` invocation per submission, preserved safe failure, manual resubmission, and unchanged credential outcome; +- local registration wrapper reuse of `sign_up`, immediate auth-center session outcome, transaction association, rollback, and shared-completion convergence; +- normalized external identity: existing `(service, identifier)` login, unowned-email provisioning, owned-email conflict, connected-account uniqueness race, and rollback of partial provisioning; +- handoff one-minute expiry, plaintext-not-persisted assertion, hash uniqueness, wrong Site/Tenant/runtime-tuple rejection, transient pre-consume rollback/retry, and exactly one winner under concurrent redemption; +- Site credential issuance and handoff consumption in one transaction; +- Site-local session rejection after the DB PR implements the required unified-session binding; +- RLS/grant checks for anonymous, authenticated, Site-server, Tenant administrator, wrong Tenant, and direct-table access; +- fail-closed rejection when a Site or flow requires Constructive `strictAuth`, local MFA, or step-up authentication; +- purge correctness, bounded batches, active-row preservation, and repeated cleanup safety; +- deploy, verify, revert, and regeneration consistency; and +- migration behavior for existing Sites and any installed legacy SSO module. + +### Constructive integration tests + +The DB PR should expose stable seams for Constructive's higher-level tests, which use the real GraphQL server, Express Context, Tenant/database routing, DB functions, session/Cookie behavior, and callback lifecycle. Mock only the external Provider authorization/token/user-info boundary. Higher layers must not query private SSO or `connected_accounts` tables to assert implementation details; DB invariants remain in the owning DB tests. + +### Security and negative tests + +- Property/fuzz cases for callback exactness, `returnTo`, group keys, opaque identifiers, and code/state encodings. +- Concurrent callback and handoff replay attempts. +- Callback/Site reassignment or disablement during an in-flight transaction. +- Cross-Tenant, cross-database, cross-Site, and cross-group substitution. +- API-only Site inference, principal substitution, shared-API cross-Site redemption, caller-supplied Site substitution, and spoofed/missing `Origin`/`Referer` cases proving those headers are not authority. +- Provider state linked to the wrong unified transaction. +- Exact GET callback and query encoding, Site-side `site_state` correlation, immediate clean redirect after redemption, and prevention of handoff/`site_state` leakage through cache, referrer, proxy/access/APM/analytics, or error logs at the Constructive integration owner. +- Logging/error snapshots proving that secrets, raw codes, hashes, verifier, tokens, Provider payloads, callback lists, and internal SQL details are absent. + +## Decision Classification + +### A — Confirmed or Directly Derived Conclusions + +The following items are settled by the requirements, formal Spec, verified DB contracts, or a necessary consequence of those decisions. They are not implementation questions for the user: + +- Site authentication configuration is Site-owned one-to-one, and callbacks are Site-owned one-to-many with exact active matching and deterministic default selection. +- Every enabled Tenant receives its own Tenant-prefixed private SSO schema. All transient state and session reuse remain inside the authoritative Tenant boundary; no global or fallback SSO schema is allowed. +- The existing Tenant-local OAuth authorization-request table is evolved with a unified-transaction reference. New flows ignore legacy `lane` and keep validated `returnTo` only on the unified transaction. +- Unified transactions are active-flow-only, have one authenticated identity outcome, expose no recovery/status query, and are not resumed after interruption, invalid state, or failed Provider flow. +- The authentication-center first-party session/request context provides the authoritative browser binding. Heuristic browser fingerprints cannot replace it; the physical binding representation is a DB PR choice. +- The Site-created `site_state` is a separate public correlation binding on the unified transaction. It is returned beside the handoff so the Site can validate the initiating browser, but it never replaces handoff, Tenant, or Site/runtime authentication. +- Constructive generates OAuth state, PKCE verifier/nonce, and handoff plaintext with the owning cryptographic packages; PostgreSQL persists only the required server-side values and the handoff hash. +- Site, callback, Tenant, SSO group, and current enablement are revalidated at the final handoff/redemption boundaries. A snapshot may support deterministic processing but never replaces live trust validation. +- A completed transaction creates at most one handoff. The same unconsumed code may be retried after a transient exchange failure during its one-minute lifetime; no replacement code is minted for that transaction in v1. +- Current-browser global logout revokes the unified session, and every bound Site-local session becomes unusable on its next protected request. The physical binding belongs to the sessions/SSO owner. +- Handoff redemption authenticates the target Site/runtime through the exact `(site_id, api_id, principal_id)` tuple carried by routing/runtime authentication and pgSettings and authorized by `site_runtime_clients`, in addition to validating the code and transaction-bound context. Site is not inferred from API, `Origin`/`Referer` are never authoritative, and no parallel SSO-specific secret or credential system is added. +- Unified login transactions and Provider OAuth authorization requests each expire ten minutes after creation. +- Local password and registration reuse unchanged `sign_in` and `sign_up`; Provider identities reuse unchanged `sign_in_identity`, `sign_up_identity`, and `connected_accounts` through narrow SSO wrappers. +- Sites or flows requiring Constructive `strictAuth`, local MFA, or step-up authentication are outside v1 SSO integration and fail closed. A future implementation requires a separate design based on an actual use case and cannot downgrade or bypass those policies. +- First-time external identity provisioning follows the confirmed `sign_up_identity` path when the email is unowned. Provider enablement remains the availability control; v1 adds no separate auto-provision policy switch. +- Stable public error semantics, cause preservation, secret redaction, real DB integration tests, and external-Provider-only mocks remain mandatory. + +### B — DB PR Implementation Decisions + +The implementing DB PR resolves the following from the live repository's owner, generator, migration, error, authorization, and test conventions. These choices require evidence and review in the PR, but are not user-facing open decisions: + +- physical module/schema/table/column/function/GraphQL names, including the repository-local physical name and placement of logical `site_runtime_clients`, SQL and GraphQL types, IDs, FKs, state/timestamp representation, and Tenant-prefix discovery mechanics; +- opaque identifier/state/hash-at-rest representations and repository-approved PostgreSQL storage/index types for Constructive-generated secure random material; +- exact wrapper composition, session/credential primitive integration, unified-to-Site session-binding relation, and one-database versus cross-database routing mechanics while preserving confirmed Tenant isolation; +- snapshots versus references, authenticated-outcome references, browser-binding storage, locking, uniqueness, indexes, query plans, cascades, and transaction-state representation; +- exact permission bits/roles/policies selected from the existing registry, canonical error-registry entries, safe audit fields, and GraphQL exposure annotations; +- existing-Site backfill mechanics using disabled + `confirm`, legacy OAuth row draining/migration and `lane`/`return_to` removal timing, generated-artifact rollout, and deploy/revert/verify structure; and +- cleanup ownership, cadence, batches, operational grace retention, monitoring, and alerts. None of these choices may extend an artifact's validity or weaken confirmed boundaries. diff --git a/docs/sso/spec/oauth-sso-platform-integration-constructive-design.md b/docs/sso/spec/oauth-sso-platform-integration-constructive-design.md new file mode 100644 index 0000000000..376e915787 --- /dev/null +++ b/docs/sso/spec/oauth-sso-platform-integration-constructive-design.md @@ -0,0 +1,1085 @@ +# OAuth/SSO Platform Integration — Constructive Detailed Design + +## Document Status + +This document is the implementation design for the Constructive repository. +It is derived from: + +- the [product requirements](../plan/oauth-sso-platform-integration.md); +- the [formal platform specification](./oauth-sso-platform-integration.md); and +- the [Constructive DB detailed design](./oauth-sso-platform-integration-constructive-db-design.md). + +The product and formal specification remain authoritative for observable +behavior. This document fixes Constructive package ownership, request lifecycle, +server interfaces, configuration, security controls, testing, and PR slicing. +Physical database objects and SQL atomicity remain owned by the DB design. + +The original repository evidence in this design was inspected on branch +`feat/oauth-sso-platform-integration` at Constructive commit `5af7e77b4`. Final +convergence evidence uses the combined Constructive DB runtime commit +`ffc87bb07ede49a0734f676d0eb06042f0565eef`, the seven-PR Constructive stack +head `87061435f59a1410b19161c2ae2365cc0891da5d`, and Dashboard implementation +commit `a6611fbcab677e91c431f275bd02930b39828421`. + +## Scope + +This design covers: + +- the Tenant-scoped unified-authentication entry in Constructive; +- full Express request-context and loader integration; +- GraphQL operations used by Dashboard and target Site servers; +- local password and registration orchestration; +- the protocol-neutral Provider adapter registry and concrete adapter implementations, with Google and GitHub documented as examples rather than a release allowlist; +- OAuth Authorization Code, S256 PKCE, state, callback, and identity resolution; +- authentication-center Cookie/Bearer completion; +- creation and browser delivery of the one-time Site handoff; +- Site-side `site_state` and redemption integration contracts; +- trusted Site runtime identity propagation and Site/API/principal authorization; +- configuration, errors, observability, tests, rollout, and package ownership. + +## Non-Goals + +- Dashboard page implementation or management UI implementation. +- Hub integration or cross-host browser E2E in this Constructive phase. +- A shared-parent-domain Cookie design, third-party Cookie dependency, or a + global session shared across Tenants. +- A complete browser SSO experience when the user disables all first-party + Cookies. Bearer remains supported for Site/API clients, but v1 browser + correlation and auth-center session reuse use each domain's own Cookie. +- Strict-auth, local MFA, or step-up Site integration in v1. +- Account-settings Provider linking, automatic email-based account merge, or + password-confirmation linking. +- Transaction recovery/status polling after an interrupted flow. +- A new OAuth/SSO secret store, Site-runtime credential system, request context, + Provider registry, or identity-association table. +- Compatibility fallbacks for older Constructive DB versions, alternate schemas, + legacy OAuth `lane`, or legacy `return_to` behavior. + +## Confirmed Implementation Decisions + +1. The unified-authentication origin is Tenant-scoped. Its Host is registered in + the existing routing plane and must resolve through + `routing_public.resolve_route(host, '/', NULL)` before any SSO operation. +2. `req.constructive` is the only request-level source for Tenant, API, + database, route, session, user, request ID, database access, and loaders. +3. A browser-supplied Tenant/database identifier, path-derived Tenant, or global + transaction/state scan is never an authentication authority. +4. The authentication-center session Cookie is host-only to the Tenant's auth + origin. Another Tenant's auth Host receives a different first-party Cookie + boundary and cannot reuse its session, OAuth state, transaction, or handoff. +5. The Site creates a cryptographically random, one-time `site_state`, stores it + under the current browser in a process-independent first-party session + boundary, and supplies it at login start. Constructive binds it to the login + transaction and returns it beside the handoff. The Site validates it before + redemption and consumes it after successful redemption. +6. Provider OAuth uses Authorization Code with mandatory S256 PKCE and + server-side state for every Provider request handled by a registered adapter. +7. Successful completion uses a one-minute, one-time handoff code in a top-level + `GET` callback query. It never carries identity or a reusable credential. +8. Local password, local registration, reused unified authentication, and every + registered Provider adapter all enter the same post-authentication + continuation. +9. Site-originated handoff redemption, Site credential issuance, and later Site + authentication use an authoritative `(site_id, api_id, principal_id)` runtime + tuple. `site_id` is a first-class routing/runtime fact and is never inferred + from `api_id`; multiple Sites may share one API. +10. The DB-owned logical `site_runtime_clients` relation authorizes exact Site, + API, and service-principal tuples. `Origin` and `Referer` are optional + defense-in-depth signals, not Site identity sources. + +## Verified Baseline and Implemented Evolution + +The table records the original and second-round gaps that the final stacked +implementation is required to close. It is historical implementation evidence, +not a description of missing work in the final stack. + +| Area | Original or second-round gap | Final required state | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request routing | `graphql/server/src/middleware/api.ts` resolves Host through the canonical scoped routing plane; PR7 redemption has routed API facts but no authoritative Site fact | Preserve the Tenant auth-origin behavior and extend the correct routing/runtime-identity owner so Site-originated requests resolve `site_id` independently of `api_id` | +| Request context | `packages/express-context` builds one lazy `req.constructive` context after routing and authentication; PR7 exposes API/token principal but no trusted Site ID | Reuse the single context and add the resolved Site runtime fact there; do not create an SSO context or accept Site identity from GraphQL input | +| PostgreSQL settings | PR7 forwards `api_id`, token/session facts, and `principal_id`; it does not forward `site_id` | Forward trusted `site_id` as `jwt.claims.site_id` beside `jwt.claims.api_id` and `jwt.claims.principal_id`; DB functions require the complete tuple | +| Site runtime authorization | PR7 requires an API-key principal for redemption but has no exact Site/API/principal authorization relation | Reuse normal runtime authentication and validate the exact tuple through DB-owned `site_runtime_clients`; never infer Site from API or add an SSO secret | +| Identity procedures | `authSurface` discovers Tenant-prefixed identity procedure schemas and connected-account views | Reuse for `sign_in_identity` and `sign_up_identity`; do not duplicate discovery SQL | +| Provider configuration | `identityProviders` resolves Tenant provider rows and current internal secrets, is opt-in, and has a 30-second cache | Register it only for the auth service; reuse its cache/rotation lifecycle and map its result to a public DTO | +| OAuth package | `packages/oauth` currently has a hard-coded Provider registry, browser state Cookie middleware, no PKCE, unbounded native fetches, raw Provider error text, and `/auth/providers` | Replace the new-flow surface with protocol primitives and adapters; do not wire the legacy middleware into the platform flow | +| GraphQL configuration | `graphql/types` and `graphql/env` own GraphQL options/defaults/env merging; no OAuth options exist yet | Add typed OAuth server options and final validation here; middleware never reads `process.env` | +| Authentication | `graphql/server` prefers Bearer over `constructive_session`; session Cookie attributes come from auth settings | Preserve credential precedence and Cookie ownership; enforce the auth-center host-only minimum | +| Cookie lifecycle | `AuthCookiePlugin` recognizes an allowlist of auth mutations and extracts the existing access-token result | Extend the correct owner for SSO mutations and share the same Cookie writer with HTTP callback completion | +| Request logging | the shared request logger currently logs `req.originalUrl` | Add sensitive-query redaction before OAuth and handoff-bearing URLs can reach logs | +| Errors | `@constructive-io/errors` is the canonical registry/factory; current OAuth uses a separate error shape | Register stable business codes and remove the separate runtime error surface from the new flow | +| Server integration tests | `graphql-server-test` runs the real server, routed database, Express Context, SuperTest, and seed lifecycle | Extend its typed options and mock only the external Provider | + +## Component Ownership + +```mermaid +flowchart LR + Site["Target Site integration"] --> Dashboard["Dashboard unified-auth UI"] + Dashboard --> GraphQL["Constructive GraphQL SSO plugin"] + Dashboard --> OAuthRoutes["Constructive OAuth HTTP routes"] + GraphQL --> Context["req.constructive"] + OAuthRoutes --> Context + Context --> SsoSurface["SSO surface loader"] + Context --> AuthSurface["Existing authSurface loader"] + Context --> Providers["Existing identityProviders loader"] + OAuthRoutes --> Orchestration["Common SSO orchestration"] + GraphQL --> Orchestration + Orchestration --> OAuthPkg["packages/oauth primitives and adapters"] + Orchestration --> DB["Constructive DB functions"] + OAuthPkg --> Registry["Registered Provider adapters"] + Registry --> Google["Google/OIDC example"] + Registry --> GitHub["GitHub/OAuth example"] +``` + +### `packages/oauth` + +Owns protocol-neutral Provider contracts and OAuth/OIDC security primitives: + +- opaque random state and PKCE verifier/challenge generation; +- authorization-request construction from validated configuration; +- code exchange, token/response validation, and safe normalization; +- Provider endpoint validation and bounded network behavior; +- concrete adapter implementations registered by the package, including the + documented Google and GitHub examples; +- Provider-unit-test fixtures. + +It does not own Express routing, Cookies, login transactions, Tenant lookup, +database calls, identity association, session issuance, handoff creation, or +Dashboard behavior. + +### `packages/express-context` + +Remains the only request context. It continues to own `authSurface` and +`identityProviders`. Add one opt-in `ssoSurface` loader whose only responsibility +is to resolve the current database's provisioned Tenant-prefixed SSO private +schema/surface from Constructive DB module metadata. + +The final stacked implementation extends the canonical routed request facts +with trusted `siteId` for a Site-originated runtime request. This is not an +SSO-only field or loader result: +the routing/runtime-identity owner resolves it before SSO code runs, the same +`req.constructive` instance carries it beside API/database/token facts, and +pgSettings forwards it as `jwt.claims.site_id`. A browser or GraphQL caller +cannot set or override it. + +The loader returns `undefined` when the current Tenant has no provisioned SSO +module. It never searches another Tenant, falls back to a global `sso_private`, +or reads Provider configuration/secrets. Procedure names remain fixed by the DB +module contract; the loader returns physical schema identity, not application +policy. + +`SsoSurface` is added to the typed built-in module map, but its loader is not +added to `createDefaultRegistry()`. The GraphQL server registers it, together +with `identityProvidersLoader`, only for the unified-auth service. This preserves +the current opt-in cost and secret-decryption boundary. + +### `graphql/server` + +Owns: + +- the SSO Graphile plugin and public GraphQL orchestration contract; +- the two browser-only OAuth routes; +- route/context validation and common login orchestration; +- use of DB functions through `req.constructive.withPgClient`; +- auth-center Cookie completion; +- safe error mapping, response headers, and request-target redaction; +- synthesis of the exact Provider authorization URL and Site continuation URL. + +Feature-local SSO code belongs under one `graphql/server/src/auth/sso/` +boundary (exact filenames may follow the PR's local convention). It must not be +placed in generic middleware files or copy loader/config/error helpers. + +### Constructive DB + +Owns the atomic state transitions and credential results defined by the DB +detailed design. Constructive calls those functions; it does not query private +transaction, OAuth request, handoff, connected-account, or session tables +directly. + +## Tenant-Scoped Authentication Origin + +### Routing Contract + +Each enabled Tenant registers an authentication Host in the existing routing +plane. The concrete DNS naming pattern is deployment configuration; the runtime +contract is exact Host resolution to the intended Tenant, API, database, role, +and public/private surface. + +For a Site-originated runtime request, the routing/runtime-authentication plane +must additionally resolve the exact Site independently of the API. An API route +or service-principal credential may be shared by multiple Sites, so `api_id` +cannot be reverse-mapped to a unique Site. The concrete routing/runtime identity +representation follows the live platform owner, but its result is a trusted +`site_id` fact before Express Context and PostgreSQL settings are built. + +The existing middleware order is preserved: + +1. domain parsing; +2. request ID and redacted request logging; +3. canonical API/Host resolution; +4. Bearer-or-Cookie authentication; +5. creation of the complete `req.constructive` context; +6. CSRF protection where applicable; +7. OAuth HTTP routes and GraphQL; +8. canonical error handling. + +An auth-center SSO handler starts by requiring `req.constructive`, the routed +API/database, and the current Tenant's `ssoSurface`. A Site redemption handler +additionally requires trusted `siteId` and an authenticated principal from the +same context. Neither handler performs a second Tenant, Site, API, database, or +route lookup from request parameters. + +### Tenant Isolation Consequences + +- A Site ID from Tenant B presented on Tenant A's auth Host fails before a login + transaction is created. +- An OAuth state created on Tenant A cannot be found or consumed on Tenant B's + Host because both Context and the provisioned SSO schema are Tenant-local. +- Provider callback URI construction uses the validated current auth Host and + the fixed callback path; an arbitrary `Host`, `Origin`, `Referer`, or query + value does not select the redirect URI. +- A Site request with a valid API/principal but no trusted Site fact, or with a + tuple not registered by `site_runtime_clients`, fails before redemption or + Site credential issuance. Matching `Origin`/`Referer` cannot repair it. +- The auth-center Cookie has no `Domain` attribute and therefore is not shared + with another Tenant auth Host or target Site. +- If a future deployment needs shared-host path routing, it must first extend + the canonical routing owner. SSO middleware does not introduce that alternate + resolver in v1. + +## Site `site_state` Correlation + +### Site Responsibility + +Before navigating to unified login, the Site: + +1. generates 32 random bytes with a cryptographically secure generator and + base64url-encodes them; +2. records the value, creation/expiry, intended Site callback, and browser + session association in its existing process-independent session store; +3. keeps a bounded set of pending states per browser so concurrent attempts do + not overwrite one another; and +4. sends the public state with Site ID, optional exact callback, and validated- + candidate application-relative `returnTo` to the Tenant auth page. + +The browser's first-party Site session Cookie is `Secure`, `HttpOnly`, +`SameSite=Lax` or stricter where compatible, and scoped to the narrowest useful +Site path/Host. A process-local map or one overwrite-prone raw-state Cookie is +not sufficient in a multi-instance or concurrent-login deployment. + +The pending Site state lasts no longer than the ten-minute login transaction +plus the one-minute handoff window. This lets a handoff created at the end of a +valid transaction finish without turning `site_state` into longer-lived state. + +### Constructive Responsibility + +The start mutation validates the state as a bounded base64url value, passes it +to the DB start function, and never changes it afterward. The login transaction +binds it to the authoritative Site and callback. Shared completion reads it only +from that transaction and appends it beside the handoff code to the exact +callback URL. + +`site_state` is not a credential and is not sufficient to redeem a handoff. It +is nevertheless redacted from application, access, proxy, APM, analytics, and +error logs because it correlates a live authentication attempt. + +### Callback Responsibility + +The Site callback performs these steps before rendering HTML or loading +third-party resources: + +1. require the Site's first-party browser session; +2. exact-match `site_state` against an unexpired pending login in that session; +3. call the Constructive handoff-redemption mutation from the Site server; +4. on successful redemption, mark the pending state consumed, set the Site's + own first-party credential, and redirect to the verified clean `returnTo`; +5. on a transient redemption failure, keep the pending state only while the + handoff and state remain live so the same callback can retry; and +6. on mismatch, expiry, replay, or terminal failure, clear the relevant pending + state and fail safely without redeeming. + +## Configuration and Environment Ownership + +### Typed Options + +Add an OAuth server-options group to `@constructive-io/graphql-types` and the +effective `ConstructiveOptions` shape. The minimum v1 deployment options are: + +| Option | Default/validation | Owner | +| -------------------------------- | ------------------------------------------------ | ------------------------------------------------------------- | +| `oauth.enabled` | `false`; explicit opt-in | `graphql/types` default, `graphql/env` merge/final validation | +| `oauth.providerRequestTimeoutMs` | bounded positive duration; v1 default 10 seconds | `graphql/types` default, `graphql/env` parser/validation | + +There is no server OAuth state secret: state and PKCE are random, persisted, +and consumed through the Tenant-local DB lifecycle. Provider client IDs, +secrets, endpoints, scopes, and policy remain Tenant data resolved through +`identityProviders`; they are not GraphQL-server environment variables. + +### Environment Parsing + +- `graphql/env` parses optional `OAUTH_ENABLED` and + `OAUTH_PROVIDER_REQUEST_TIMEOUT_MS` overrides with existing 12-factor parsers. +- Because `getGraphQLEnvVars()` is a partial override parser, an absent variable + emits no override. It must not inject `false` and overwrite a config-file or + runtime `true` value. +- Honest defaults live in `constructiveGraphqlDefaults`; the optional env parser + uses `parseEnvBoolean`/`parseEnvNumber`, equivalent to the established + `withDefault` class without duplicating parsing. +- If a future server setting has no honest production fallback, it must use the + existing `required` or `devDefault` validator at the owning config boundary. + v1 introduces no such secret setting. +- `graphql/env` includes the `oauth` config-file key in its established merge + order: PGPM core → GraphQL defaults → GraphQL config → GraphQL env → runtime + overrides, with existing array-replacement behavior. +- `@pgpmjs/env` continues to own PGPM/PostgreSQL runtime configuration. No OAuth + or SSO key is added to `pgpm/env`, and middleware does not read `process.env`. + +When OAuth is disabled, Provider discovery is empty, the stable Provider-start +mutation fails with registered `OAUTH_SIGN_IN_DISABLED`, and the OAuth HTTP +router is not mounted. Keeping the schema field stable avoids making GraphQL +introspection depend on a runtime feature flag. No Provider loader or secret +resolution is performed. When enabled, malformed server options fail during +options/startup validation. Tenant Provider faults fail at the request that +selects that Tenant configuration; they cannot be globally validated at process +startup. + +## Express Context and Loader Design + +The server builds one registry during startup: + +1. create the existing default registry; +2. register `ssoSurfaceLoader` for the unified-auth service; +3. register `identityProvidersLoader` only when `oauth.enabled` is true; and +4. pass that registry to the single existing context middleware. + +All SSO code uses: + +- `req.constructive.api` for the routed API facts; +- trusted `req.constructive.siteId` for the routed Site fact when the operation + is Site-originated; auth-center operations do not synthesize one; +- `req.constructive.databaseId` and `withPgClient` for the current Tenant DB; +- `req.constructive.userId` and current token/session facts for reusable auth; +- `req.constructive.useModule('ssoSurface')` for the provisioned SSO surface; +- `req.constructive.useModule('authSurface')` for existing identity procedures; + and +- `req.constructive.useModule('identityProviders')` for enabled Provider + configuration and secret resolution. + +The Graphile request context is extended with a reference to the same +`req.constructive` object so SSO schema-extension plans can use it. This is not a +second auth context: the extension forwards the already resolved object rather +than reconstructing selected fields. + +`buildPgSettings` forwards trusted Site identity as `jwt.claims.site_id` beside +the existing `jwt.claims.api_id` and normalized +`jwt.claims.principal_id`. It omits the Site claim when the routed operation is +not Site-originated; a DB operation that requires Site authority then fails +closed. SSO services never fill the claim from mutation arguments, transaction +rows, `Origin`, `Referer`, or an API lookup. + +`identityProvidersLoader` currently throws plain `Error` for missing/disabled +configuration. The SSO integration maps those failures at the loader/service +boundary to registered domain errors. It does not parse error strings or return +a secret-less Provider as a public client. + +The current loader also transforms every row before filtering and therefore +rejects an incomplete disabled Provider. The auth-flow query should resolve only +enabled rows; disabled administration templates are not runtime Providers. An +enabled row missing client ID, required secret, endpoint, mandatory PKCE, or +adapter-specific verification configuration remains an explicit configuration +failure rather than being silently omitted or treated as a public client. + +## Provider Adapter Design + +### Contract + +Define one protocol-neutral interface; do not require inheritance or an abstract +base class. Its logical contract is: + +```ts +interface ProviderAdapter { + readonly kind: string; + validateConfiguration( + config: IdentityProviderConfig + ): ValidatedProviderConfig; + createAuthorizationRequest( + input: ProviderAuthorizationInput + ): ProviderAuthorizationResult; + completeAuthorization( + input: ProviderCallbackInput + ): Promise; +} +``` + +The adapter receives only: + +- already selected, enabled, Tenant-scoped Provider configuration; +- the exact callback URI persisted for the OAuth request; +- the request-specific OAuth state, S256 challenge, and optional nonce on start; +- the callback authorization code, server-held verifier, and nonce on complete; + and +- the configured network timeout. + +The adapter returns only a normalized identity with Provider service key, stable +external identifier/subject, optional email, and safe profile basics. It never +receives a unified login transaction object and never associates accounts, +issues Constructive credentials, creates handoffs, or performs Tenant routing. + +### Common OAuth Primitives + +The package provides focused functions for: + +- 32-byte base64url OAuth state generation; +- RFC 7636-compatible high-entropy verifier generation; +- S256 challenge derivation; +- optional OIDC nonce generation; +- exact HTTPS endpoint/origin validation against the selected adapter's v1 + allowlist; +- authorization URL construction that cannot override protected parameters; +- token/user-info requests with `AbortSignal.timeout`, `redirect: 'error'`, + bounded response size, content-type checks, and strict response parsing; and +- safe Provider error classification without raw response text. + +The service persists OAuth state, verifier, nonce, redirect URI, Provider, and +unified-transaction association before browser navigation. Browser-visible +authorization requests contain state and the S256 challenge only. Callback +consumes valid state before Provider error or code handling; only the server +exchanges code plus original verifier. + +### Google Adapter + +The Google adapter: + +- accepts only approved Google HTTPS authorization, token, issuer, discovery, + and JWKS endpoints from the enabled Tenant configuration; +- sends Authorization Code + S256 PKCE and a per-request nonce; +- exchanges the code server-side; +- validates ID-token signature, issuer, audience, expiry, and nonce with a + maintained JWT/JWKS implementation (add `jose` to `packages/oauth` unless the + frozen baseline provides an equivalent owned dependency); +- normalizes the stable subject, email when present, and safe name/avatar data; + and +- does not persist or expose the optional access token when validated ID-token + data is sufficient. + +`skipNonceCheck=true` or `pkceEnabled=false` is not a compatibility mode for the +v1 Google flow; selecting such a configuration fails closed. + +### GitHub Adapter + +The GitHub adapter: + +- accepts only approved GitHub HTTPS authorization, token, user, and email + endpoints from the enabled Tenant configuration; +- exchanges Authorization Code + verifier server-side; +- uses the resulting access token only for server-side user and, when needed, + email requests; +- treats the stable GitHub user ID, not login or email, as the external + identifier; and +- normalizes optional email and safe name/avatar data. + +A valid, successful empty or unavailable optional-email response may normalize +to no email. Transport, timeout, invalid JSON, or schema failures are explicit +Provider failures and are not swallowed. + +### Existing Package Surface + +The new platform path does not use the current `createOAuthMiddleware`, browser +state Cookie, hard-coded `/auth/providers`, raw `OAuthProfile.raw`, or the +hard-coded Facebook/LinkedIn registry. The package PR replaces or deprecates +those exports under the repository's package-release rules, updates README and +tests, and leaves no runtime branch that falls back to the legacy behavior. + +## Constructive SSO Orchestration + +### Graphile Plugin + +Add one Graphile v5 schema-extension plugin using the repository's existing +`graphile-utils` `extendSchema`/Grafast patterns. It exposes the application- +coordinated GraphQL operations and delegates every state transition to a DB +function through `req.constructive.withPgClient`. + +The plugin returns explicit DTOs rather than private DB rows. It is the merge +point for safe Site display context and public Provider display options from +`identityProviders`. Secret, endpoint-internal, callback-list, SSO-group, +transaction, and database details never enter these DTOs. + +The logical GraphQL operations are: + +| Operation | Caller and input | Result/behavior | +| ------------------------ | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Start unified login | Dashboard; Site ID, optional exact callback, application-relative `returnTo`, `site_state` | Calls DB start; returns opaque transaction ID, safe Site branding/mode/decision context, and enabled supported Provider display options | +| Confirm existing account | Authenticated Dashboard; transaction ID | Validates current auth-center identity and transaction, then returns one server-built Site continuation URL | +| Local password sign-in | Dashboard; transaction ID plus existing credentials | Calls the SSO wrapper once; returns the normal auth-center Bearer result plus minimal continuation URL; existing Cookie behavior applies | +| Local registration | Dashboard; transaction ID plus existing registration input | Calls the SSO wrapper once; immediately establishes auth-center state and returns minimal continuation URL | +| Start Provider | Dashboard; transaction ID plus Provider slug | Validates transaction/config, creates the linked OAuth request, and returns only a server-built same-origin initiation URL containing opaque OAuth state | +| Redeem handoff | Target Site server; one-time handoff code | Calls the atomic DB redemption function and returns only the Site-local credential result plus verified `returnTo` | +| Provider discovery | Dashboard | Returns the same safe enabled/supported display options; never restores legacy `/auth/providers` | + +There is no public login-transaction read/status query. Password recovery/reset +continues to use the existing GraphQL operations; it does not resume the old +transaction, so the user restarts from the Site entry afterward. + +### Safe Provider Display Options + +Provider options are the intersection of: + +1. `oauth.enabled`; +2. current Tenant Provider rows with `enabled=true`; +3. complete confidential-client configuration resolved by the existing loader; +4. mandatory PKCE/nonce policy for the selected adapter; and +5. adapters registered in the running server. + +Dashboard receives only an ordered array containing a stable Provider key, +display label, and safe display metadata. It does not hard-code Provider +availability, receive secrets/endpoints, or infer support. Google/GitHub names +in this design are concrete adapter and test examples, not a v1 product +allowlist. Adding another registered adapter and complete enabled Tenant +configuration exposes it through the same DTO and Dashboard flow. An explicitly +selected missing, disabled, unsupported, or misconfigured Provider fails with a +registered error. Disabled OAuth or no enabled Provider returns an empty list. + +### Browser OAuth Routes + +The v1 Constructive HTTP surface is limited to: + +- `GET /auth/oauth/authorize?state=...` +- `GET /auth/oauth/callback?code=...&state=...` +- `GET /auth/oauth/callback?error=...&state=...` + +These names are Constructive implementation contracts; Dashboard does not +construct Provider URLs itself. The Provider-start GraphQL mutation returns the +same-origin authorize URL after persisting the linked OAuth request. Dashboard +performs a top-level navigation to it. The authorize route restores the current +Tenant-local OAuth request by state, revalidates current route/configuration, +and responds with an HTTP `303` to the adapter-built Provider URL. + +The callback route requires the current Tenant-scoped Host and complete Express +Context. It validates and consumes OAuth state first, restores the Provider and +unified transaction from server-side state, handles Provider error/code, +invokes the adapter, applies the normalized identity, and enters the shared +continuation. It never accepts transaction ID, Tenant, database, callback, or +`returnTo` as callback input. + +The authorize route uses a narrow DB function that reads an active Tenant-local +OAuth request by state without consuming it. It returns only the Provider key, +server-held verifier/derived-challenge inputs, nonce, redirect URI, and linked +transaction facts needed for revalidation. Callback consumption remains the +single state-consuming transition. Repeating the authorize GET may repeat the +Provider navigation, but only one valid callback can consume the request. + +OAuth-disabled servers do not mount these routes. Every response uses +`Cache-Control: no-store` and `Referrer-Policy: no-referrer`. Provider failure +returns or redirects only a stable safe classification to the Tenant auth-center +failure page. Raw `error`, `error_description`, Provider bodies, codes, state, +tokens, and internal exceptions are never forwarded. + +## Flow Details + +### Start and Existing Unified Authentication + +```mermaid +sequenceDiagram + actor U as User + participant S as Site + participant B as Browser + participant D as Dashboard at Tenant auth origin + participant G as Constructive SSO GraphQL plugin + participant X as req.constructive + participant DB as Constructive DB + participant P as Shared completion + + U->>S: Choose sign-in + S->>S: Generate and store pending site_state for browser + S-->>B: Navigate with Site inputs, returnTo, and site_state + B->>D: GET Tenant-scoped auth page + D->>G: Start mutation with Site inputs and site_state + G->>X: Require routed Tenant/API/database/context + G->>DB: Start transaction with authoritative context + DB-->>G: Opaque ID + safe Site/mode/auth decision + G->>X: Load safe enabled Provider display options if OAuth enabled + G-->>D: Opaque ID + safe context/options + alt Reusable identity and silent mode + G->>P: Existing identity + transaction + else Reusable identity and confirm mode + D-->>U: Show lightweight account confirmation + U->>D: Continue + D->>G: Confirm mutation with opaque ID + G->>P: Existing identity + transaction + else No reusable identity + D-->>U: Show local account and enabled Provider options + end +``` + +The mutation trusts the routed Host/context, not the Site inputs. The DB function +validates Site ownership, exact/default callback, `returnTo`, `site_state`, Site +enablement, sign-in mode, SSO group, and active session boundaries in one start +transition. + +### Local Password and Registration + +The local password mutation calls the new SSO wrapper, which validates the +active transaction and then invokes unchanged `constructive_auth_public.sign_in` +once. It preserves the existing safe password result, rate limiting, audit, and +credential outcome. There is no automatic retry; the user may submit again while +the ten-minute transaction remains active. + +Local registration uses the corresponding SSO wrapper around unchanged +`sign_up`. Success immediately creates the auth-center session/Bearer result and +enters shared completion without an email-verification gate. Password reset uses +the existing recovery flow and requires a fresh unified login afterward. + +For the GraphQL mutations, the existing auth-cookie owner sets the auth-center +first-party Cookie from the normal access-token result. The SSO integration adds +the exact mutation fields to the owned detection/metadata path and refactors the +plugin to use GraphQL AST or typed operation metadata rather than adding another +regex or cookie middleware. Parsing/serialization failures are mapped or +propagated; the new path does not catch and ignore them. + +### External Provider + +```mermaid +sequenceDiagram + actor U as User + participant D as Dashboard + participant G as SSO GraphQL plugin + participant R as Constructive OAuth routes + participant DB as Constructive DB + participant A as Provider Adapter + participant B as Browser + participant P as Configured external Provider + participant C as Shared completion + + U->>D: Choose enabled Provider + D->>G: Start Provider mutation with transaction ID + Provider + G->>DB: Create linked OAuth request with state/verifier/nonce + DB-->>G: Opaque OAuth state + G-->>D: Same-origin authorize URL containing state only + D-->>B: Top-level GET authorize URL + B->>R: GET /auth/oauth/authorize?state + R->>DB: Validate Tenant-local active OAuth request + R->>A: Build URL with state + S256 challenge + nonce + R-->>B: 303 to Provider + B->>P: Authorization request + P-->>B: Callback code/error + OAuth state + B->>R: GET Tenant callback + R->>DB: Validate and consume state; restore Provider + transaction + alt Invalid, expired, replayed, or mismatched state + R-->>B: Safe auth-center failure; restart + else Provider error/cancel + R-->>B: Safe auth-center failure; restart + else Authorization code + R->>A: Complete with code + server-held verifier/nonce + A->>P: Server-only exchange/verification + P-->>A: Provider response + A-->>R: Normalized external identity + R->>DB: Existing connected account, provision, or conflict + alt Existing or newly provisioned identity + R->>C: Authenticated identity + transaction + else Existing-email conflict or verification failure + R-->>B: Safe auth-center failure; restart + end + end +``` + +Returning Provider accounts use `sign_in_identity(service, identifier, ...)`. +An unlinked identity whose email does not belong to another account uses +`sign_up_identity` and `connected_accounts`. Email-verification metadata may be +stored in safe details but is not the durable identity key or an automatic merge +authority. An email owned by another account fails explicitly and instructs the +user to use an existing sign-in method. + +### Shared Completion and Site Handoff + +Constructive generates the handoff plaintext with 32 cryptographically random +bytes encoded as base64url and computes its SHA-256 hash. The DB creation +function receives only the hash, stores the minimal handoff row, binds it to the +transaction, and returns the confirmed one-minute expiry. The plaintext exists +only in the current response path and is emitted once. + +```mermaid +sequenceDiagram + participant P as Any successful auth branch + participant C as Common Constructive SSO service + participant DB as Constructive DB + participant D as Dashboard + participant B as Browser + participant S as Target Site callback + + P->>C: Authenticated identity + active transaction + C->>DB: Associate auth-center outcome; create handoff hash + DB-->>C: Exact callback + site_state + one-minute handoff expiry + alt Provider callback is current HTTP response + C-->>B: 303 exact callback?handoff=...&site_state=... + else Dashboard-mediated branch + C-->>D: One server-built continuation URL + D-->>B: Assign browser location without altering it + end + B->>S: GET exact callback with handoff + site_state + S->>S: Match pending site_state for this browser + S->>C: Redeem GraphQL mutation through trusted Site runtime + C->>DB: Atomic redeem with site_id + api_id + principal_id + DB-->>C: Site-local credential + verified returnTo; handoff consumed + C-->>S: Site-local result + verified returnTo + S->>S: Consume site_state; set Site Cookie and/or deliver Site Bearer + S-->>B: 303 clean application-relative returnTo +``` + +For Provider completion, the callback HTTP response reuses the existing +auth-settings cookie helper to set the auth-center Cookie before issuing the +Site `303`. For Dashboard-mediated branches, the GraphQL mutation returns the +normal auth-center Bearer result and existing Cookie lifecycle plus one complete +server-built continuation URL. Dashboard must not receive decomposed callback, +handoff, group, or transaction internals or synthesize the target. + +The target Site redeems from its server. Constructive hashes the presented code +before the DB lookup. Trusted routing/runtime authentication must already have +placed `site_id`, `api_id`, and `principal_id` in the one Express Context and its +pgSettings. The DB function requires the transaction Site and the exact +`site_runtime_clients` tuple, verifies all Tenant/transaction/session bindings, +issues a distinct Site-local credential, and marks the handoff consumed in one +transaction. Constructive does not infer Site from API, accept Site identity in +the mutation input, or add an SSO-specific runtime secret. + +`Origin` and `Referer` may be compared with registered Site information as +auxiliary request checks, but they do not create Site authority and cannot +replace a missing or mismatched runtime tuple. + +Constructive cannot set another parent domain's Cookie. The Site response owns +its Cookie and frontend Bearer-storage behavior. Auth-center and Site-local +Bearer tokens are distinct even when they represent the same user. + +## Sessions, Cookies, CSRF, and Logout + +- Bearer remains preferred over `constructive_session` when both are present. +- Site APIs continue to support Bearer-only clients such as API, CLI, mobile, or + applications that deliberately manage their local credential. This does not + make browser origins automatically share storage; the browser SSO flow still + uses each origin's own first-party Cookie plus the one-time handoff. +- Auth-center and Site cookies contain their own existing local access-token + credential; no unified transaction, Provider token, handoff, or `site_state` + is stored as a session credential. +- The auth-center Cookie is `Secure`, `HttpOnly`, host-only, and uses an + appropriate `SameSite` policy with existing CSRF protection. Tenant auth + settings may narrow path/lifetime but cannot introduce a broad Domain or + weaken these minimums. +- Cookie-authenticated GraphQL mutations keep the existing CSRF token check; + Bearer-authenticated requests keep the existing CSRF exemption. +- OAuth callback GET is protected by consumed server-side OAuth state and PKCE, + not by treating GET as a state-changing CSRF exemption. +- Site callback GET is protected by Site `site_state`, exact callback binding, + the one-time handoff, and server-side redemption. It does not establish a + credential until redemption commits. +- DB session authentication owns the link from each Site-local session to its + Tenant-local unified session. Existing Constructive request authentication + therefore rejects a revoked bound session on the next protected request; the + server does not add a per-request HTTP call to the auth center. +- Current-browser logout revokes that auth-center session and its bound Site + sessions. Account switching performs that logout before authenticating the + new account. There is no Site notification endpoint or all-device operation. + +The current `AuthCookiePlugin` and cookie helper read some environment state and +catch some parse failures. The SSO PR must not copy those patterns. It should +move any new environment-dependent Cookie default into validated options or the +existing auth settings and make new-path failures explicit while keeping the +existing owner and response shape. + +## Error Contract + +All public codes are registered in `@constructive-io/errors`, use stable +`UPPER_SNAKE_CASE` business semantics, and contain no package, route, +middleware, schema, or internal-class names. The implementation reuses existing +codes where their semantics are exact and adds the minimum missing set, expected +to include: + +- `INVALID_SSO_SITE_STATE`; +- `INVALID_SSO_CALLBACK` and `INVALID_SSO_RETURN_TARGET`; +- `SSO_LOGIN_TRANSACTION_EXPIRED` and + `SSO_LOGIN_TRANSACTION_ALREADY_USED`; +- `OAUTH_SIGN_IN_DISABLED`; +- `INVALID_OAUTH_STATE` and `INVALID_OAUTH_PKCE`; +- `IDENTITY_PROVIDER_NOT_CONFIGURED` and an explicit disabled/unsupported + Provider classification where not already registered; +- `SSO_ACCOUNT_CONFLICT`; +- `INVALID_SSO_HANDOFF`, `SSO_HANDOFF_EXPIRED`, and + `SSO_HANDOFF_ALREADY_USED`. + +The implementation reconciles this list with the DB error inventory and +registers one canonical code per stable meaning; it does not create both server +and DB spellings for the same condition. + +Provider responses, tokens, authorization codes, verifier, raw state, handoff, +`site_state`, secrets, private callback lists, raw `returnTo`, and SQL details +never appear in public messages or error context. Logs may include safe request +ID, Tenant/API IDs, Provider key, Site ID, phase, and stable error code. + +Every caught error follows one of two paths: + +1. map a known external/transport/validation failure to a registered error and + log the original only in safe structured context; or +2. rethrow the original or a canonical error with its cause preserved. + +The current canonical error class does not expose a verified `cause` option. +Before wrapping unknown errors, the error-owner PR must add and test standard +`ErrorOptions.cause` support or rethrow the original. Logging and fallback do +not replace failure semantics. + +## Observability and URL Hygiene + +The shared request logger must stop logging raw `req.originalUrl` for sensitive +routes. Add an owned request-target sanitizer used for request start, finish, +early-close, error, and redirect logs. + +At minimum it redacts: + +- `state`, `code`, `error`, and `error_description` on OAuth authorize/callback; +- `handoff` and `site_state` on the Site callback integration contract; and +- any future query value classified as an approved one-time auth artifact. + +OAuth and handoff responses use `Cache-Control: no-store`; browser-visible auth +pages/callbacks use `Referrer-Policy: no-referrer`. Site callbacks redeem before +rendering or loading third-party resources and immediately leave the code-bearing +URL. Documentation and test fixtures use fake codes only. + +Useful metrics are counters/timers by safe phase and stable outcome: + +- login start success/failure; +- existing-auth silent/confirm/no-session decision; +- Provider start/callback result by Provider key; +- state/PKCE/transaction expiry and replay rejection; +- identity existing/provisioned/conflict outcome; +- handoff creation/redemption/expiry/replay; and +- Site credential issuance and unified-session rejection. + +Metrics never label on raw host input, email, user ID, token, state, code, +callback, or `returnTo`. + +## Network and Redirect Security + +- Provider authorization/token/user-info/JWKS endpoints come only from the + enabled Tenant Provider config and must pass the selected registered + adapter's exact HTTPS allowlist. +- Requests reject userinfo in URLs, fragments, loopback, private, link-local, + multicast, unspecified, and reserved destinations. DNS resolution and + redirect behavior cannot bypass the allowlist. +- Native fetch uses a bounded timeout and `redirect: 'error'`; response bodies + have a small maximum and expected content type/schema. +- Protected authorization parameters (`client_id`, redirect URI, response type, + scope, state, PKCE challenge/method, and nonce) cannot be overridden through + Provider `extraAuthorizationParams`. +- Provider callback URI is built from the current canonical Tenant auth origin + and fixed callback path, persisted with the OAuth request, and reused exactly + during code exchange. +- Site callback is always the exact active value stored in the unified + transaction and revalidated before handoff creation. `returnTo` remains an + application-relative path and is never concatenated into the callback URL. + +## Test Design + +### `packages/oauth` Unit Tests + +Reuse its existing Jest/ts-jest infrastructure and rewrite tests against the new +contract. Cover: + +- random state/verifier format and uniqueness without asserting implementation + UUIDs; +- RFC S256 vectors and verifier/challenge separation; +- protected authorization parameters; +- endpoint allowlist, unsafe IP/URL rejection, redirect rejection, timeout, + response-size/content-type/schema failures, and safe errors; +- Google ID-token signature/issuer/audience/expiry/nonce validation; +- GitHub token/profile/email normalization and optional-email behavior; +- no raw Provider payload or token in normalized output/errors/log fixtures; and +- adapter-registry selection for each concrete adapter under test, including + Google/GitHub examples, and an explicit unsupported result. + +### `packages/express-context` Tests + +Reuse existing loader tests. Add: + +- current-database SSO surface resolution; +- undefined when the current Tenant is not provisioned; +- no cross-database result/fallback; +- typed `useModule('ssoSurface')` behavior; +- explicit opt-in registration; and +- existing `identityProviders` secret rotation/cache behavior remaining intact. + +### `graphql/env` and `graphql/types` Tests + +Cover default-off behavior, config/env/runtime precedence, absent-env no-op, +boolean/number parsing, invalid timeout rejection, OAuth-disabled route/loader +behavior, and enabled startup validation. No tests mutate global `process.env` +when the existing injected-env API is available. + +### GraphQL and Server Integration + +Use `@constructive-io/graphql-server-test` with a real GraphQL server, routed +Tenant database, Express Context, DB functions, Cookie/CSRF behavior, and +SuperTest. Add the small typed OAuth-options forwarding needed by the helper; +do not copy the old PR's untyped `input.oauth` surface or build a parallel +Express/mock-context harness. + +Mock only the external Provider HTTP boundary with local per-suite fixtures for +the concrete adapters under test. Keep routing, context, DB, identity, sessions, +and callbacks real. The fixtures include the documented Google ID-token/JWKS and +GitHub profile/email examples together with authorization success/error, token +exchange, timeout, invalid response, and redirect attempts. Keep them local +until a second independent stable scenario proves a public helper is warranted. + +Required integration cases include: + +- unknown auth Host, wrong Tenant Site, and no routing fallback; +- SSO module absent/disabled and OAuth disabled; +- Site exact/default callback selection and `returnTo` rejection; +- `site_state` format/binding and callback echo; +- reusable auth silent/confirm decisions and SSO-group separation; +- local password single call/manual retry, registration immediate continuation, + and auth-center Bearer/Cookie behavior; +- Provider display filtering and no secret/private config exposure; +- state/transaction separation, ten-minute expiry, cross-Tenant mismatch, and + replay rejection; +- mandatory PKCE, Google nonce/ID-token validation, and GitHub profile flow; +- existing connected account, new provisioning, no-email metadata, and existing- + email conflict; +- Provider cancel/error safe restart; +- shared handoff convergence for every successful branch; +- one-minute handoff expiry, callback URL encoding, Site/runtime authentication, + atomic redemption, transient pre-consume retry, and replay rejection; +- authoritative `site_id` Context/pgSettings propagation, exact + `(site_id, api_id, principal_id)` authorization, two Sites sharing one API, + wrong/missing Site facts, principal substitution, and proof that + `Origin`/`Referer` cannot establish Site identity; +- auth-center versus Site-local credential separation; +- host-only auth-center Cookie, Bearer precedence, CSRF, no-store/no-referrer; +- raw query/token/Provider response absence from logs; and +- strict-auth/MFA/step-up fail-closed exclusion. + +Database/RLS/concurrency invariants stay in the Constructive DB owning tests. +Constructive integration tests assert public/HTTP/GraphQL seams rather than +querying private SSO or connected-account tables. CNC Playwright is not added in +this phase; the separate Site/Hub integration owns real cross-parent-domain +browser E2E later. + +## Package and File Change Inventory + +| Owner | Expected change | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/oauth` | Replace new-flow client/middleware API with primitives, adapter contract and registry, concrete adapters, endpoint/network safety, normalized types, tests, and README | +| `packages/errors` | Register missing stable SSO/OAuth errors; add tested `cause` support only if wrapping requires it | +| `packages/express-context` | Add typed opt-in `ssoSurface` loader; carry trusted routed `siteId`; forward `jwt.claims.site_id`; reuse existing `authSurface` and `identityProviders`; tests/docs | +| `graphql/types` | Add typed OAuth server options and honest defaults | +| `graphql/env` | Parse/merge/finally validate OAuth options with existing 12-factor helpers; tests/docs | +| `graphql/server` | Register loaders, preserve trusted Site/runtime routing facts, forward full context to Graphile, add SSO Graphile plugin/service, OAuth routes, Cookie integration, URL redaction, errors, and tests | +| `graphql/server-test` | Add small typed options forwarding and local Provider fixture support | +| Constructive DB dependency | Pin the DB commit that supplies the reviewed SSO surface metadata/functions before server integration merges | + +No change is planned for `pgpm/env`; no OAuth configuration belongs there. + +## Incremental PR Plan + +1. **Errors and typed server configuration** + - canonical codes/cause support if required; + - `graphql/types` OAuth options; + - `graphql/env` parsing/merge/validation/tests. +2. **OAuth package primitives and adapters** + - state/PKCE/network/endpoint helpers; + - adapter contract; + - concrete registered adapter implementations and unit tests, including the + documented Google and GitHub examples; + - no Express, DB, or Tenant orchestration. +3. **Express Context SSO surface** + - add the DB-metadata-backed `ssoSurface` loader and type; + - prove Tenant-scoped/absent-module behavior; + - preserve existing loader cache ownership. +4. **Unified-login GraphQL integration** + - Graphile plugin and Context forwarding; + - start, confirm, local password, registration, discovery; + - existing Cookie/CSRF integration and server tests. +5. **Provider HTTP integration** + - authorize/callback router; + - adapter orchestration, state/PKCE, identity resolution; + - safe failure page continuation and redacted observability. +6. **Shared handoff and Site redemption** + - server-generated handoff/hash; + - provider `303` and Dashboard continuation URL; + - target Site/runtime-authenticated redemption and Site-local result; + - unified-session revocation behavior. +7. **Full Constructive integration review** + - run real server/database flow matrix; + - remove any temporary debug code, legacy middleware wiring, fallback, or + duplicated test harness; + - freeze Constructive/DB pins for the later Site/Hub phase. + +Each PR is independently testable and must not include speculative compatibility +paths for a later slice. + +## Runtime Contract Convergence Across PR1–7 + +The temporary PR8 convergence branch is not part of the final stack. Its valid +owner-specific changes were folded back into PR1–7 so contract convergence does +not become a separate product or compatibility layer: + +1. The stack pins the combined Constructive DB runtime commit + `ffc87bb07ede49a0734f676d0eb06042f0565eef` and uses its generated application + and PostgreSQL functions in real integration tests. +2. PR3 owns canonical `runtime_site_id` routing, Express Context `siteId`, and + `jwt.claims.site_id` forwarding beside `api_id` and `principal_id`. +3. PR6 owns Site-runtime-authenticated handoff redemption using the exact + DB-owned `site_runtime_clients` tuple; public redemption input remains the + handoff proof rather than a caller-selected Site. +4. PR7 owns the real-runtime integration fixture and end-to-end server contract. + The fixture uses the current `capabilities_module`; the former + `graphql/server-test/__fixtures__/seed/oauth-sso/contract.sql` simulation is + removed and must not return. +5. The resulting integration path is generated Constructive DB → PostgreSQL → + Constructive GraphQL/OAuth HTTP, with only the external Provider boundary + mocked. + +## Implementation Evidence Gates + +These are repository-integration checks, not product decisions: + +1. The pinned Constructive DB runtime exposes authoritative current-Tenant SSO + surface metadata and the reviewed function contracts consumed by + `ssoSurfaceLoader`. +2. The final stack demonstrates trusted Site/runtime identity from canonical + routing through Express Context and pgSettings. It does not infer Site from + API, invent an SSO secret, accept a caller Site ID, or reduce redemption to + possession-only. +3. Google ID-token verification must use a maintained dependency and validated + JWKS/discovery inputs. A handwritten JWT verifier is not acceptable. +4. Any removal of current `packages/oauth` public exports follows repository + package-release policy, but the new runtime never dual-runs or falls back to + the legacy path. + +## Acceptance Criteria + +The Constructive implementation is complete when: + +1. every auth request resolves the Tenant through canonical routing and uses + one full Express Context; every Site-originated security operation also + carries trusted `site_id`, `api_id`, and `principal_id` through Context and + pgSettings and is authorized by the exact `site_runtime_clients` tuple; +2. auth-center Cookies and state are Tenant-host-local and cannot cross Tenants; +3. `site_state` binds the initiating Site browser to the exact callback and is + validated before handoff redemption; +4. OAuth is default-off, fully owned by `graphql/env` options, and reads no env + value in middleware/adapters; +5. every registered Provider uses one adapter contract with mandatory state, + S256 PKCE, safe endpoints, bounded network calls, and server-only tokens; +6. local password/registration and Provider identity reuse the existing DB + primitives without parallel stores or schema inference; +7. all successful branches converge on one one-minute handoff, exact GET Site + callback, atomic redemption, distinct Site credential, and verified + application-relative `returnTo`; +8. no reusable credential, Provider token, verifier, identity, transaction ID, + callback list, or raw `returnTo` crosses a URL or public DTO; +9. errors are registered and no caught exception is swallowed; +10. logs, metrics, traces, and non-protocol/error responses omit Provider + authorization codes, OAuth state, handoff, `site_state`, secrets, and raw + Provider data; the approved authorize/callback redirects carry only their + explicitly required one-time values; +11. package, Context, env, and real-server integration tests pass at their owning + layers; and +12. no legacy `/auth/providers`, shared Cookie, in-process replay map, manual + Tenant/schema inference, secret fallback, DB-version fallback, or temporary + debug path remains in the implementation. diff --git a/docs/sso/spec/oauth-sso-platform-integration-dashboard-design.md b/docs/sso/spec/oauth-sso-platform-integration-dashboard-design.md new file mode 100644 index 0000000000..0c68406e95 --- /dev/null +++ b/docs/sso/spec/oauth-sso-platform-integration-dashboard-design.md @@ -0,0 +1,748 @@ +# OAuth/SSO Platform Integration — Dashboard Unified Authentication UI Detailed Design + +## Document Status + +This document is the implementation-oriented design for the unified authentication UI hosted by Dashboard. It derives product behavior from [OAuth and Cross-Domain SSO Requirements](../plan/oauth-sso-platform-integration.md), protocol and trust boundaries from [OAuth/SSO Platform Integration Technical Specification](oauth-sso-platform-integration.md), and server contracts from [Constructive Detailed Design](oauth-sso-platform-integration-constructive-design.md). + +The verified Dashboard code baseline is remote `main` commit `892c85e491ff8acfe8a267c3bb3487b2488223c6` from 2026-08-06. Current implementation evidence is Dashboard commit `a6611fbcab677e91c431f275bd02930b39828421` against Constructive stack head `87061435f59a1410b19161c2ae2365cc0891da5d` and Constructive DB runtime `ffc87bb07ede49a0734f676d0eb06042f0565eef`. Any later contract change must freeze the replacement SHAs and regenerate the typed GraphQL artifacts before compatibility conclusions are drawn. + +This design distinguishes confirmed cross-system behavior from three remaining low-level product presentation choices in [Open Presentation Decisions](#open-presentation-decisions). Those choices do not change the trust, transaction, Provider-discovery, or handoff architecture. + +## Scope + +This design covers: + +- the Dashboard-hosted unified authentication entry; +- isolation from Dashboard management features and management authentication; +- start-login orchestration and the silent, confirm, and authentication-required decisions; +- reuse of the existing Tenant/database authentication presentation components; +- local email/password sign-in, registration, and password recovery presentation; +- configuration-driven external Identity Provider buttons; +- authentication-center Bearer/Cookie handling; +- safe navigation to Constructive Provider authorization and Site continuations; +- account confirmation, cancellation, and switching; +- branding, error presentation, accessibility, responsive behavior, testing, and rollout. + +## Non-Goals + +- Site/callback, Tenant, database, SSO-group, or `returnTo` validation in Dashboard. +- OAuth state, PKCE, Provider endpoint, token, callback, or identity-association logic. +- Handoff construction, inspection, redemption, or Site-local credential issuance. +- Site administration, Provider administration, or other Dashboard management UI. +- A fixed frontend Provider allowlist or Provider-specific top-level page workflow. +- Transaction recovery, polling, refresh restoration, or cross-tab transaction sharing. +- Cross-parent-domain Cookie sharing, third-party Cookie dependence, or browser SSO when all first-party Cookies are disabled. +- Strict-auth, local MFA, or step-up integration. +- New Dashboard Playwright infrastructure or Hub cross-host E2E in the Dashboard PR. + +## Confirmed UI Boundaries + +1. Dashboard is the presentation host. Constructive owns every security decision and server-side state transition. +2. The unified authentication capability is independent of Dashboard organization, database-management, schema-builder, chatbot, and administration features. +3. The browser enters on the current Tenant's canonical authentication origin. Dashboard never accepts or selects a Tenant/database independently of that routed Host. +4. Dashboard sends the untrusted Site entry inputs to start-login exactly once for the active page flow. Constructive returns the trusted safe display and decision context. +5. The opaque unified login transaction ID remains in page memory and is supplied only to Constructive operations during the active flow. It is never written to a URL or persistent browser storage. +6. Existing unified authentication follows the server's effective silent or confirm decision. Dashboard cannot request silent mode. +7. Local sign-in, local registration, reused authentication, and every external Provider converge on the same server-owned continuation contract. +8. Registration success immediately establishes authentication-center state and continues the Site login flow without an email-verification gate. +9. Provider availability is not fixed by Dashboard or a release-specific frontend list. Dashboard renders the ordered safe Provider options returned for the current Tenant. +10. A successful Dashboard-mediated branch receives one complete server-built continuation URL and assigns browser location without parsing, rebuilding, or appending values. +11. Provider callback success navigates from Constructive directly to the exact Site callback; Dashboard is not inserted into that success response. +12. An interrupted, refreshed, expired, or failed Provider flow is not resumed. The user restarts from the Site login entry. + +## Verified Dashboard Baseline and Required Evolution + +| Area | Current Dashboard baseline | Unified-auth decision | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Tenant login container | `components/auth/auth-embedded.tsx` switches among login, registration, password recovery, and reset and selects Tenant/database forms | Reuse its presentation pattern, not its management/database orchestration | +| Form views | `LoginFormView`, `RegisterFormView`, `ForgotPasswordFormView`, and `ResetPasswordFormView` own accessible fields, validation, loading, and inline errors | Reuse or narrowly extend these views through callbacks and composition | +| Tenant login controllers | `DashboardLoginForm` and related wrappers call per-database hooks and redirect to `/db/:id/data` | Replace with unified-auth controllers that carry the active transaction and consume server continuation results | +| Registration behavior | `useRegisterDashboard` signs up directly without the schema-builder email-verification redirect | Preserve the direct-authentication behavior through the new SSO registration operation; do not reuse the schema-builder controller | +| Management registration | `useRegisterSb` checks membership verification, sends verification email, and redirects to `check-email` | Do not use this controller in unified authentication | +| Routes | `/login`, `/register`, and recovery routes are management `guest-only` routes | Add public auth-center routes that remain usable with an existing unified session and never redirect merely because the user is authenticated | +| Root runtime | Root layout mounts management authentication, schema-builder data, shell, stack, and chatbot providers | Give auth-center routes a minimal runtime that does not require or initialize management capabilities | +| Token storage | `TokenManager` supports `schema-builder` and database-scoped `dashboard` keys with local/session storage and cross-tab invalidation | Reuse/generalize its storage lifecycle under an explicit auth-center namespace; never reuse a management or Site token slot | +| GraphQL clients | Existing generated auth SDK targets management/control-plane auth while dynamic database hooks use `executeInContext` | Generate or use the correct Tenant-auth GraphQL target after the Constructive schema freezes; do not hand-maintain a parallel untyped API | +| Error UI | `parseGraphQLError` maps known canonical codes and hides technical network messages | Extend the owned mapping for canonical SSO/OAuth codes; never display raw Provider or internal errors | +| Branding | Existing auth screen components accept a logo and application name | Render the safe Site display context and constrained theme returned by start-login | +| Tests | Vitest, Testing Library, Storybook, and Storybook a11y are present; Dashboard has no owned Playwright suite | Use existing unit/component/story infrastructure; leave real cross-host browser E2E to the owning Hub/Site integration | +| Localization | Application auth copy is currently English and no app-level translation framework is present | Keep copy centralized; the initial language choice remains a presentation decision rather than an ad hoc new i18n framework | + +## Component and Trust Map + +```mermaid +flowchart LR + Site["Registered Site"] -->|"Top-level navigation with untrusted Site inputs"| Page["Dashboard unified-auth page"] + Page --> Controller["UnifiedAuthController"] + Controller --> Client["Typed same-origin Constructive client"] + Client --> Service["Constructive SSO GraphQL/HTTP service"] + Service --> Context["Authoritative Express Context"] + Service --> DB["Constructive DB SSO functions"] + Service --> Provider["Registered Provider adapter"] + Controller --> Views["Reused auth form views"] + Controller --> Browser["Browser top-level navigation"] + Browser --> Provider + Browser -->|"Server-built continuation"| Site +``` + +Dashboard owns only: + +- rendering safe Site/account/Provider context; +- collecting local credentials and explicit user choices; +- keeping ephemeral page state; +- invoking typed Constructive operations; and +- performing approved top-level navigation. + +Dashboard never owns: + +- trust in a Site ID, callback, Host, `returnTo`, or `site_state`; +- the effective sign-in mode or reusable-session decision; +- Provider support, endpoint, secret, state, PKCE, or token processing; +- identity matching/provisioning; +- handoff contents or Site-local credential creation. + +## Route and Runtime Isolation + +### Public Route Surface + +The Dashboard PR should establish these implementation routes unless the frozen deployment contract selects equivalent names before coding: + +| Route | Purpose | Access | +| ---------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------- | +| `/auth/login` | Unified login entry and active-flow UI | Public, with or without an existing auth-center session | +| `/auth/error` | Safe terminal failure from Constructive or local start failure | Public; accepts only a stable safe error code | +| `/auth/reset-password` | Existing password-reset completion outside the active login transaction | Public | + +The management routes `/login`, `/register`, `/forgot-password`, `/reset-password`, `/check-email`, and `/verify-email` retain their existing behavior. Unified authentication does not overload their route guards or controllers. + +### Minimal Auth-Center Runtime + +The auth-center route group must render without: + +- `AuthenticatedShell`; +- organization/database/schema-builder data providers; +- Dashboard database selection; +- chatbot, command palette, management stack, or management navigation; +- management route guards; +- Direct Connect or user-configurable endpoint overrides; or +- management-token injection. + +The common root may retain fonts, validated runtime bootstrapping, theme primitives, a query client, and the portal/toast primitives actually required by the auth UI. Management-only providers move behind a management route boundary or are lazily selected so visiting `/auth/*` does not initialize or depend on them. + +The implementing PR should isolate any mechanical route-group/layout movement from behavioral auth changes so the human review remains readable. + +### Same-Origin Constructive Client + +Auth-center operations use the current Tenant authentication origin's Constructive GraphQL/HTTP surface. The client: + +- uses a relative or otherwise server-approved same-origin endpoint; +- sends first-party Cookies through the existing request mode; +- adds only the auth-center Bearer token when one exists; +- never accepts a UI endpoint override; +- never sends a schema-builder, per-database Dashboard, or Site-local token; +- uses the generated typed operation surface selected after the Constructive schema freezes; and +- disables automatic mutation retry for transaction-changing operations. + +No new Dashboard environment variable is required merely to select Tenant, database, Site, Provider, callback, or redirect behavior. Those values come from routed server context and safe API results, not browser configuration. + +## Entry Input and URL Lifecycle + +The Site navigates the browser to `/auth/login` with the logical entry values already defined by the formal Spec: + +- Site identifier; +- optional exact callback; +- optional Site-internal application-relative `returnTo`; and +- Site-generated `site_state`. + +These query values are untrusted start inputs, not Dashboard configuration. The page: + +1. parses only the expected scalar values and rejects duplicate/structurally invalid query shapes locally; +2. invokes start-login once without inferring Tenant, callback trust, or `returnTo` safety; +3. performs no automatic retry after an ambiguous transport failure; +4. after a successful start response, replaces the visible URL with the clean `/auth/login` route; and +5. keeps only the returned opaque transaction ID and safe display/decision context in memory. + +The initial document and route use `Referrer-Policy: no-referrer` and no-store behavior. Dashboard access, analytics, and error logging redact the callback, `returnTo`, `site_state`, transaction ID, and any future approved one-time auth artifact. + +If page refresh or browser history loses the in-memory transaction, Dashboard does not call a transaction-status query. It presents the safe restart instruction and directs the user to begin again from the Site. + +## Typed Client Contract + +The exact GraphQL field and generated type names follow the frozen Constructive schema. Dashboard consumes the following logical discriminated results rather than private DB rows. + +### Start Result + +```ts +type SafeSiteDisplay = { + displayName: string; + verifiedHost: string; + iconRef?: string | null; + accentColor?: string | null; +}; + +type SafeAccountDisplay = { + displayName: string; + avatarRef?: string | null; +}; + +type ProviderDisplayOption = { + key: string; + displayName: string; + iconKey?: string | null; +}; + +type UnifiedAuthStartResult = + | { + decision: 'SILENT_CONTINUE'; + site: SafeSiteDisplay; + continuationUrl: string; + } + | { + decision: 'CONFIRM_ACCOUNT'; + transactionId: string; + site: SafeSiteDisplay; + account: SafeAccountDisplay; + } + | { + decision: 'AUTHENTICATION_REQUIRED'; + transactionId: string; + site: SafeSiteDisplay; + providers: ProviderDisplayOption[]; + }; +``` + +This is a Dashboard consumer contract, not a requirement that the GraphQL schema use TypeScript union names verbatim. It expresses these invariants: + +- silent completion gives Dashboard one opaque navigation target, not handoff parts; +- account confirmation gets only safe identity display data; +- authentication-required gets the active transaction and the ordered Provider options; +- callback, SSO group, database, secret, endpoint, raw `returnTo`, and handoff internals are absent. + +### Action Results + +Logical active-flow actions are: + +| Action | Input | Safe result | +| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Confirm current account | Transaction ID | Complete server-built continuation URL | +| Cancel | Transaction ID | Complete trusted Site cancellation URL or terminal safe result selected by Constructive | +| Switch account | Transaction ID | Authentication-required state after current-browser unified logout, plus fresh safe Provider options | +| Local sign-in | Transaction ID plus existing local credentials | Normal auth-center credential result plus complete continuation URL | +| Local registration | Transaction ID plus existing registration input | Normal auth-center credential result plus complete continuation URL | +| Start Provider | Transaction ID plus Provider key | Same-origin Constructive authorization-initiation URL only | + +Dashboard never accepts decomposed callback, handoff, `site_state`, SSO group, or `returnTo` fields in an action result. + +## Page State Machine + +Use one reducer or equivalent explicit state machine rather than a collection of unrelated booleans. + +```ts +type UnifiedAuthViewState = + | { status: 'starting' } + | { status: 'silent-continuing'; site: SafeSiteDisplay } + | { + status: 'confirming'; + transactionId: string; + site: SafeSiteDisplay; + account: SafeAccountDisplay; + } + | { + status: 'sign-in'; + transactionId: string; + site: SafeSiteDisplay; + providers: ProviderDisplayOption[]; + } + | { + status: 'register'; + transactionId: string; + site: SafeSiteDisplay; + providers: ProviderDisplayOption[]; + } + | { status: 'forgot-password'; site: SafeSiteDisplay } + | { status: 'recovery-sent'; site: SafeSiteDisplay } + | { + status: 'provider-starting'; + transactionId: string; + site: SafeSiteDisplay; + selectedProvider: string; + } + | { status: 'continuing'; site: SafeSiteDisplay } + | { status: 'terminal-error'; errorCode?: string }; +``` + +```mermaid +stateDiagram-v2 + [*] --> Starting + Starting --> SilentContinuing: silent continuation + Starting --> Confirming: existing account requires confirmation + Starting --> SignIn: authentication required + Starting --> TerminalError: invalid or failed start + SilentContinuing --> Navigating + Confirming --> Navigating: continue or trusted cancel + Confirming --> SignIn: switch account completes logout + SignIn --> Register: choose registration + Register --> SignIn: choose sign-in + SignIn --> ForgotPassword: choose recovery + ForgotPassword --> RecoverySent: request accepted + SignIn --> ProviderStarting: choose returned Provider + ProviderStarting --> ProviderNavigation + SignIn --> Navigating: local sign-in succeeds + Register --> Navigating: registration succeeds + SignIn --> SignIn: safe credential failure and manual retry + TerminalError --> [*] + Navigating --> [*] + ProviderNavigation --> [*] +``` + +State rules: + +- at most one mutation is active at a time; +- the initiating button alone shows the relevant spinner when possible; +- duplicate submission is disabled until the operation settles; +- local credential failure returns to the same form and permits manual retry while the transaction remains active; +- transaction, boundary, and Provider failures do not silently fall back to another branch; +- no state transition reconstructs a trusted URL or infers a Provider list. + +## Main UI Flows + +### Start, Silent, and Confirm + +```mermaid +sequenceDiagram + actor U as User + participant B as Browser + participant D as Dashboard auth-center page + participant C as Typed Constructive client + + B->>D: Load /auth/login with Site entry inputs + D->>C: Start-login once + alt Invalid input, untrusted Site/callback, or failed start + C-->>D: Stable safe failure + D-->>U: Generic failure and restart guidance + else Silent continuation + C-->>D: Safe Site display + complete continuation URL + D->>B: Replace location unchanged + else Confirm current account + C-->>D: Transaction ID + safe Site/account display + D-->>U: Continue, switch account, or cancel + alt Continue + D->>C: Confirm with transaction ID + C-->>D: Complete continuation URL + D->>B: Replace location unchanged + else Switch account + D->>C: Switch with transaction ID + C-->>D: Current-browser logout + authentication-required state + D-->>U: Show local and returned Provider choices + else Cancel + D->>C: Cancel with transaction ID + C-->>D: Complete trusted cancellation continuation + D->>B: Replace location unchanged + end + else Authentication required + C-->>D: Transaction ID + safe Site display + ordered Provider options + D-->>U: Show Provider choices and local account form + end +``` + +The initial loading state is neutral and does not flash a credential form before Constructive returns the decision. Silent mode does not show the account confirmation card. It still executes the same start validation and shared completion path. + +### Local Sign-In and Registration + +```mermaid +sequenceDiagram + actor U as User + participant D as Dashboard auth-center page + participant C as Typed Constructive client + participant T as Auth-center token storage + participant B as Browser + + U->>D: Submit local credentials or registration + D->>C: SSO operation with active transaction + alt Safe credential or validation failure + C-->>D: Canonical safe error + D-->>U: Inline message; manual correction/resubmit + else Success + C-->>D: Auth-center Bearer outcome + complete continuation URL + D->>T: Store auth-center Bearer according to persistence choice + Note over C,B: Existing first-party HttpOnly Cookie behavior is owned by Constructive response + D->>B: Assign complete continuation URL unchanged + end +``` + +The reused form views keep current field semantics, password-manager-friendly autocomplete, validation, and loading behavior. The new controllers: + +- call SSO-specific typed operations rather than `useLoginDashboard`, `useRegisterDashboard`, or `useRegisterSb`; +- pass the active transaction ID only to Constructive; +- do not redirect to `/db/:id/data`, the management home, or `check-email`; +- preserve the existing safe local-password error outcome; +- perform no automatic password retry; and +- treat successful registration as authenticated completion immediately. + +### Password Recovery + +Password recovery reuses the existing Tenant-local recovery operation and form presentation, but it does not resume the active unified login transaction. + +- Entering recovery abandons the UI's active transaction reference. +- The request result remains enumeration-safe. +- The email opens `/auth/reset-password` with the existing reset proof expected by Constructive. +- The reset page does not accept or restore a unified transaction ID. +- After successful reset, Dashboard tells the user to return to the originating Site and start login again. +- Dashboard does not create a convenience continuation containing the old Site, callback, or `returnTo`. + +### External Provider Start + +```mermaid +sequenceDiagram + actor U as User + participant D as Dashboard auth-center page + participant C as Typed Constructive client + participant B as Browser + participant A as Constructive OAuth route + participant P as Configured external Provider + + U->>D: Choose a returned Provider option + D->>C: Provider-start with transaction ID + Provider key + C-->>D: Same-origin authorization URL + D->>B: Top-level navigation to returned URL + B->>A: Authorization initiation with opaque OAuth state + A-->>B: Redirect to selected registered Provider adapter flow + B->>P: Provider authorization + P-->>A: Callback with code/error + OAuth state + alt Provider success + A-->>B: Direct Site continuation; Dashboard not re-entered + else Safe Provider failure + A-->>B: Dashboard /auth/error with stable safe code only + B->>D: Render failure and restart guidance + end +``` + +Dashboard never: + +- sends the unified transaction ID to a Provider; +- constructs an OAuth authorize URL; +- handles Provider authorization code, state, verifier, token, or profile data; +- opens a Provider popup or attempts cross-window state synchronization; or +- resumes a failed Provider branch. + +## Provider Discovery and Button Design + +### Availability Contract + +Provider options are returned by Constructive after combining: + +1. server OAuth enablement; +2. adapters registered in the running server; +3. the current Tenant database's enabled Provider rows; +4. complete validated Provider configuration and secret resolution; and +5. the mandatory adapter security policy. + +The Dashboard page has no Google/GitHub/Facebook/LinkedIn or other availability constant. Google and GitHub may appear in adapter-specific tests and assets, but they do not define a v1 UI allowlist. A synthetic supported Provider used in a Dashboard test must render without changing the page controller. + +The current `identityProviders` loader already exposes a Tenant-configured `displayName`; it does not expose an icon or display-order field. The public DTO therefore uses: + +- `key` from the selected registered Provider identity; +- `displayName` from safe Tenant configuration, falling back server-side to a safe registry name; +- optional `iconKey` from approved server registry metadata, never arbitrary HTML/SVG or an unvalidated remote asset URL; and +- array order selected deterministically by Constructive. + +Dashboard renders the returned order exactly and does not sort by a hard-coded Provider priority. Until a separately confirmed Tenant-controlled order exists, Constructive may use stable `displayName` then `key` ordering without adding a DB field. + +### Button Presentation + +When one or more Provider options exist: + +- render one full-width, neutral outline button per returned option above the local email/password form; +- use `Continue with {displayName}` as accessible visible text; +- render the approved local icon for `iconKey`, with a generic identity icon fallback; +- never hide or disable a valid unknown key merely because Dashboard lacks a branded icon; +- keep Provider buttons in a vertical list so labels remain readable and every option stays visible; +- render an `or continue with email` separator before the local form; +- show progress only on the selected Provider button and block duplicate starts; and +- submit only the opaque transaction ID and selected Provider key. + +When the Provider array is empty, omit both the Provider region and its separator. The Constructive account/password, registration, and recovery options remain present. + +Tenant branding and Provider branding are separate. The Site accent color may style the primary local action and focus token, but it does not recolor Provider logos or turn database text into trusted Provider artwork. + +## Tenant Login UI Reuse + +### Reuse Directly or with Narrow Props + +- `AuthScreenLayout` for the responsive card and legal footer. +- `AuthScreenHeader` for constrained Site/Constructive branding. +- `LoginFormView` for email/password, remember-me presentation, password-manager attributes, validation, and inline errors. +- `RegisterFormView` for email/password confirmation and existing validation. +- `ForgotPasswordFormView` and `ResetPasswordFormView` for recovery presentation. +- `AuthErrorAlert`, `AuthLoadingButton`, `FormField`, and `PasswordStrength`. + +### Extract or Add + +- `UnifiedAuthController` for start and active transaction state. +- `UnifiedAuthProviderButtons` for configuration-driven Provider actions. +- `UnifiedAuthConfirmAccount` for safe account display and continue/switch/cancel. +- `UnifiedAuthSafeError` for terminal restart-only failures. +- `UnifiedAuthBranding` for safe Site name, verified host, icon, accent token, and fixed Constructive identity. +- typed unified-auth hooks and a small navigation helper that accepts only a complete server result. +- a pure reducer and canonical error-to-view mapper. + +### Do Not Reuse + +- `AuthEmbedded` database selection, `useDashboardContext`, or service-error UI; +- `DashboardLoginForm` redirect behavior; +- `useLoginDashboard` or database-scoped token selection; +- `useRegisterSb` email-verification redirect; +- management `RouteGuard` guest-only behavior; +- Direct Connect endpoint overrides; or +- any existing controller that chooses a database or post-login management route. + +## Branding and Visual Rules + +The start result supplies only safe public branding. The page displays: + +- the Site display name; +- the verified registered Site hostname; +- an optional platform-approved Site icon; +- an optional validated accent color; and +- a fixed, low-key Constructive identity. + +The account confirmation view displays only the safe account name and optional avatar with Continue, Switch account, and Cancel. It does not display permissions, scopes, callback URLs, database names, Provider secrets, or technical transaction facts. + +Dashboard does not render arbitrary Tenant HTML, Markdown, CSS, fonts, scripts, data URLs, inline SVG, or unapproved remote branding URLs. Missing or invalid display metadata falls back to Constructive defaults without changing the authentication decision. + +The accent color is applied through a narrow CSS custom property or theme token with contrast enforcement. It cannot replace destructive/error colors or weaken focus visibility. + +## Authentication-Center Credential Handling + +The auth-center Host is a separate first-party origin and therefore a separate browser credential boundary. Dashboard should reuse and generalize the current Token Manager lifecycle under an explicit auth-center namespace, for example: + +```text +constructive-auth-token:auth-center +constructive-remember-me:auth-center +``` + +The Tenant-scoped Host already isolates these keys by browser origin; the key must not contain a browser-selected Tenant ID. + +Rules: + +- Constructive owns the host-only `Secure`, `HttpOnly` first-party Cookie. +- Dashboard may store the returned auth-center Bearer in localStorage or sessionStorage according to the confirmed persistence choice. +- The GraphQL client sends Bearer when available while retaining first-party Cookie behavior; Constructive preserves Bearer precedence. +- Auth-center logout and switch clear the auth-center Bearer only. They do not delete management or arbitrary Site storage. +- Transaction IDs, Provider data, handoff, callback, `site_state`, and `returnTo` never enter token storage. +- Storage logging never includes the Bearer value. +- If browser token storage is unavailable but Constructive confirms the first-party Cookie/session outcome, the current browser flow may continue using the Cookie. If neither local credential mode is usable, the UI fails explicitly rather than pretending persistent SSO exists. + +Cross-tab token invalidation may reuse the current storage-event pattern. Active transaction state remains tab-local and is never synchronized. + +## Navigation Contract + +One helper owns successful Dashboard navigation: + +```ts +function navigateToServerContinuation(url: string): void; +``` + +It accepts only a URL field from a successful typed Constructive result and performs top-level location assignment. It does not: + +- parse query parameters; +- inspect the handoff; +- append Site or `returnTo` values; +- normalize or resolve a relative target against browser input; +- log the URL; or +- send it through client-side Next.js routing or prefetch. + +Provider start uses the same pattern with the returned same-origin Constructive authorization URL. The typed response category, not URL string inspection, determines which helper call is valid. + +## Error Presentation + +Extend the existing owned auth-error mapping rather than creating a separate error system. The mapper consumes stable canonical error codes and produces a safe UI category: + +| Category | Examples | Dashboard behavior | +| ---------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| Correctable local input | Invalid credentials, password policy, existing account | Inline form message; retain non-secret fields and allow deliberate resubmit | +| Transaction/boundary failure | Invalid Site/callback/`site_state`, expired or used transaction | Terminal generic page; do not navigate to an untrusted target | +| Provider failure | Cancel, invalid OAuth state/PKCE, verification, configuration, account conflict | Safe auth-center failure; tell user to restart from Site | +| Network uncertainty | Start/action response not known | No automatic mutation retry; show restart guidance | +| Internal/unknown | Unregistered or technical failure | Generic failure plus safe correlation/request ID when supplied | + +Raw GraphQL messages, Provider `error_description`, URLs, callback values, email ownership details, SQL, stack traces, and tokens never appear in the page. Unknown codes fall back to the generic internal message; they are not displayed verbatim. + +After reading a stable safe error code from `/auth/error`, the page replaces the URL with the clean error route. The failure page has no same-transaction Retry button. It may offer only a neutral instruction to return to the Site and restart. + +## Security and Privacy + +- Auth-center routes use `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. +- CSP forbids unapproved script, frame, and asset origins; the login page cannot be embedded by an arbitrary Site. +- No third-party analytics, support widget, chatbot, or marketing pixel runs on auth-center routes. +- Site and Provider labels render as React text, never HTML. +- Password fields use correct `autocomplete` values and never copy credentials into React Query cache, logs, analytics, or persistent application state. +- Mutations have automatic retry disabled. React Strict Mode and remount behavior must not issue duplicate start mutations; one guarded start invocation owns the mounted entry. +- Continuation and Provider authorization use top-level browser navigation, not popups, iframes, fragments, or cross-window messages. +- The page does not inspect or persist the handoff code because Dashboard-mediated completion receives only the opaque full continuation URL. +- All sensitive query names and values are redacted from Dashboard request, access, APM, client logger, and error-reporting paths. +- The auth-center endpoint cannot be overridden through persisted Dashboard settings or Direct Connect. + +## Accessibility and Responsive Behavior + +- Use semantic headings, labels, forms, and buttons; do not express state only through icons or color. +- Move focus to the new screen heading or first invalid field after a state transition. +- Announce inline and terminal errors through the existing accessible alert pattern. +- Preserve keyboard submission and navigation for every Provider and local action. +- Provider buttons always expose their complete display name to assistive technology. +- Loading indicators have accessible names and buttons retain stable dimensions. +- Respect reduced-motion preferences; transitions never delay navigation or authentication. +- The card remains usable at narrow mobile widths, large text zoom, and keyboard viewport sizes. +- Site icon/avatar failures use silent visual fallbacks without changing text identity. +- Theme accents must pass contrast checks against both supported Dashboard themes. + +## Test Design + +### Pure State and Mapping Tests + +Use Vitest for: + +- start-result discriminated-state transitions; +- transaction ID never entering serializable/persisted state; +- safe error-code mapping and unknown-code fallback; +- Provider option rendering data independent of Provider names; +- complete continuation navigation accepting only typed success results; +- no automatic retry decisions; and +- URL cleanup/redaction helpers. + +### Component and Hook Tests + +Use Testing Library with the existing Dashboard test setup. Mock the typed Constructive transport, not Provider protocols or private DB state. Cover: + +- neutral starting state with no credential-form flash; +- silent continuation; +- confirm, continue, cancel, and switch-account behavior; +- dynamic zero/one/many Provider arrays; +- a fabricated registered Provider key with no branded icon, proving no frontend allowlist; +- server-provided Provider ordering and generic icon fallback; +- selected-Provider loading and duplicate-submit prevention; +- local password safe failure/manual retry and success continuation; +- registration success without check-email navigation; +- recovery abandoning the transaction and reset requiring a fresh Site start; +- auth-center token namespace separation from schema-builder and database Dashboard tokens; +- no management provider, shell, chatbot, or endpoint override on `/auth/*`; +- terminal transaction/Provider failure with no same-flow resume; and +- sensitive values absent from rendered output and logger mocks. + +### Storybook and Accessibility + +Add stories for: + +- starting; +- no Providers; +- several configuration-driven Providers; +- unknown icon fallback; +- account confirmation with and without avatar; +- local sign-in/registration/recovery; +- inline local error; +- terminal safe error; and +- long names, narrow viewport, dark/light theme, and reduced motion. + +Run the existing Storybook a11y addon on these stories. Do not create Provider-specific stories as the only coverage of the generic list. + +### Browser and Cross-System Coverage + +The Dashboard PR does not introduce a standalone OAuth simulator or Playwright framework. Constructive server integration tests own GraphQL/HTTP/Cookie/redirect behavior with external Providers mocked. The later Hub/Site integration owns real cross-parent-domain browser navigation, exact callback, handoff redemption, Site Cookie, account switching, and shared-session rejection. + +Dashboard acceptance at that layer is observable only through its public seams: + +- correct start/action calls; +- correct first-party credential behavior; +- top-level navigation to the exact server result; and +- no token, transaction, handoff, or raw Provider data exposed by the UI. + +## Proposed File and Package Inventory + +The actual PR reconciles these paths with the frozen Dashboard tree and avoids editing generated SDK files by hand. + +| Owner | Expected change | +| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `apps/admin/src/app/(auth-center)/auth/login/page.tsx` | Public unified-auth entry page | +| `apps/admin/src/app/(auth-center)/auth/error/page.tsx` | Stable safe failure page | +| `apps/admin/src/app/(auth-center)/auth/reset-password/page.tsx` | Existing recovery completion in auth-center runtime | +| auth-center and management layouts | Minimal auth runtime and isolation from management providers/shell | +| `apps/admin/src/app-routes.ts` | Public auth-center route declarations with shell bypass | +| `apps/admin/src/components/auth/unified/` | Controller, Provider buttons, confirmation, branding, and safe-error views | +| existing `apps/admin/src/components/auth/*-form-view.tsx` | Narrow compositional props only where existing views cannot be reused unchanged | +| `apps/admin/src/lib/gql/hooks/auth/unified/` | Typed start, confirm, cancel, switch, local, registration, recovery, and Provider-start hooks | +| generated auth SDK target | Regenerated from the frozen Constructive schema; no manual generated edits | +| `apps/admin/src/lib/auth/token-manager.ts` and auth store selectors | Reuse/generalize the lifecycle for explicit auth-center credential namespace | +| `apps/admin/src/lib/auth/auth-errors.ts` | Canonical SSO/OAuth code-to-copy mappings through the existing parser | +| `apps/admin/src/stories/auth/` | Unified-auth state stories and a11y cases | +| colocated `__tests__` | Reducer, hook, component, route isolation, token separation, and navigation tests | + +No OAuth environment parser, Provider endpoint configuration, transaction cache, callback parser, or handoff utility belongs in Dashboard. + +## Incremental PR Plan + +1. **Route/runtime isolation and reusable presentation** + - minimal auth-center layout; + - management route behavior unchanged; + - controller reducer and mocked state stories; + - focused route-isolation tests. +2. **Typed start and existing-authentication decisions** + - generated client integration after Constructive schema pin; + - start input lifecycle and URL cleanup; + - silent/confirm/authentication-required states; + - branding and canonical errors. +3. **Local account and credential integration** + - SSO local sign-in and registration hooks; + - auth-center token namespace and existing Cookie behavior; + - password recovery/reset separation; + - local form and storage tests. +4. **Configuration-driven Provider UI** + - ordered generic Provider buttons and icons/fallback; + - Provider-start top-level navigation; + - safe Provider failure page; + - tests with known and fabricated Provider keys. +5. **Switch/cancel, hardening, and cross-system readiness** + - current-browser switch behavior and cancellation; + - accessibility, security headers, URL/log redaction, long-label/responsive cases; + - final typed integration against pinned Constructive; + - handoff to Hub/Site browser E2E owner. + +Each PR should keep generated code changes separate or clearly identified, remove pure formatting churn, and avoid bundling unrelated Dashboard management refactors. + +## Rollout and Compatibility + +- Existing management and per-database Tenant login pages continue to work unchanged while the auth-center route is introduced. +- The new page is enabled only after the pinned Constructive endpoint exposes the required typed operations and safe DTOs. +- Sites begin using the unified entry only after their Site/callback configuration and Tenant auth Host are active. +- No legacy `/auth/providers`, browser OAuth-state Cookie, fragment token, shared-parent Cookie, or management-login fallback is added. +- If the running server returns no Providers, local account/password, registration, and recovery remain available. +- A Provider added later through the server adapter registry and complete Tenant configuration appears without a Dashboard workflow or availability-list change. + +## Open Presentation Decisions + +These do not alter server trust or protocol behavior and may be confirmed before the Dashboard UI PR is finalized: + +1. **Remember-me default.** Recommendation: keep the existing visible choice and current default-on behavior, while the server remains the maximum-lifetime authority. Unchecked uses session storage; checked uses local storage. +2. **Switch-account warning.** Recommendation: show one concise confirmation because switching revokes account A across this browser's bound Sites, then continue the current transaction with account choices. +3. **Initial language.** Recommendation: ship the current English experience with all auth-center copy centralized; do not introduce a new application-wide i18n framework inside this feature PR. + +Provider availability, Provider ordering ownership, unknown-icon fallback, Tenant-login-view reuse, and the absence of a fixed Provider allowlist are resolved by this design and are not open presentation decisions. + +## Acceptance Criteria + +The Dashboard unified-authentication UI is complete when: + +1. `/auth/*` runs without management authentication, database selection, management data providers, shell, chatbot, or endpoint overrides. +2. start-login runs once from the Tenant auth Host, cleans initial query data after success, and keeps the opaque transaction ID only in memory. +3. silent, confirm, and authentication-required decisions come only from Constructive and render without branch guessing or credential-form flash. +4. the confirmation view shows only safe Site/account identity and implements continue, switch, and cancel through typed Constructive operations. +5. local sign-in reuses existing form behavior, permits deliberate manual retry, and follows only the complete server continuation on success. +6. local registration authenticates and continues immediately without the management email-verification flow. +7. password recovery/reset uses existing Tenant operations and never restores or resumes an old transaction. +8. Provider buttons render from the ordered server array with no fixed frontend Provider list, support unknown keys through a generic icon, and disappear cleanly when the list is empty. +9. Provider start sends only transaction ID plus selected key to Constructive and performs top-level navigation to the returned same-origin URL. +10. no Dashboard code receives or processes Provider state/code/token/profile, handoff parts, callback trust, SSO group, raw `returnTo`, or Site-local credentials. +11. auth-center Bearer/Cookie handling is isolated from management and Site credentials; no credential or transaction is logged. +12. stable canonical failures map to safe accessible copy; unknown/internal failures remain generic; failed Provider/transaction flows require a fresh Site start. +13. all continuation navigation uses the complete typed server result unchanged, with no client reconstruction or Next.js prefetch. +14. Vitest/Testing Library and Storybook cover the state machine, dynamic Providers, local flows, branding, errors, token separation, accessibility, and sensitive-data absence. +15. the implementation introduces no parallel Provider registry, transaction store, error system, environment parser, or browser OAuth/handoff protocol. diff --git a/docs/sso/spec/oauth-sso-platform-integration-draft.md b/docs/sso/spec/oauth-sso-platform-integration-draft.md new file mode 100644 index 0000000000..4dbe13c7f1 --- /dev/null +++ b/docs/sso/spec/oauth-sso-platform-integration-draft.md @@ -0,0 +1,21 @@ +# OAuth/SSO Platform Integration Working Draft + +Use this document only for active design discussion, alternatives, evidence, and unresolved questions. Once a decision is confirmed, promote the resulting requirement to [`oauth-sso-platform-integration.md`](./oauth-sso-platform-integration.md) and remove it from this draft. + +The formal specification is the sole authoritative record of confirmed design decisions. + +## Active Discussion Notes + +No active discussion notes. + +## Candidate Designs and Suggested Values + +### Future Initialization Consideration: Provider Templates + +**Status:** Candidate initialization convenience only; not a current formal-specification decision. + +When an `auth:sso`-style Tenant preset provisions identity-provider support, it may seed disabled, non-secret Google and GitHub Provider templates with their standard endpoints, baseline scopes, and PKCE enabled. It must not seed client IDs or client secrets and must not enable either Provider. An administrator must supply the Provider credentials through the existing configuration and secret owners, then explicitly enable the Provider. + +## Open Questions + +No open questions. diff --git a/docs/sso/spec/oauth-sso-platform-integration.md b/docs/sso/spec/oauth-sso-platform-integration.md new file mode 100644 index 0000000000..e32e95a1e4 --- /dev/null +++ b/docs/sso/spec/oauth-sso-platform-integration.md @@ -0,0 +1,341 @@ +# OAuth/SSO Platform Integration Technical Specification + +## Document Status + +This document is the technical design specification for the unified authentication center. It records confirmed design decisions; unresolved proposals remain in the adjacent working draft until they are explicitly confirmed. + +## Product Requirements Source + +The product requirements and expected user-visible behavior are defined in [`docs/sso/plan/oauth-sso-platform-integration.md`](../plan/oauth-sso-platform-integration.md). This specification describes how those requirements are implemented without redefining them. + +Working proposals, suggested values, evidence, and unresolved alternatives belong in the adjacent [working draft](./oauth-sso-platform-integration-draft.md). Only confirmed decisions should be promoted into this document. + +## Implementation Designs + +The component-level implementation designs derive from this specification: + +- [Constructive DB detailed design](./oauth-sso-platform-integration-constructive-db-design.md) +- [Constructive detailed design](./oauth-sso-platform-integration-constructive-design.md) +- [Dashboard unified-authentication UI detailed design](./oauth-sso-platform-integration-dashboard-design.md) + +## Goals and Scope + +- Integrate external Providers through one protocol-neutral adapter boundary rather than duplicating the unified-login workflow per Provider. +- Reuse the existing Constructive identity association and provisioning capabilities. +- Make every successful authentication method converge on one Site handoff and local-credential completion path. +- Keep Sites or flows requiring Constructive `strictAuth`, local MFA, or step-up authentication outside v1 SSO integration. Their future integration requires a separate design based on a real use case and must never downgrade or bypass the existing policy. + +## Public Concepts and Trust Boundaries + +### Roles and Ownership + +- Dashboard owns authentication-center presentation and sends the active unified login transaction identifier only to Constructive. +- Constructive owns generic SSO orchestration, OAuth authorization-request state, Provider adapter selection, normalized identity consumption, and transition into shared post-authentication completion. +- Provider Adapters own Provider-specific authorization and callback/code-to-normalized-identity behavior. Common login-transaction validation, account matching or provisioning, and shared handoff orchestration remain in Constructive; the adapter contract and concrete Google/GitHub examples are detailed in [Main Login 3](#main-login-3-external-provider). Those examples are not a release-specific Provider allowlist. +- Constructive DB owns durable identity association and the existing identity procedures. The browser and external Provider are outside the trusted transaction boundary. +- The current Express Context remains the only request-level source for resolved Tenant, API, database, route, session, user, and request facts. Common orchestration uses its existing `authSurface` and `identityProviders` loaders for authentication-procedure discovery, enabled Tenant-scoped Provider configuration, and internal-secret resolution; it does not introduce parallel auth contexts or loaders. +- v1 exposes the unified-authentication UI and Constructive endpoints through a Tenant-scoped authentication origin registered in the existing canonical Host routing plane. The resolved Host and full Express Context establish the Tenant/API/database boundary before any SSO operation; no handler accepts a browser-selected Tenant/database or scans another Tenant's state. Each authentication-center session Cookie is host-only to that Tenant origin. +- Provider adapters receive the already selected and validated Provider configuration from common orchestration. They do not read environment variables, query Provider configuration or secrets directly from the database, infer Tenant or route facts, or implement legacy Tenant, secret, schema, or database-version fallbacks. +- Existing `constructive_user_identifiers_private.connected_accounts`, `sign_in_identity`, and `sign_up_identity` ownership is preserved. The unified-login flow does not add a parallel identity store or duplicate those procedures. + +### Tenant Isolation and SSO Groups + +- Tenant is the hard unified-authentication boundary. Every Site, login transaction, reusable authentication state, and handoff belongs to one Tenant and must never be reused across Tenants. +- Sites within a Tenant share the Tenant default SSO group when no group is configured. +- A Site Authentication Configuration may set an optional `sso_group_key` to create an additional SSO boundary within that Tenant. Only Sites with the same effective group may silently reuse authentication state; moving between groups requires a new sign-in. +- `sso_group_key` is a nullable, normalized lowercase key, not a separately managed entity in v1. It must contain only lowercase letters, digits, and hyphens. A null value means the Tenant default group. Existing authorization groups and scopes are not SSO groups. + +### Site Authentication Configuration, Callbacks, and Return Targets + +Each Site has one logical Site Authentication Configuration. It records whether unified authentication is enabled, the Site sign-in mode (`confirm` by default or `silent`), and the optional `sso_group_key`. This configuration extends rather than changes the existing Site deployment and routing records. + +Each Site may have multiple active, exact technical callback URLs. Wildcards, suffix matching, and arbitrary subdomains are not valid callback registrations. Constructive validates Site enablement, Tenant ownership, the callback, and `returnTo` at login start; an advisory discovery or preflight operation cannot replace this enforcement. + +The registered technical callback and the Site-internal, application-relative `returnTo` are separate values. An explicitly supplied callback requires an exact active registered match; when omitted, Constructive selects the earliest registered callback by `created_at` ascending and then ID ascending. Both values are validated at login start and retained only in server-side transaction context. After start, neither the opaque transaction identifier nor raw `returnTo` is carried in browser navigation. + +Before navigating to the authentication origin, the Site creates a cryptographically random, short-lived, one-time `site_state` and records it for the current browser in its own first-party, process-independent server-side session boundary. The start operation binds it to the unified login transaction. Every successful completion returns the same value beside the handoff code at the exact Site callback; the Site verifies it against that browser's pending login before redemption and consumes it after redemption succeeds. Missing, expired, mismatched, or replayed state fails safely. `site_state` is public correlation only and contains no identity, credential, callback, or `returnTo` data. The Site store supports concurrent pending attempts rather than an overwrite-prone single Cookie value or process-local map. + +### Login Transactions + +The unified login transaction is server-side, active-flow-only orchestration state and expires ten minutes after creation. Dashboard receives only an opaque identifier from start-login and supplies it only to Constructive operations for the current branch. v1 exposes no transaction/status retrieval query: an interrupted flow starts again from the Site login entry. The transaction model is distinct from the Provider-specific OAuth authorization-request model; each linked Provider OAuth authorization request also expires ten minutes after creation and uses its own one-time-consumption lifecycle. + +For an external Provider branch, Constructive creates a separate OAuth authorization request and links it to the unified login transaction on the server. The browser and Provider never receive the unified login transaction identifier. The OAuth state and PKCE lifecycle is defined in [Main Login 3](#main-login-3-external-provider). + +### Credential Vocabulary + +- **Unified login transaction identifier:** An opaque active-flow identifier used only in Dashboard-to-Constructive operations. It is not a browser redirect parameter or credential. +- **OAuth state:** An opaque, cryptographically random correlation value that the browser and Provider only carry and echo. Its authorization-request, Provider, and unified-transaction associations remain server-side. +- **Provider authorization code:** A transient callback input exchanged only by Constructive after state validation. It is not a durable identity key or a Site credential. +- **Provider token:** An access or identity token confined to the selected server-side Provider adapter. It is never a Dashboard or Site credential. +- **Authentication-center credential:** The Dashboard Bearer result or authentication-domain first-party session Cookie. It remains local to the authentication center. +- **Site-local credential:** A distinct credential issued after handoff redemption for one Site. That Site accepts it through its own first-party session Cookie or `Authorization: Bearer`; Bearer takes precedence when both are present. +- **Handoff code:** A cryptographically random, one-minute, Site-bound, one-time authorization code carried as a query parameter on the exact registered Site callback. It contains no identity, session, or long-lived credential and is not a reusable session credential. +- **Site state:** A Site-generated, one-time public correlation value bound to the initiating browser and unified login transaction. It may accompany the handoff in the callback query but is not proof of identity, a session, or authorization to redeem the handoff by itself. + +Only protocol-required or explicitly approved opaque, short-lived, one-time codes and correlation values may cross a browser URL boundary. This currently includes Provider authorization code/OAuth state at the Provider callback and the handoff code plus public `site_state` correlation at the Site callback. Unified login transaction identifiers, raw `returnTo`, identity data, reusable access or session credentials, Provider tokens, secrets, and PKCE verifiers do not enter the Site callback URL or URL fragments. + +The following three main-flow chapters share one continuation contract. Constructive owns transaction, authentication, and Site-mode decisions; Dashboard renders safe context and submits user actions. + +## Main Login 1: Start and Existing Unified Authentication + +### Sequence + +```mermaid +sequenceDiagram + actor U as User + participant S as Site + participant B as Browser + participant D as Dashboard + participant C as Common Constructive SSO service + participant DB as Constructive DB + participant P as Shared post-authentication continuation + + U->>S: Choose sign-in + S->>S: Create one-time site_state for this browser session + S-->>B: Navigate to Tenant auth origin with Site ID, optional exact callback, returnTo, and site_state + B->>D: Load unified login page + D->>C: Start-login mutation with Site inputs and site_state + C->>C: Use canonical Host route and complete Express Context + C->>DB: Resolve exact callback, validate Tenant/Site/callback/returnTo/site_state, and create transaction + C->>DB: Resolve Provider display options and existing auth-center identity + DB-->>C: Opaque transaction ID + safe context + unified-auth decision + C-->>D: Opaque transaction ID + safe display/decision context + alt Existing identity and silent Site mode + C->>P: Continue with existing identity and transaction + else Existing identity and confirm-before-sign-in + D-->>U: Show lightweight account confirmation + U->>D: Continue with current account + D->>C: Confirm-account mutation with opaque transaction ID + C->>P: Continue with existing identity and transaction + else No reusable identity + D-->>U: Show local password and enabled Provider options + end +``` + +Start-login returns enabled Provider display options for the transaction Tenant alongside safe Site display context and Constructive's authentication decision. Dashboard displays local-password and Provider choices only when there is no reusable unified authentication state. + +## Main Login 2: Local Username/Password + +### Sequence + +```mermaid +sequenceDiagram + actor U as User + participant D as Dashboard + participant C as Common Constructive SSO service + participant W as SSO password PostgreSQL wrapper + participant I as Existing constructive_auth_public.sign_in + participant P as Shared post-authentication continuation + + U->>D: Submit local email and password + D->>C: Password mutation with opaque transaction ID and credentials + C->>W: Invoke SSO wrapper once + W->>W: Validate active transaction and Tenant/Site/SSO boundaries + alt Transaction or boundary validation fails + W-->>C: Safe classified validation failure + C-->>D: Render safe error + else Boundaries are valid + W->>I: Call unchanged sign_in once + alt Password authentication fails + I-->>W: Existing safe authentication failure + W-->>C: Preserve safe failure without automatic retry + C-->>D: User may manually resubmit while transaction remains active + else Authentication succeeds + I-->>W: Existing identity and Dashboard credential outcome + W-->>C: Associate identity with transaction and preserve credential outcome + C->>P: Continue with authenticated identity and transaction + end + end +``` + +The SSO password wrapper validates the active transaction and Tenant, Site, and SSO boundaries, then calls the existing `constructive_auth_public.sign_in` primitive unchanged and exactly once for that submission. It performs no automatic retry; after a safe authentication failure, the user may manually resubmit while the transaction remains active. On success, the wrapper associates the existing identity outcome with the transaction and preserves the Dashboard credential outcome. + +`constructive_auth_public.sign_in` remains the general Tenant-local email/password authentication primitive and is not extended with SSO concerns. `sign_in_identity` remains the separate Provider external-identity primitive keyed by `service` plus stable `identifier`. The Dashboard Bearer result and authentication-domain first-party Cookie behavior remain auth-center-local; they are not the target Site credential. + +## Main Login 3: External Provider + +### Sequence + +```mermaid +sequenceDiagram + actor U as User + participant D as Dashboard + participant C as Common Constructive SSO service + participant DB as Constructive DB + participant PA as Protocol-neutral Provider Adapter + participant B as Browser + participant IP as External Identity Provider + participant P as Shared post-authentication continuation + + U->>D: Choose an enabled configured Provider + D->>C: Provider start with opaque transaction ID and Provider + C->>DB: Validate transaction/Provider and persist linked OAuth request + DB-->>C: Random OAuth state; high-entropy PKCE verifier remains server-side + C->>PA: Build authorization request with S256 challenge + C-->>B: Redirect with random state and S256 challenge only + B->>IP: Complete Provider interaction + IP-->>B: Authorization code + OAuth state, or error + OAuth state + B->>C: Provider callback + C->>DB: Validate match/expiry/unused state, consume it, and restore Provider + transaction + alt Invalid, expired, replayed, or mismatched OAuth state + DB-->>C: Classified state failure + C-->>D: Render safe error; restart from Site login entry + else State restores configured Provider and original transaction + DB-->>C: Restored transaction and Provider context + alt Callback contains Provider cancellation/error + C-->>D: Render safe error; restart from Site login entry + else Callback contains authorization code + C->>PA: Complete code + original verifier through configured adapter + alt Google/OIDC adapter example + PA->>IP: Exchange authorization code + verifier server-side + IP-->>PA: Identity token + optional access token + user data + PA->>PA: Validate identity token and normalize user data + else GitHub/OAuth adapter example + PA->>IP: Exchange authorization code + verifier server-side + IP-->>PA: Access token + PA->>IP: Query user and optional email endpoints + IP-->>PA: User and optional email data + PA->>PA: Normalize GitHub user data + else Another supported adapter + PA->>PA: Verify and normalize through its implementation + end + Note over B,IP: Browser never receives Provider tokens or unified transaction ID + alt Adapter exchange/verification fails + PA-->>C: Safe classified failure + C-->>D: Render safe error; restart from Site login entry + else Normalized external identity + PA-->>C: Service + stable identifier + optional email + safe profile + C->>DB: Resolve connected_accounts by service + identifier + alt Existing association + DB-->>C: Linked local identity + C->>P: Continue with linked identity and transaction + else Unlinked and email is unowned + C->>DB: Call existing sign_up_identity provisioning path + DB-->>C: Provisioned identity + connected account + C->>P: Continue with provisioned identity and transaction + else Email belongs to another local account + DB-->>C: Explicit account conflict + C-->>D: Use existing sign-in method; restart login + end + end + end + end +``` + +### OAuth Authorization State and PKCE + +Before redirecting the browser, Constructive creates an OAuth authorization request with an opaque, cryptographically random OAuth state and persists its server-side association to the configured Provider and unified login transaction. Only that state and the S256 PKCE challenge cross the browser/Provider boundary; the unified login transaction identifier and PKCE verifier never do. + +On callback, Constructive verifies that OAuth state matches the stored authorization request, configured Provider, and unified login transaction, is unexpired, and remains unconsumed. It consumes the state before handling a Provider error or authorization code and before any code exchange. Invalid, expired, replayed, or mismatched state fails safely without authentication completion. + +Every OAuth/OIDC Provider branch uses Authorization Code with mandatory S256 PKCE. For each Provider authorization request, Constructive generates a fresh, cryptographically random, high-entropy `code_verifier`, derives its S256 `code_challenge`, and stores the verifier securely with the server-side authorization request. The browser authorization request carries only the challenge and `code_challenge_method=S256`. After OAuth state validation and consumption, only Constructive exchanges the callback authorization code with the original verifier for that request. + +The verifier and all Provider access or identity tokens remain server-side. They never enter browser-visible data, authorization or callback URLs, URL fragments, or logs. The browser and Provider only carry and echo opaque OAuth state and the protocol-required authorization code or safe Provider error. + +### Provider Adapter and Configuration + +One protocol-neutral Provider Adapter interface or contract covers Provider-specific authorization initiation and callback/code-to-normalized-identity completion. Concrete adapters, including the documented Google and GitHub examples, implement it, while common login orchestration remains in Constructive outside the adapters. The design does not prescribe a base class or inheritance. + +The available Provider set is not fixed by Dashboard or by a release-specific frontend list. It is the intersection of adapters registered in the running Constructive server and complete, enabled Provider configuration for the current Tenant. Dashboard renders the safe display options returned by Constructive in server-provided order; adding another registered and configured Provider does not add a Provider-specific Dashboard workflow. + +Provider endpoints, Provider-facing redirect configuration, scopes, and credentials come only from enabled, Tenant-scoped registered Provider configuration resolved through the existing Express Context `identityProviders` loader. The common Constructive service passes the selected validated configuration to the adapter. Adapters do not read this configuration from environment variables or query it directly from the database. + +Configured Provider endpoints and redirects must pass the existing allowlist and endpoint-safety validation before use. Provider network operations use bounded timeouts and safe redirect behavior. Configuration, transport, and Provider failures map to classified errors without exposing raw Provider responses, tokens, secrets, or private database details. + +### Provider Identity Resolution + +The Provider Adapter normalizes every successful Provider result to the same minimal external identity: + +- Provider service key or name; +- stable Provider user identifier or subject; +- email when available; and +- safe profile details. + +Generic SSO consumes only this normalized outcome. Provider-specific OAuth/OIDC exchange, verification, and optional profile retrieval remain within the selected adapter. Provider authorization codes are transient protocol inputs and are never stored or treated as identity. + +The Google/OIDC adapter exchanges the authorization code server-side, validates the returned identity token as identity proof, and normalizes the resulting user data. A returned access token is not retained or used for v1 SSO when validated identity data is sufficient. The GitHub/OAuth adapter exchanges the authorization code server-side for an access token, uses it server-side to retrieve GitHub user and, when necessary, email data, and normalizes the result. These are adapter-internal examples; they do not create Provider-specific top-level SSO flows. The browser receives only an authorization code plus OAuth state, or a Provider error, and never receives Provider tokens. + +Durable association uses `constructive_user_identifiers_private.connected_accounts`: Provider `service` plus stable `identifier` resolves `owner_id`, while other safe profile attributes remain in `details`. Email is not the returning-account key. + +- An existing association authenticates its linked local user. +- An unlinked identity whose normalized email is not owned by a local account follows the existing `sign_up_identity` path, atomically provisioning the application user, email, and connected account. +- An unlinked identity whose normalized email belongs to a different local account fails explicitly with guidance to use the existing sign-in method. This flow performs no automatic merge or binding and no password-confirmation linking. + +### Future consideration (not current scope) + +An already authenticated local user may later bind Google, GitHub, or another Provider from account settings. Account-settings binding is not part of the current unified-login flow. + +## Shared Post-Authentication Completion + +Every successful route enters exactly one shared post-authentication continuation. Reused unified authentication may enter after silent Site handling or explicit account confirmation; local password and every supported external Provider enter after authentication and identity resolution. + +### Completion and Handoff Diagram + +```mermaid +flowchart LR + Silent["Existing unified auth: silent"] --> Shared["Shared post-authentication continuation"] + Confirm["Existing unified auth: confirm"] --> Shared + Password["Local password"] --> Shared + Provider["External Provider"] --> Shared + Shared --> Center["Preserve, establish, or reuse auth-center Bearer/Cookie outcome"] + Center --> Handoff["One-time Site-bound handoff"] + Handoff --> Callback["GET exact Site callback with handoff + site_state query"] + Callback --> Correlate["Site matches current browser pending site_state"] + Correlate --> Redeem["Redeem GraphQL mutation and DB function"] + Redeem --> Credential["Distinct Site-local Bearer/Cookie"] + Credential --> Return["Verified Site-internal returnTo"] +``` + +### Handoff Codes and Callbacks + +Constructive creates the Site-bound, short-lived, one-time handoff from the shared post-authentication path. After a successful external Provider callback, Constructive issues HTTP `303` to the already validated technical Site callback with the handoff code and transaction-bound `site_state` as query parameters. For Dashboard-mediated success paths, Dashboard performs the equivalent immediate top-level `GET` navigation using the minimal validated continuation returned by Constructive; it does not construct or alter the callback target. + +The target Site server calls the Constructive redeem-handoff GraphQL mutation backed by the handoff-redemption PostgreSQL function. In addition to the handoff and transaction-bound Site context, trusted routing/runtime authentication supplies authoritative `site_id`, `api_id`, and `principal_id`. PostgreSQL requires the transaction Site and the exact tuple authorized by the logical `site_runtime_clients` relation before it issues a Site credential or consumes the handoff. Site is not inferred from API, and `Origin`/`Referer` are auxiliary checks rather than identity sources. This reuses the platform runtime-authentication boundary without introducing an SSO-specific parallel secret or credential system. Successful redemption consumes the handoff and returns a distinct Site-local credential plus the verified Site-internal, application-relative `returnTo`. The Site callback owns setting its first-party Cookie and delivering its Bearer result to its frontend before redirecting to `returnTo`. + +An SSO handoff is a dedicated, minimal persistence model; it is not stored in the Provider OAuth request or session-credential model. It stores an internal ID, secure code hash, referenced login transaction ID, creation time, expiry time, and consumption time. The plaintext code is emitted once only. A handoff expires after one minute and is consumed only after successful redemption; a transient failure before consumption may retry the same code during that lifetime. + +Because the handoff code crosses a browser URL, the Site callback first matches `site_state` to the current browser's pending login, redeems the handoff before rendering HTML or loading third-party resources, consumes the pending Site state after redemption succeeds, returns no-store and no-referrer protections, and immediately redirects to the clean verified `returnTo`. Reverse-proxy, access, APM, analytics, and error logging must redact both raw query values. + +Each Site accepts its local credential through either its first-party session Cookie or an `Authorization: Bearer` header. When both are present, Bearer takes precedence. Dashboard/authentication-center credentials are never handed to a Site and are not its local credentials. + +## Routes and Interfaces + +- Provider authorization initiation and callback retain browser HTTP semantics. The callback accepts a Provider code or error only together with valid opaque OAuth state. +- Authentication-center routes run only on a Tenant-scoped Host already resolved by the canonical routing plane. Shared-host path parsing, browser-selected Tenant/database parameters, and global transaction/state lookup are outside v1. +- Provider discovery remains on the confirmed GraphQL surface; the legacy `/auth/providers` HTTP discovery endpoint is not restored. +- Dashboard never sends the unified login transaction identifier to an external Provider. +- Successful Provider completion uses Constructive's HTTP `303` redirect to the exact registered Site callback with the one-time handoff and `site_state` query parameters. Other successful branches use the equivalent top-level `GET` navigation. +- Target Site handoff redemption uses a Constructive GraphQL mutation backed by a PostgreSQL function. Trusted runtime `site_id`, `api_id`, and `principal_id` must match the transaction Site and an exact `site_runtime_clients` authorization; multiple Sites may share one API without becoming interchangeable. + +## Data Model + +- The existing `constructive_user_identifiers_private.connected_accounts` relation remains the durable Provider association; no parallel Provider-identity table is introduced. +- `service` plus stable external `identifier` identifies the connected account and maps to `owner_id`. Safe non-key Provider profile attributes remain in `details`. +- OAuth authorization-request state and PKCE verifier remain transient and server-side. Their expiry, match, unused state, and one-time consumption are not implemented with browser state/PKCE Cookies or an in-process replay map. A Provider authorization code is never persisted as an identity key. + +## Errors and Observability + +Provider cancellation, a Provider-reported error, invalid OAuth state, exchange or verification failure, and existing-email account conflict produce safe, explicit user-facing failures without exposing raw Provider details. The failed Provider flow is not resumed; the user must restart from the Site login entry with a new unified login transaction. + +## Security + +- Unified login transaction identifiers stay within Dashboard-to-Constructive operations. Provider redirects carry only the protocol-required authorization inputs, including random OAuth state and S256 challenge on initiation and authorization code/state on callback; Site redirects carry only the approved handoff code and transaction-bound `site_state`. +- Parent-domain shared session Cookies are not an SSO mechanism in this design. The authentication center and each Site use their own first-party credential boundary and the confirmed one-time handoff. +- Access tokens, session tokens, Provider tokens, user data, raw `returnTo`, and unified transaction identifiers never appear in the Site callback URL or URL fragments. PKCE verifiers never enter browser-visible data or logs. The one-time handoff query is the sole credential-like Site callback URL artifact and is subject to its one-minute expiry, exact Site/transaction binding, atomic consumption, redaction, no-store/no-referrer, and immediate clean-redirect controls. +- Authentication middleware must not bypass RLS, repeat Tenant/database/route inference outside Express Context, or introduce secret or compatibility fallbacks. +- v1 rejects SSO integration for Sites or flows that require Constructive `strictAuth`, local MFA, or step-up authentication. It never treats SSO success as satisfying or disabling those policies; support is deferred to a separate future design. +- Stable Provider identifier, not email or authorization code, is the durable identity key. +- An email collision never authorizes automatic account merge, binding, or password-confirmation linking. +- All successful Provider branches use the same Site-bound handoff, top-level `GET` callback, redemption, Site-local credential, and verified `returnTo` path as local authentication and reused unified sessions. + +## Testing and Migration + +Provider unit tests mock the external Provider boundary only. They cover Authorization Code + S256 PKCE, server-held verifier handling, OAuth state lifecycle, endpoint and redirect validation, timeout and failure mapping, Google/GitHub adapter behavior, and normalized identity output at the owning package. + +GraphQL server integration tests use the real Constructive server, Express Context, routing, Tenant/database resolution, registered Provider configuration loaders, database procedures, session/Cookie behavior, callback lifecycle, and shared handoff continuation. The external Provider remains the only mocked service boundary. Coverage verifies Tenant-scoped auth-host isolation, transaction-ID/OAuth-state separation, ten-minute expiry, replay rejection, existing connected-account login, `sign_in_identity` and `sign_up_identity` reuse, email-conflict rejection, Provider failure restart, `site_state` binding, concurrent Site attempts, exact GET handoff callback behavior, sensitive-query redaction and no-store/no-referrer protections, Site/runtime authentication at redemption, fail-closed exclusion of strict-auth/MFA/step-up flows, and convergence into the shared handoff path. + +Existing identity association data remains authoritative; no migration to a parallel identity store is permitted. Database-owned transaction, identity, and RLS invariants are tested at the database-owning layer rather than by querying private tables from higher-level integration tests. + +## Open Design Items + +Design questions remain in the working draft until they are resolved. This section will track only unresolved items that have been explicitly accepted as part of the formal specification review. diff --git a/graphql/env/README.md b/graphql/env/README.md index e5084a59d8..c31dd26031 100644 --- a/graphql/env/README.md +++ b/graphql/env/README.md @@ -57,6 +57,14 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag - `API_ANON_ROLE` - Anonymous role name - `API_ROLE_NAME` - Default role name +### OAuth Server +- `OAUTH_ENABLED` - Explicitly enable the unified-auth Provider flow (default: `false`) +- `OAUTH_PROVIDER_REQUEST_TIMEOUT_MS` - Per-request Provider timeout in milliseconds (default: `10000`, maximum: `60000`) + +Provider endpoints, client IDs, secrets, scopes, and policy are Tenant data; +they are not process environment variables. Explicit malformed OAuth values +fail during option resolution instead of falling back silently. + ## Defaults GraphQL defaults are provided by `@constructive-io/graphql-types`: @@ -76,6 +84,10 @@ GraphQL defaults are provided by `@constructive-io/graphql-types`: isPublic: true, metaSchemas: ['routing_public', 'metaschema_public', 'metaschema_modules_public'], routingSchema: 'routing_public' + }, + oauth: { + enabled: false, + providerRequestTimeoutMs: 10000 } } ``` diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 6383de2044..ace18ac67b 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -80,6 +80,10 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "useTx": false, }, }, + "oauth": { + "enabled": false, + "providerRequestTimeoutMs": 10000, + }, "pg": { "database": "config-db", "host": "override-host", diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index fa7dd645e8..f16d841a2f 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import { getGraphQLEnvVars } from '../src/env'; import { getEnvOptions } from '../src/merge'; +import { OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS } from '../src/oauth'; const writeConfig = (dir: string, config: Record): void => { fs.writeFileSync(path.join(dir, 'pgpm.json'), JSON.stringify(config, null, 2)); @@ -230,6 +231,108 @@ describe('getEnvOptions', () => { expect(result.sms).toBeUndefined(); }); + it('defaults OAuth off with a ten-second Provider timeout', () => { + expect(getEnvOptions({}, process.cwd(), {}).oauth).toEqual({ + enabled: false, + providerRequestTimeoutMs: 10_000 + }); + }); + + it('keeps absent OAuth environment variables out of partial overrides', () => { + expect(getGraphQLEnvVars({})).not.toHaveProperty('oauth'); + }); + + it('parses explicit OAuth environment overrides', () => { + expect( + getGraphQLEnvVars({ + OAUTH_ENABLED: 'true', + OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: '2500' + }).oauth + ).toEqual({ + enabled: true, + providerRequestTimeoutMs: 2500 + }); + }); + + it('preserves config OAuth enablement when environment overrides are absent', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-env-oauth-')); + writeConfig(tempDir, { + oauth: { + enabled: true, + providerRequestTimeoutMs: 8000 + } + }); + + expect(getEnvOptions({}, tempDir, {}).oauth).toEqual({ + enabled: true, + providerRequestTimeoutMs: 8000 + }); + }); + + it('honors config, env, and runtime priority for OAuth', () => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-oauth-priority-') + ); + writeConfig(tempDir, { + oauth: { + enabled: false, + providerRequestTimeoutMs: 5000 + } + }); + + const result = getEnvOptions( + { oauth: { providerRequestTimeoutMs: 9000 } }, + tempDir, + { OAUTH_ENABLED: 'true', OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: '7000' } + ); + + expect(result.oauth).toEqual({ + enabled: true, + providerRequestTimeoutMs: 9000 + }); + }); + + it.each(['not-a-boolean', '', 'enabled'])( + 'rejects an explicitly malformed OAuth enabled value %p', + value => { + expect(() => getGraphQLEnvVars({ OAUTH_ENABLED: value })).toThrow( + /OAUTH_ENABLED/ + ); + } + ); + + it.each([ + 'not-a-number', + '0', + '-1', + '1.5', + String(OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS + 1) + ])('rejects an invalid OAuth Provider timeout %p', value => { + expect(() => + getEnvOptions({}, process.cwd(), { + OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: value + }) + ).toThrow(/providerRequestTimeoutMs|OAUTH_PROVIDER_REQUEST_TIMEOUT_MS/); + }); + + it('rejects invalid OAuth config and runtime override types after merging', () => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-oauth-invalid-') + ); + writeConfig(tempDir, { oauth: { enabled: 'yes' } }); + + expect(() => getEnvOptions({}, tempDir, {})).toThrow( + /oauth.enabled must be a boolean/ + ); + expect(() => + getEnvOptions( + { oauth: { providerRequestTimeoutMs: 60_001 } }, + process.cwd(), + {} + ) + ).toThrow(/providerRequestTimeoutMs/); + }); + it('omits an invalid SMS timeout from partial env overrides', () => { const result = getGraphQLEnvVars({ SMS_REQUEST_TIMEOUT_MS: '5s' diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 014924ef24..358d32c600 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -1,6 +1,8 @@ import { ConstructiveOptions } from '@constructive-io/graphql-types'; import { parseEnvBoolean, parseEnvNumber } from '12factor-env'; +import { getOAuthEnvVars } from './oauth'; + /** * @param env - Environment object to read from (defaults to process.env for backwards compatibility) */ @@ -38,6 +40,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial // let an absent env var overwrite pgpm.json or consumer-specific values. const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS); const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN); + const oauth = getOAuthEnvVars(env); const hasSmsEnvOverrides = Boolean( SMS_PROVIDER || SMS_SENDER_ID || @@ -67,6 +70,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial ...(API_ANON_ROLE && { anonRole: API_ANON_ROLE }), ...(API_ROLE_NAME && { roleName: API_ROLE_NAME }) }, + ...(oauth && { oauth }), ...((EMBEDDER_PROVIDER || CHAT_PROVIDER) && { llm: { ...((EMBEDDER_PROVIDER || EMBEDDER_MODEL || EMBEDDER_BASE_URL) && { diff --git a/graphql/env/src/index.ts b/graphql/env/src/index.ts index 50627b7d54..6fb27c2f29 100644 --- a/graphql/env/src/index.ts +++ b/graphql/env/src/index.ts @@ -1,4 +1,9 @@ // Export Constructive-specific env functions export { getGraphQLEnvVars } from './env'; export { getConstructiveEnvOptions,getEnvOptions } from './merge'; +export { + getOAuthEnvVars, + OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS, + validateOAuthServerOptions +} from './oauth'; export type { DevSmsOptions, SmsOptions } from '@constructive-io/graphql-types'; diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index 15f1402c53..a371cfc569 100644 --- a/graphql/env/src/merge.ts +++ b/graphql/env/src/merge.ts @@ -3,6 +3,7 @@ import { getEnvOptions as getPgpmEnvOptions, loadConfigSync, replaceArrays } fro import deepmerge from 'deepmerge'; import { getGraphQLEnvVars } from './env'; +import { validateOAuthServerOptions } from './oauth'; /** * Get Constructive environment options by merging: @@ -36,7 +37,7 @@ export const getEnvOptions = ( const configOptions = loadConfigSync(cwd) as Partial; // Merge in order: core -> graphql defaults -> config (for graphql keys) -> graphql env -> overrides - return deepmerge.all([ + const merged = deepmerge.all([ coreOptions, constructiveGraphqlDefaults, // Only merge graphql-related keys from config (if present) @@ -44,6 +45,7 @@ export const getEnvOptions = ( ...(configOptions.graphile && { graphile: configOptions.graphile }), ...(configOptions.features && { features: configOptions.features }), ...(configOptions.api && { api: configOptions.api }), + ...(configOptions.oauth && { oauth: configOptions.oauth }), ...(configOptions.sms && { sms: configOptions.sms }), }, graphqlEnvOptions, @@ -51,6 +53,11 @@ export const getEnvOptions = ( ], { arrayMerge: replaceArrays }) as ConstructiveOptions; + + return { + ...merged, + oauth: validateOAuthServerOptions(merged.oauth) + }; }; /** diff --git a/graphql/env/src/oauth.ts b/graphql/env/src/oauth.ts new file mode 100644 index 0000000000..4262e2f578 --- /dev/null +++ b/graphql/env/src/oauth.ts @@ -0,0 +1,74 @@ +import { + oauthServerDefaults, + type OAuthServerOptions +} from '@constructive-io/graphql-types'; +import { bool, env as validateEnv, EnvError, num } from '12factor-env'; + +export const OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS = 60_000; + +const assertProviderRequestTimeout = (value: unknown): number => { + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value <= 0 || + value > OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS + ) { + throw new EnvError( + `oauth.providerRequestTimeoutMs must be an integer between 1 and ${OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS}` + ); + } + return value; +}; + +/** Parse only explicitly supplied OAuth environment overrides. */ +export const getOAuthEnvVars = ( + input: NodeJS.ProcessEnv +): OAuthServerOptions | undefined => { + const overrides: OAuthServerOptions = {}; + let configured = false; + + if (input.OAUTH_ENABLED !== undefined) { + const parsed = validateEnv( + { OAUTH_ENABLED: input.OAUTH_ENABLED }, + {}, + { OAUTH_ENABLED: bool() } + ); + overrides.enabled = parsed.OAUTH_ENABLED; + configured = true; + } + + if (input.OAUTH_PROVIDER_REQUEST_TIMEOUT_MS !== undefined) { + const parsed = validateEnv( + { + OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: + input.OAUTH_PROVIDER_REQUEST_TIMEOUT_MS + }, + {}, + { OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: num() } + ); + overrides.providerRequestTimeoutMs = assertProviderRequestTimeout( + parsed.OAUTH_PROVIDER_REQUEST_TIMEOUT_MS + ); + configured = true; + } + + return configured ? overrides : undefined; +}; + +/** Validate and complete the effective OAuth options after all merge layers. */ +export const validateOAuthServerOptions = ( + input: OAuthServerOptions | undefined +): Required => { + const enabled = input?.enabled ?? oauthServerDefaults.enabled; + if (typeof enabled !== 'boolean') { + throw new EnvError('oauth.enabled must be a boolean'); + } + + return { + enabled, + providerRequestTimeoutMs: assertProviderRequestTimeout( + input?.providerRequestTimeoutMs ?? + oauthServerDefaults.providerRequestTimeoutMs + ) + }; +}; diff --git a/graphql/server/src/middleware/error-handler.ts b/graphql/server/src/middleware/error-handler.ts index bbf63de194..1111e1847c 100644 --- a/graphql/server/src/middleware/error-handler.ts +++ b/graphql/server/src/middleware/error-handler.ts @@ -1,5 +1,6 @@ import './types'; +import { ConstructiveError } from '@constructive-io/errors'; import { getNodeEnv } from '@pgpmjs/env'; import { Logger } from '@pgpmjs/logger'; import type { ErrorRequestHandler, NextFunction, Request, Response } from 'express'; @@ -39,6 +40,14 @@ const isCsrfError = (err: Error): boolean => { }; const categorizeError = (err: Error): ErrorResponse => { + if (err instanceof ConstructiveError) { + return { + statusCode: err.http, + code: err.code, + message: err.isPublic ? err.message : 'An unexpected error occurred', + logLevel: err.http >= 500 ? 'error' : 'warn' + }; + } if (isApiError(err)) { return { statusCode: err.statusCode, @@ -79,7 +88,15 @@ const logError = (err: Error, req: Request, level: 'warn' | 'error'): void => { clientIp: req.clientIp, }; - if (isApiError(err)) { + if (err instanceof ConstructiveError) { + log[level]({ + event: 'constructive_error', + code: err.code, + statusCode: err.http, + message: err.message, + ...context + }); + } else if (isApiError(err)) { log[level]({ event: 'api_error', code: err.code, statusCode: err.statusCode, message: err.message, ...context }); } else { log[level]({ event: 'unexpected_error', name: err.name, message: err.message, stack: isDevelopment() ? err.stack : undefined, ...context }); diff --git a/graphql/types/README.md b/graphql/types/README.md index e071cedb40..5160fec847 100644 --- a/graphql/types/README.md +++ b/graphql/types/README.md @@ -47,6 +47,10 @@ const config: ConstructiveOptions = { simpleInflection: true, postgis: true, }, + oauth: { + enabled: false, + providerRequestTimeoutMs: 10_000, + }, }; ``` @@ -68,6 +72,12 @@ Configuration for the Constructive API including meta API settings, exposed sche Feature flags for GraphQL/Graphile including inflection settings and PostGIS support. +### OAuthServerOptions + +GraphQL-server-owned OAuth enablement and bounded Provider request timeout. +Provider credentials and endpoint configuration remain Tenant data and are not +part of this type. + ## Re-exports This package re-exports all types from `@pgpmjs/types` for convenience, so you can import both core PGPM types and GraphQL types from a single package. diff --git a/graphql/types/src/constructive.ts b/graphql/types/src/constructive.ts index 485a4f4a59..cb88edbdf5 100644 --- a/graphql/types/src/constructive.ts +++ b/graphql/types/src/constructive.ts @@ -17,6 +17,7 @@ import { GraphileFeatureOptions, GraphileOptions} from './graphile'; import { LlmOptions } from './llm'; +import { oauthServerDefaults, type OAuthServerOptions } from './oauth'; import { SmsOptions } from './sms'; /** @@ -29,6 +30,8 @@ export interface ConstructiveGraphQLOptions { features?: GraphileFeatureOptions; /** API configuration options */ api?: ApiOptions; + /** GraphQL server OAuth feature and transport options */ + oauth?: OAuthServerOptions; } /** @@ -58,6 +61,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt llm?: LlmOptions; /** SMS provider configuration */ sms?: SmsOptions; + /** GraphQL server OAuth feature and transport options */ + oauth?: OAuthServerOptions; } /** @@ -66,7 +71,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt export const constructiveGraphqlDefaults: ConstructiveGraphQLOptions = { graphile: graphileDefaults, features: graphileFeatureDefaults, - api: apiDefaults + api: apiDefaults, + oauth: oauthServerDefaults }; /** diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index 895604e137..dd8eaa6fb7 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -27,6 +27,11 @@ export { LlmEmbedderOptions, LlmOptions} from './llm'; +// Export GraphQL-server OAuth options +export { + oauthServerDefaults, + type OAuthServerOptions} from './oauth'; + // Export SMS types export { DevSmsOptions, diff --git a/graphql/types/src/oauth.ts b/graphql/types/src/oauth.ts new file mode 100644 index 0000000000..2b52a2003b --- /dev/null +++ b/graphql/types/src/oauth.ts @@ -0,0 +1,17 @@ +/** + * GraphQL-server-owned OAuth runtime options. + * + * Provider endpoints, credentials, scopes, and policy remain Tenant data and + * are deliberately absent from this process-level configuration surface. + */ +export interface OAuthServerOptions { + /** Explicitly enables the unified-auth Provider flow. */ + enabled?: boolean; + /** Maximum duration of one outbound Provider HTTP request. */ + providerRequestTimeoutMs?: number; +} + +export const oauthServerDefaults: Required = { + enabled: false, + providerRequestTimeoutMs: 10_000 +}; diff --git a/packages/errors/README.md b/packages/errors/README.md index 4668324e57..5ad2b283c6 100644 --- a/packages/errors/README.md +++ b/packages/errors/README.md @@ -30,6 +30,9 @@ if (parsed.class === 'public') { // Throw a structured error throw errors.ACCOUNT_EXISTS(); + +// Preserve an internal cause without exposing it in transport extensions +throw errors.INVALID_OAUTH_STATE(undefined, undefined, { cause: caught }); ``` ## Design notes diff --git a/packages/errors/__tests__/parse.test.ts b/packages/errors/__tests__/parse.test.ts index da301fd44e..f1fbf0dc89 100644 --- a/packages/errors/__tests__/parse.test.ts +++ b/packages/errors/__tests__/parse.test.ts @@ -126,10 +126,12 @@ describe('toError', () => { }); it('falls back to UNKNOWN_ERROR and the raw message for unresolved errors', () => { - const err = toError(new Error('totally opaque failure')); + const original = new Error('totally opaque failure'); + const err = toError(original); expect(err.code).toBe('UNKNOWN_ERROR'); expect(err.errorClass).toBe('internal'); expect(err.message).toBe('totally opaque failure'); + expect(err.cause).toBe(original); }); it('returns a ConstructiveError unchanged', () => { diff --git a/packages/errors/__tests__/sso.test.ts b/packages/errors/__tests__/sso.test.ts new file mode 100644 index 0000000000..36eea0d62f --- /dev/null +++ b/packages/errors/__tests__/sso.test.ts @@ -0,0 +1,39 @@ +import { ConstructiveError, errors, getDefinition } from '../src'; + +const PUBLIC_SSO_CODES = [ + 'INVALID_SSO_SITE_STATE', + 'INVALID_SSO_CALLBACK', + 'INVALID_SSO_RETURN_TARGET', + 'SSO_LOGIN_TRANSACTION_EXPIRED', + 'SSO_LOGIN_TRANSACTION_ALREADY_USED', + 'OAUTH_SIGN_IN_DISABLED', + 'INVALID_OAUTH_STATE', + 'INVALID_OAUTH_PKCE', + 'IDENTITY_PROVIDER_NOT_CONFIGURED', + 'IDENTITY_PROVIDER_UNSUPPORTED', + 'SSO_ACCOUNT_CONFLICT', + 'INVALID_SSO_HANDOFF', + 'SSO_HANDOFF_EXPIRED', + 'SSO_HANDOFF_ALREADY_USED' +] as const; + +describe('OAuth/SSO error contract', () => { + it.each(PUBLIC_SSO_CODES)('registers %s as a stable public error', code => { + const definition = getDefinition(code); + expect(definition).toMatchObject({ code, class: 'public' }); + expect(definition?.message).not.toEqual(code); + }); + + it('preserves a cause without exposing it in transport extensions', () => { + const cause = new Error('provider response contained a secret'); + const error = errors.INVALID_OAUTH_STATE(undefined, undefined, { cause }); + + expect(error).toBeInstanceOf(ConstructiveError); + expect(error.cause).toBe(cause); + expect(error.toExtensions()).toEqual({ + code: 'INVALID_OAUTH_STATE', + class: 'public', + http: 400 + }); + }); +}); diff --git a/packages/errors/src/error.ts b/packages/errors/src/error.ts index 98f9269f15..a91f27621a 100644 --- a/packages/errors/src/error.ts +++ b/packages/errors/src/error.ts @@ -1,6 +1,6 @@ import type { ErrorClass, ErrorContext } from './types'; -export interface ConstructiveErrorArgs { +export interface ConstructiveErrorArgs extends ErrorOptions { code: string; message: string; errorClass: ErrorClass; @@ -22,7 +22,10 @@ export class ConstructiveError extends Error { readonly context?: ErrorContext; constructor(args: ConstructiveErrorArgs) { - super(args.message); + super( + args.message, + args.cause === undefined ? undefined : { cause: args.cause } + ); this.name = 'ConstructiveError'; this.code = args.code; this.errorClass = args.errorClass; diff --git a/packages/errors/src/factory.ts b/packages/errors/src/factory.ts index 8d34d01c95..48b535a3c1 100644 --- a/packages/errors/src/factory.ts +++ b/packages/errors/src/factory.ts @@ -10,8 +10,16 @@ import type { ErrorClass, ErrorContext, ErrorDefinition } from './types'; * The `[keyof C]` tuple wrapper prevents `never` from distributing. */ export type ErrorFactory = [keyof C] extends [never] - ? (context?: Record, overrideMessage?: string) => ConstructiveError - : (context: C, overrideMessage?: string) => ConstructiveError; + ? ( + context?: Record, + overrideMessage?: string, + options?: ErrorOptions + ) => ConstructiveError + : ( + context: C, + overrideMessage?: string, + options?: ErrorOptions + ) => ConstructiveError; export type ErrorsApi = { [K in keyof R]: R[K] extends { __context: (context: infer C) => void } @@ -25,13 +33,18 @@ export type ErrorsApi = { export function makeErrorFromDefinition( def: ErrorDefinition ): ErrorFactory { - const factory = (context?: ErrorContext, overrideMessage?: string): ConstructiveError => + const factory = ( + context?: ErrorContext, + overrideMessage?: string, + options?: ErrorOptions + ): ConstructiveError => new ConstructiveError({ code: def.code, message: overrideMessage ?? format(def.code, context ?? {}), errorClass: def.class, http: def.http, - context + context, + cause: options?.cause }); return factory as ErrorFactory; } @@ -59,14 +72,19 @@ export function makeError( messageFn: (context: C) => string, httpCode = 500, errorClass: ErrorClass = 'internal' -): (context: C, overrideMessage?: string) => ConstructiveError { - return (context: C, overrideMessage?: string) => +): ( + context: C, + overrideMessage?: string, + options?: ErrorOptions +) => ConstructiveError { + return (context: C, overrideMessage?: string, options?: ErrorOptions) => new ConstructiveError({ code, message: overrideMessage ?? messageFn(context), errorClass, http: httpCode, - context + context, + cause: options?.cause }); } diff --git a/packages/errors/src/parse.ts b/packages/errors/src/parse.ts index 807076af1d..df1b953978 100644 --- a/packages/errors/src/parse.ts +++ b/packages/errors/src/parse.ts @@ -207,6 +207,7 @@ export function toError(error: unknown, locale?: string): ConstructiveError { message, errorClass: parsed.class, http: def ? def.http : httpStatusFor(code).status, - context: parsed.context + context: parsed.context, + cause: parsed.originalError }); } diff --git a/packages/errors/src/registry.ts b/packages/errors/src/registry.ts index 7c5cf70fc1..6886dbd34a 100644 --- a/packages/errors/src/registry.ts +++ b/packages/errors/src/registry.ts @@ -148,6 +148,90 @@ export const registry = { http: 404, message: 'No single sign-on account was found.' }), + OAUTH_SIGN_IN_DISABLED: defineError({ + code: 'OAUTH_SIGN_IN_DISABLED', + class: 'public', + http: 403, + message: 'OAuth sign in is not enabled.' + }), + INVALID_SSO_SITE_STATE: defineError({ + code: 'INVALID_SSO_SITE_STATE', + class: 'public', + http: 400, + message: 'The sign-in request is invalid or has expired. Please restart sign in.' + }), + INVALID_SSO_CALLBACK: defineError({ + code: 'INVALID_SSO_CALLBACK', + class: 'public', + http: 400, + message: 'The requested sign-in callback is not registered for this Site.' + }), + INVALID_SSO_RETURN_TARGET: defineError({ + code: 'INVALID_SSO_RETURN_TARGET', + class: 'public', + http: 400, + message: 'The requested return location is invalid.' + }), + SSO_LOGIN_TRANSACTION_EXPIRED: defineError({ + code: 'SSO_LOGIN_TRANSACTION_EXPIRED', + class: 'public', + http: 410, + message: 'The sign-in request has expired. Please restart sign in.' + }), + SSO_LOGIN_TRANSACTION_ALREADY_USED: defineError({ + code: 'SSO_LOGIN_TRANSACTION_ALREADY_USED', + class: 'public', + http: 409, + message: 'The sign-in request has already been completed. Please restart sign in.' + }), + INVALID_OAUTH_STATE: defineError({ + code: 'INVALID_OAUTH_STATE', + class: 'public', + http: 400, + message: 'The external sign-in state is invalid or has expired. Please restart sign in.' + }), + INVALID_OAUTH_PKCE: defineError({ + code: 'INVALID_OAUTH_PKCE', + class: 'public', + http: 400, + message: 'The external sign-in verification failed. Please restart sign in.' + }), + IDENTITY_PROVIDER_NOT_CONFIGURED: defineError({ + code: 'IDENTITY_PROVIDER_NOT_CONFIGURED', + class: 'public', + http: 400, + message: 'This identity provider is not configured.' + }), + IDENTITY_PROVIDER_UNSUPPORTED: defineError({ + code: 'IDENTITY_PROVIDER_UNSUPPORTED', + class: 'public', + http: 400, + message: 'This identity provider is not supported.' + }), + SSO_ACCOUNT_CONFLICT: defineError({ + code: 'SSO_ACCOUNT_CONFLICT', + class: 'public', + http: 409, + message: 'An account already uses this email. Sign in with its existing method.' + }), + INVALID_SSO_HANDOFF: defineError({ + code: 'INVALID_SSO_HANDOFF', + class: 'public', + http: 400, + message: 'The Site sign-in handoff is invalid.' + }), + SSO_HANDOFF_EXPIRED: defineError({ + code: 'SSO_HANDOFF_EXPIRED', + class: 'public', + http: 410, + message: 'The Site sign-in handoff has expired. Please restart sign in.' + }), + SSO_HANDOFF_ALREADY_USED: defineError({ + code: 'SSO_HANDOFF_ALREADY_USED', + class: 'public', + http: 409, + message: 'The Site sign-in handoff has already been used.' + }), MAGIC_LINK_SIGN_IN_DISABLED: defineError({ code: 'MAGIC_LINK_SIGN_IN_DISABLED', class: 'public',