Skip to content

feat(auth): @better-auth/sso scaffold — per-env external IdP (OIDC/SAML) RP#2322

Merged
os-zhuang merged 5 commits into
mainfrom
feat/sso-external-idp
Jun 25, 2026
Merged

feat(auth): @better-auth/sso scaffold — per-env external IdP (OIDC/SAML) RP#2322
os-zhuang merged 5 commits into
mainfrom
feat/sso-external-idp

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Draft / scaffold. Compiles green and is off by default (OS_SSO_ENABLED). Implements the wiring for ADR-0024 V1's open per-env SSO mechanism (let an environment federate login to a customer's own OIDC/SAML IdP), and documents a real framework gap that must be closed before it's functional.

What's here (builds green — DTS ok for plugin-auth + platform-objects)

  • sys_sso_provider object (platform-objects/.../identity/) mirroring @better-auth/sso@1.6.20's BaseSSOProvider (issuer, oidc_config/saml_config JSON blobs, user_id, provider_id, organization_id, domain); managedBy: 'better-auth'; mutations via /api/v1/auth/sso/{register,delete-provider}.
  • plugin-auth wiring: sso() plugin gated by enabled.sso (OS_SSO_ENABLED, mirrors OS_OIDC_PROVIDER_ENABLED); ssoProvider → sys_sso_provider added to AUTH_MODEL_TO_PROTOCOL.
  • deps: @better-auth/sso ^1.6.20 (+ lockfile).

The gap I uncovered (the real remaining work)

@better-auth/sso@1.6.20 exposes no schema option — verified: no mergeSchema, and the runtime never reads options.schema (unlike oauthProvider, which the framework remaps via buildOauthProviderPluginSchema). So the sso plugin cannot declare its model/field names.

The active adapter (createObjectQLAdapterFactory) passes the raw better-auth model name to the data engine (dataEngine.insert(model, …)) and maps fields only from per-model declarations (core user/session/account config or a plugin schema option). The sso plugin provides neither, so at runtime:

  • model ssoProvider won't resolve to sys_sso_provider, and
  • camelCase fields (oidcConfig, …) won't map to snake columns.

To finish (next PR): extend objectql-adapter so the factory adapter resolves schema-less plugin model names (consult resolveProtocolName/AUTH_MODEL_TO_PROTOCOL) and maps camelCase→snake fields for them; update objectql-adapter.test.ts; then full local E2E (register an OIDC IdP → login → confirm JIT sys_user + sys_account.provider_id + default role). provisionUser default-role, ssoClient(), and the admin UI are also follow-ups.

Open-core / refs

Per-env SSO mechanism is OPEN (framework; self-host needs it — ADR-0024 topology boundary). Refs: ADR-0024 (objectstack-ai/cloud#502), tracker objectstack-ai/cloud#500.

🤖 Generated with Claude Code

os-zhuang and others added 2 commits June 25, 2026 16:21
…ng party

ADR-0024 V1: the OPEN per-env SSO mechanism. Lets an environment federate
login to a customer's own OIDC/SAML IdP (Okta / Entra / Google), registered
at runtime in the env (cloud-free for self-host).

- platform-objects: new `sys_sso_provider` object mirroring @better-auth/sso
  @1.6.20's BaseSSOProvider (issuer, oidc_config/saml_config JSON blobs,
  user_id, provider_id, organization_id, domain); managedBy: better-auth;
  mutations via /api/v1/auth/sso/{register,delete-provider}.
- plugin-auth: AUTH_SSO_PROVIDER_SCHEMA + buildSsoPluginSchema() (camelCase→
  snake_case, per the buildOauthProviderPluginSchema template); auth-manager
  wires sso({ schema }) gated by enabled.sso (OS_SSO_ENABLED, mirrors
  OS_OIDC_PROVIDER_ENABLED).
- package.json: add @better-auth/sso ^1.6.20.

Follow-ups: provisionUser default-role, ssoClient() + admin UI (PR-2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er map

@better-auth/sso (1.6.20) exposes NO `schema` option (verified: no mergeSchema,
runtime never reads options.schema), so the per-plugin schema-remap that
oauthProvider uses is unavailable. Drop the invalid `schema` arg + the dead
buildSsoPluginSchema(); call sso() bare (gated off by OS_SSO_ENABLED). Add
ssoProvider -> sys_sso_provider to AUTH_MODEL_TO_PROTOCOL.

KNOWN-INCOMPLETE (documented inline): the ACTIVE factory adapter
(createObjectQLAdapterFactory) passes the raw better-auth model name to the
data engine and maps fields only from per-model declarations — neither of
which the sso plugin provides. Finishing the integration needs objectql-adapter
to resolve schema-less plugin model + camelCase field names, then full E2E.
Compiles green (DTS ok for plugin-auth + platform-objects); feature off by
default.

+ pnpm-lock.yaml (@better-auth/sso ^1.6.20).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jun 25, 2026 11:05am

Request Review

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file size/m labels Jun 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/plugin-auth.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/concepts/packages.mdx (via @objectstack/platform-objects, @objectstack/plugin-auth)
  • content/docs/concepts/setup-app.mdx (via @objectstack/platform-objects)
  • content/docs/getting-started/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/guides/auth-sso.mdx (via @objectstack/plugin-auth)
  • content/docs/guides/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/guides/kernel-services.mdx (via @objectstack/plugin-auth)
  • content/docs/guides/packages.mdx (via @objectstack/platform-objects, @objectstack/plugin-auth)
  • content/docs/guides/plugins.mdx (via @objectstack/plugin-auth)
  • content/docs/guides/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…sso)

