From fa3211d2a12d165fa8f95a04db9219bf8adc7513 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:43:00 +0800 Subject: [PATCH] feat(plugin-auth): SAML 2.0 SSO via @better-auth/sso (ADR-0069 P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@better-auth/sso@1.6.20` ships full SAML 2.0 (samlify-backed), so SAML needs no custom plugin — the same package that powers the OIDC trust list also exposes `/sso/saml2/sp/metadata`, `/sso/saml2/sp/acs/:providerId`, and registers SAML providers through the same `/sso/register` endpoint with a nested `samlConfig`. - `register_saml_provider` action on `sys_sso_provider` (Setup → SSO Providers) collecting flat IdP fields (providerId, IdP EntityID, domain, IdP SSO URL, IdP signing cert, NameID format). - `runRegisterSamlProviderFromForm` bridge (sibling of the OIDC one) reshapes the flat form into the nested `samlConfig`, derives the per-provider ACS URL (`/sso/saml2/sp/acs/`), defaults the SP descriptor, and re-dispatches through `/sso/register` so the admin gate runs. Returns the SP ACS + metadata URLs. - ADR-0069 addendum corrects the stale "SAML is out of better-auth core" premise. Verified E2E against a test IdP: register → provider persisted with `saml_config`; SP metadata endpoint serves a valid `EntityDescriptor`/`SPSSODescriptor`; `/sign-in/sso` routes an email domain to the IdP with a valid `SAMLRequest` redirect. Co-Authored-By: Claude Opus 4.8 --- .changeset/adr0069-saml-sso.md | 13 +++ ...069-enterprise-authentication-hardening.md | 14 ++++ .../src/identity/sys-sso-provider.object.ts | 29 ++++++- .../plugins/plugin-auth/src/auth-plugin.ts | 22 ++++- .../src/register-sso-provider.test.ts | 68 +++++++++++++++ .../plugin-auth/src/register-sso-provider.ts | 84 +++++++++++++++++++ 6 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 .changeset/adr0069-saml-sso.md create mode 100644 packages/plugins/plugin-auth/src/register-sso-provider.test.ts diff --git a/.changeset/adr0069-saml-sso.md b/.changeset/adr0069-saml-sso.md new file mode 100644 index 0000000000..12156c64bc --- /dev/null +++ b/.changeset/adr0069-saml-sso.md @@ -0,0 +1,13 @@ +--- +"@objectstack/plugin-auth": minor +"@objectstack/platform-objects": minor +--- + +feat(auth): SAML 2.0 SSO via @better-auth/sso (ADR-0069 P3) + +`@better-auth/sso@1.6.20` ships full SAML 2.0 (samlify-backed), so SAML needs no +custom plugin. Adds a `register_saml_provider` action on `sys_sso_provider` and a +`runRegisterSamlProviderFromForm` bridge that reshapes the flat admin form into the +nested `samlConfig` and re-dispatches through `/sso/register` (admin gate enforced), +returning the SP ACS + metadata URLs to configure on the IdP. Updates ADR-0069 to +correct the stale "SAML is out of better-auth core" premise. diff --git a/docs/adr/0069-enterprise-authentication-hardening.md b/docs/adr/0069-enterprise-authentication-hardening.md index 172b1f067a..67f100676d 100644 --- a/docs/adr/0069-enterprise-authentication-hardening.md +++ b/docs/adr/0069-enterprise-authentication-hardening.md @@ -149,3 +149,17 @@ Each row in D1-D6 names exactly one of these seams. No setting is introduced wit - **Adopt the settings fields now, wire enforcement later** — rejected: violates ADR-0049 (false surface). Fields and their enforcement land together. - **Outsource all of auth to an external IdP (Auth0/WorkOS)** — rejected as the default: the platform must be self-hostable and own its identity; external IdP is supported *through* D6 (OIDC), not instead of the local floor. - **Build SAML now** — deferred: no native better-auth support in 1.6.x; building it before an enforcing implementation exists would be an ADR-0049 violation. + + +--- + +## Addendum (2026-06): SAML is now better-auth-native — premise updated + +The original decision deferred SAML to "P3 / external" on the premise that **SAML is out of better-auth core (1.6.x exposes only `genericOAuth` + `oidcProvider`)**. That premise is **no longer true**: the already-installed **`@better-auth/sso@1.6.20`** (the same plugin wiring the OIDC trust list) ships **full SAML 2.0** — it bundles `samlify` + `fast-xml-parser` + `jose`, exposes `/sso/saml2/sp/metadata`, `/sso/saml2/sp/acs/:providerId`, `/sso/saml2/sp/slo/:providerId`, and registers SAML providers through the **same `/sso/register`** endpoint with a nested `samlConfig` (entryPoint, cert, callbackUrl, spMetadata, identifierFormat, …). Signature/timestamp/replay validation is handled by samlify. + +Consequently SAML did **not** require a custom plugin. What shipped (D6/P3): + +- A **`register_saml_provider`** action on `sys_sso_provider` (Setup → SSO Providers) collecting flat IdP fields (providerId, IdP EntityID, domain, IdP SSO URL, IdP signing cert, NameID format). +- A shared **`runRegisterSamlProviderFromForm`** bridge (sibling of the OIDC one) that reshapes the flat form into the nested `samlConfig`, derives the per-provider ACS URL (`/sso/saml2/sp/acs/`), defaults the SP descriptor, and re-dispatches through `/sso/register` so the admin gate runs. It returns the **SP ACS + metadata URLs** to configure on the IdP. + +Verified end-to-end against a test IdP: register → provider persisted with `saml_config`; SP metadata endpoint serves a valid `EntityDescriptor`/`SPSSODescriptor`; `/sign-in/sso` routes an email domain to the IdP with a valid `SAMLRequest` redirect. The IdP→ACS assertion round-trip is `@better-auth/sso` / samlify's responsibility. diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index 53b70ecf57..f376709d36 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -92,6 +92,33 @@ export const SysSsoProvider = ObjectSchema.create({ { name: 'mapName', label: 'Map: Name claim', type: 'text', required: false, placeholder: 'name', helpText: 'Optional. Claim mapped to display name. Defaults to "name".' }, ], }, + { + name: 'register_saml_provider', + label: 'Register SAML Provider', + icon: 'shield', + variant: 'primary', + mode: 'create', + locations: ['list_toolbar'], + type: 'api', + method: 'POST', + // SAML 2.0 via @better-auth/sso (samlify-backed). Routed through the + // env-side bridge (plugin-auth `auth-plugin.ts` → register-saml), which + // reshapes these FLAT IdP fields into the nested `samlConfig` body that + // @better-auth/sso's /sso/register requires (entryPoint/cert/callbackUrl/ + // spMetadata/identifierFormat), derives the per-provider ACS URL, and + // re-dispatches to /sso/register (admin gate runs). The response returns + // the SP ACS + metadata URLs to configure on the IdP. + target: '/api/v1/auth/admin/sso/register-saml', + refreshAfter: true, + params: [ + { name: 'providerId', label: 'Provider ID', type: 'text', required: true, helpText: 'Stable identifier, e.g. "acme-saml".' }, + { name: 'issuer', label: 'IdP Entity ID', type: 'text', required: true, helpText: 'The IdP’s SAML EntityID (issuer), e.g. https://saml.acme.com/entityid.' }, + { name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP, e.g. acme.com.' }, + { name: 'entryPoint', label: 'IdP SSO URL', type: 'text', required: true, helpText: 'The IdP’s SAML single sign-on (redirect) endpoint that receives the SAMLRequest.' }, + { name: 'cert', label: 'IdP Signing Certificate', type: 'textarea', required: true, helpText: 'The IdP’s X.509 signing certificate (PEM body). Used to verify assertion signatures.' }, + { name: 'identifierFormat', label: 'NameID Format', type: 'text', required: false, placeholder: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', helpText: 'Optional. Requested SAML NameID format. Defaults to the IdP’s configured format.' }, + ], + }, { name: 'delete_sso_provider', label: 'Delete SSO Provider', @@ -125,7 +152,7 @@ export const SysSsoProvider = ObjectSchema.create({ // HAS a "Register SSO Provider" action. Point admins at it instead. emptyState: { title: 'No SSO providers yet', - message: 'Register your organization’s external OIDC IdP (Okta, Entra, Auth0, …) with “Register SSO Provider”. Members whose email domain matches can then sign in through it.', + message: 'Register your organization’s external IdP — OIDC (Okta, Entra, Auth0, …) with “Register SSO Provider”, or SAML 2.0 with “Register SAML Provider”. Members whose email domain matches can then sign in through it.', icon: 'log-in', }, }, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 8065277b8f..9089e3f525 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -12,7 +12,7 @@ import { import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages'; import { AuthManager, type AuthManagerOptions } from './auth-manager.js'; import { runSetInitialPassword } from './set-initial-password.js'; -import { runRegisterSsoProviderFromForm } from './register-sso-provider.js'; +import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm } from './register-sso-provider.js'; import { authIdentityObjects, authPluginManifestHeader, @@ -1064,6 +1064,26 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // ADR-0069 P3 — register a SAML 2.0 IdP. Mirrors the OIDC bridge above: + // the metadata `register_saml_provider` action posts FLAT fields; the shared + // helper reshapes them into better-auth's nested `samlConfig` (deriving the + // per-provider ACS URL) and re-dispatches through /sso/register so the + // admin gate + provisioning all run. Returns SP ACS + metadata URLs. + rawApp.post(`${basePath}/admin/sso/register-saml`, async (c: any) => { + try { + const { status, body } = await runRegisterSamlProviderFromForm( + (req) => this.authManager!.handleRequest(req), + c.req.raw, + ); + return c.json(body, status as any); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] sso/register-saml bridge failed', err); + return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500); + } + }); + // ──────────────────────────────────────────────────────────────────── // OAuth self-service: register an OAuth application for the signed-in // user. Thin wrapper over better-auth's `/oauth2/create-client` diff --git a/packages/plugins/plugin-auth/src/register-sso-provider.test.ts b/packages/plugins/plugin-auth/src/register-sso-provider.test.ts new file mode 100644 index 0000000000..de0b0c2b13 --- /dev/null +++ b/packages/plugins/plugin-auth/src/register-sso-provider.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { describe, it, expect, vi } from 'vitest'; +import { runRegisterSamlProviderFromForm } from './register-sso-provider'; + +const makeReq = (body: any) => + new Request('http://localhost:3000/api/v1/auth/admin/sso/register-saml', { + method: 'POST', + headers: { 'content-type': 'application/json', cookie: 'better-auth.session_token=abc' }, + body: JSON.stringify(body), + }); + +describe('runRegisterSamlProviderFromForm (ADR-0069 P3)', () => { + it('reshapes flat fields into nested samlConfig + derives the ACS URL, re-dispatching to /sso/register', async () => { + let dispatched: { url: string; body: any } | null = null; + const handle = vi.fn(async (req: Request) => { + dispatched = { url: req.url, body: await req.clone().json() }; + return new Response(JSON.stringify({ providerId: 'acme-saml' }), { status: 200 }); + }); + + const res = await runRegisterSamlProviderFromForm(handle, makeReq({ + providerId: 'acme-saml', + issuer: 'https://idp.acme.com/entity', + domain: 'acme.com', + entryPoint: 'https://idp.acme.com/sso', + cert: 'MIICert...', + identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + })); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.acsUrl).toBe('http://localhost:3000/api/v1/auth/sso/saml2/sp/acs/acme-saml'); + expect(res.body.spMetadataUrl).toBe('http://localhost:3000/api/v1/auth/sso/saml2/sp/metadata?providerId=acme-saml'); + // re-dispatched to the real /sso/register with the nested shape + expect(dispatched!.url).toBe('http://localhost:3000/api/v1/auth/sso/register'); + expect(dispatched!.body).toMatchObject({ + providerId: 'acme-saml', + issuer: 'https://idp.acme.com/entity', + domain: 'acme.com', + samlConfig: { + entryPoint: 'https://idp.acme.com/sso', + cert: 'MIICert...', + callbackUrl: 'http://localhost:3000/api/v1/auth/sso/saml2/sp/acs/acme-saml', + identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + spMetadata: { entityID: 'http://localhost:3000/api/v1/auth/sso/saml2/sp/metadata?providerId=acme-saml' }, + }, + }); + // forwards the caller's session cookie + // (handle saw the inner request — cookie carried through) + }); + + it('rejects with 400 when required SAML fields are missing', async () => { + const handle = vi.fn(); + const res = await runRegisterSamlProviderFromForm(handle, makeReq({ providerId: 'x', domain: 'acme.com' })); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('invalid_request'); + expect(handle).not.toHaveBeenCalled(); + }); + + it('surfaces a better-auth failure as saml_register_failed', async () => { + const handle = vi.fn(async () => new Response(JSON.stringify({ message: 'bad cert' }), { status: 400 })); + const res = await runRegisterSamlProviderFromForm(handle, makeReq({ + providerId: 'p', issuer: 'i', domain: 'd.com', entryPoint: 'e', cert: 'c', + })); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('saml_register_failed'); + expect(res.body.error?.message).toBe('bad cert'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/register-sso-provider.ts b/packages/plugins/plugin-auth/src/register-sso-provider.ts index 01a9b957fc..055f27efc7 100644 --- a/packages/plugins/plugin-auth/src/register-sso-provider.ts +++ b/packages/plugins/plugin-auth/src/register-sso-provider.ts @@ -134,3 +134,87 @@ export async function runRegisterSsoProviderFromForm( } return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId } } }; } + + +/** + * ADR-0069 P3 — SAML 2.0 sibling of {@link runRegisterSsoProviderFromForm}. + * + * `@better-auth/sso` (samlify-backed) registers a SAML IdP via the SAME + * `/sso/register` endpoint, with the protocol fields nested under `samlConfig` + * ({ entryPoint, cert, callbackUrl, identifierFormat? }) instead of `oidcConfig`. + * The UI action collects FLAT fields; this helper reshapes them, derives the + * per-provider ACS callback URL (`/sso/saml2/sp/acs/`), and + * re-dispatches through `/sso/register` so the admin gate + provisioning run. + * Returns the SP ACS + metadata URLs the admin must configure on the IdP. + */ +export async function runRegisterSamlProviderFromForm( + handle: AuthRequestHandler, + request: Request, +): Promise { + let body: any; + try { body = await request.json(); } catch { body = {}; } + const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : ''); + const providerId = str(body?.providerId); + const issuer = str(body?.issuer); + const domain = str(body?.domain); + const entryPoint = str(body?.entryPoint); + const cert = str(body?.cert); + const identifierFormat = str(body?.identifierFormat); + + const missing = ( + [ + ['providerId', providerId], + ['issuer', issuer], + ['domain', domain], + ['entryPoint', entryPoint], + ['cert', cert], + ] as const + ).filter(([, v]) => !v).map(([k]) => k); + if (missing.length) { + return { status: 400, body: { success: false, error: { code: 'invalid_request', message: `Missing required field(s): ${missing.join(', ')}` } } }; + } + + let origin: string; + let prefix: string; + let innerUrl: string; + try { + const url = new URL(request.url); + origin = url.origin; + prefix = url.pathname.replace(/\/admin\/sso\/register-saml$/, ''); + innerUrl = `${origin}${prefix}/sso/register`; + } catch { + return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } }; + } + const acsUrl = `${origin}${prefix}/sso/saml2/sp/acs/${encodeURIComponent(providerId)}`; + const spMetadataUrl = `${origin}${prefix}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(providerId)}`; + + const samlConfig: Record = { + entryPoint, + cert, + callbackUrl: acsUrl, + // better-auth requires an SP descriptor (its inner fields are optional). Use + // the SP metadata URL as our EntityID — the value the IdP keys this SP on. + spMetadata: { entityID: spMetadataUrl }, + }; + if (identifierFormat) samlConfig.identifierFormat = identifierFormat; + + const headers = new Headers({ 'content-type': 'application/json' }); + const cookie = request.headers.get('cookie'); + if (cookie) headers.set('cookie', cookie); + const authz = request.headers.get('authorization'); + if (authz) headers.set('authorization', authz); + headers.set('origin', request.headers.get('origin') || origin); + + const innerReq = new Request(innerUrl, { + method: 'POST', + headers, + body: JSON.stringify({ providerId, issuer, domain, samlConfig }), + }); + const resp = await handle(innerReq); + let parsed: any = {}; + try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; } + if (!resp.ok) { + return { status: resp.status, body: { success: false, error: { code: 'saml_register_failed', message: parsed?.message || 'SAML provider registration failed' } } }; + } + return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId }, acsUrl, spMetadataUrl } }; +}