Make createObjectQLAdapterFactory resolve the table name (resolveProtocolName)
AND map camelCase<->snake fields for models remapped via AUTH_MODEL_TO_PROTOCOL
— i.e. better-auth plugins like @better-auth/sso that expose no `schema` option.
Scoped by `objectName !== model`, so core / schema-declared models are
byte-for-byte unchanged.

This makes the @better-auth/sso integration (sys_sso_provider) functional at the
data layer.

Verified: 29 unit tests pass — 3 new sso-bridging tests drive the REAL
better-auth adapter wrapper (sso plugin loaded): insert maps ssoProvider +
oidcConfig -> sys_sso_provider + oidc_config, reads map back to camelCase, where
fields are snaked; the 26 existing core-path tests are unchanged.

Remaining: full browser E2E (login via a live external OIDC IdP -> JIT user +
sys_account.provider_id + default role).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added the tests label Jun 25, 2026
@os-zhuang

Copy link
Copy Markdown
Contributor Author

Update — the adapter gap is now implemented + verified at the data layer.

createObjectQLAdapterFactory now resolves the table name (resolveProtocolName) and maps camelCase↔snake fields for schema-less plugin models, scoped by objectName !== model so core / schema-declared models are byte-for-byte unchanged. This makes the @better-auth/ssosys_sso_provider integration functional.

29 unit tests pass — 3 new sso-bridging tests drive the real better-auth adapter wrapper (sso plugin loaded):

  • create(ssoProvider, { oidcConfig })dataEngine.insert('sys_sso_provider', { oidc_config })
  • reads map snake columns back to camelCase; where-clause fields are snaked
  • the 26 existing core-path tests are unchanged.

Compiles green (plugin-auth + platform-objects DTS).

Remaining before "done": full browser E2E — OS_SSO_ENABLED=true, register a live external OIDC IdP via /api/v1/auth/sso/register, log in, confirm JIT sys_user + sys_account.provider_id + default role. Staying draft until that passes.

…isions

Without adding SysSsoProvider to authIdentityObjects (the manifest the Auth
plugin provisions), the table is never created and the sso plugin's writes
fail at runtime. Caught by the live E2E: with this, sys_sso_provider
provisions with the correct snake columns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@os-zhuang

Copy link
Copy Markdown
Contributor Author

✅ Full live E2E passed — two federated objectos instances

Ran a real two-node OIDC federation from this branch: OP = a framework objectos with OS_OIDC_PROVIDER_ENABLED (standard oauth-provider = the cloud-IdP role); RP = a framework objectos with OS_SSO_ENABLED (this PR's @better-auth/sso).

  1. Boot — RP comes up clean with OS_SSO_ENABLED (29 plugins); sys_sso_provider provisions with the correct snake columns.
  2. RegisterPOST /api/v1/auth/sso/register (it fetched the OP's /.well-known/openid-configuration) → row written through the adapter bridging: providerIdprovider_id, oidcConfigoidc_config.
  3. InitiatePOST /api/v1/auth/sign-in/sso by providerId and by email-domain routing (alice@acme.testacme-op) → correct OIDC authorize URL built from the stored provider (read-bridging snake→camelCase) + PKCE/redirect_uri.
  4. Login — full authorization-code exchange → JIT user admin@op.test created in the RP + sys_account.provider_id="acme-op" linked; the RP session is the federated user.

Surfaced + fixed by the E2E: SysSsoProvider wasn't in authIdentityObjects, so the table never provisioned — fixed in e78dc96.

One caveat: the OP consent screen is /_console/oauth/consent (objectui console, not built in this worktree), so I marked the test client skip_consent in the OP DB to drive the code flow headlessly. The consent UI is framework oauth-provider (unchanged by this PR); it renders normally in a real deploy.

Net: the new code is verified end-to-end (boot → provision → register → initiate → federated login → JIT) in a live runtime, on top of the 29 passing unit tests. Follow-ups remain (provisionUser default-role; ssoClient() + admin UI = PR-2).

@os-zhuang

Copy link
Copy Markdown
Contributor Author

UI half landed → the login-page "Sign in with SSO" entry is objectstack-ai/objectui#2000.

Together with this backend PR, the per-env external-IdP SSO flow is complete and browser-verified end-to-end (two federated objectos instances): login page → click "Sign in with SSO" → IdP login → consent → JIT federated session (sys_user + sys_account.provider_id).

… (SSO login button)

Pulls in objectui#2000 (the "Sign in with SSO" login-page entry) so the
framework's bundled @objectstack/console ships the SSO UI alongside this
PR's @better-auth/sso backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@os-zhuang
os-zhuang marked this pull request as ready for review June 25, 2026 10:58
@os-zhuang
os-zhuang merged commit 13e5845 into main Jun 25, 2026
15 of 16 checks passed
@os-zhuang
os-zhuang deleted the feat/sso-external-idp branch June 25, 2026 10:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file size/m tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant