Skip to content

Commit 6e5bdd5

Browse files
os-zhuangclaude
andauthored
feat(plugin-auth): SAML 2.0 SSO via @better-auth/sso (ADR-0069 P3) (#2404)
`@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/<providerId>`), 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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a053fd6 commit 6e5bdd5

6 files changed

Lines changed: 228 additions & 2 deletions

File tree

.changeset/adr0069-saml-sso.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
"@objectstack/platform-objects": minor
4+
---
5+
6+
feat(auth): SAML 2.0 SSO via @better-auth/sso (ADR-0069 P3)
7+
8+
`@better-auth/sso@1.6.20` ships full SAML 2.0 (samlify-backed), so SAML needs no
9+
custom plugin. Adds a `register_saml_provider` action on `sys_sso_provider` and a
10+
`runRegisterSamlProviderFromForm` bridge that reshapes the flat admin form into the
11+
nested `samlConfig` and re-dispatches through `/sso/register` (admin gate enforced),
12+
returning the SP ACS + metadata URLs to configure on the IdP. Updates ADR-0069 to
13+
correct the stale "SAML is out of better-auth core" premise.

docs/adr/0069-enterprise-authentication-hardening.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,17 @@ Each row in D1-D6 names exactly one of these seams. No setting is introduced wit
149149
- **Adopt the settings fields now, wire enforcement later** — rejected: violates ADR-0049 (false surface). Fields and their enforcement land together.
150150
- **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.
151151
- **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.
152+
153+
154+
---
155+
156+
## Addendum (2026-06): SAML is now better-auth-native — premise updated
157+
158+
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.
159+
160+
Consequently SAML did **not** require a custom plugin. What shipped (D6/P3):
161+
162+
- 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).
163+
- 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/<providerId>`), 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.
164+
165+
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.

packages/platform-objects/src/identity/sys-sso-provider.object.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,33 @@ export const SysSsoProvider = ObjectSchema.create({
9292
{ name: 'mapName', label: 'Map: Name claim', type: 'text', required: false, placeholder: 'name', helpText: 'Optional. Claim mapped to display name. Defaults to "name".' },
9393
],
9494
},
95+
{
96+
name: 'register_saml_provider',
97+
label: 'Register SAML Provider',
98+
icon: 'shield',
99+
variant: 'primary',
100+
mode: 'create',
101+
locations: ['list_toolbar'],
102+
type: 'api',
103+
method: 'POST',
104+
// SAML 2.0 via @better-auth/sso (samlify-backed). Routed through the
105+
// env-side bridge (plugin-auth `auth-plugin.ts` → register-saml), which
106+
// reshapes these FLAT IdP fields into the nested `samlConfig` body that
107+
// @better-auth/sso's /sso/register requires (entryPoint/cert/callbackUrl/
108+
// spMetadata/identifierFormat), derives the per-provider ACS URL, and
109+
// re-dispatches to /sso/register (admin gate runs). The response returns
110+
// the SP ACS + metadata URLs to configure on the IdP.
111+
target: '/api/v1/auth/admin/sso/register-saml',
112+
refreshAfter: true,
113+
params: [
114+
{ name: 'providerId', label: 'Provider ID', type: 'text', required: true, helpText: 'Stable identifier, e.g. "acme-saml".' },
115+
{ name: 'issuer', label: 'IdP Entity ID', type: 'text', required: true, helpText: 'The IdP’s SAML EntityID (issuer), e.g. https://saml.acme.com/entityid.' },
116+
{ name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP, e.g. acme.com.' },
117+
{ name: 'entryPoint', label: 'IdP SSO URL', type: 'text', required: true, helpText: 'The IdP’s SAML single sign-on (redirect) endpoint that receives the SAMLRequest.' },
118+
{ 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.' },
119+
{ 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.' },
120+
],
121+
},
95122
{
96123
name: 'delete_sso_provider',
97124
label: 'Delete SSO Provider',
@@ -125,7 +152,7 @@ export const SysSsoProvider = ObjectSchema.create({
125152
// HAS a "Register SSO Provider" action. Point admins at it instead.
126153
emptyState: {
127154
title: 'No SSO providers yet',
128-
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.',
155+
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.',
129156
icon: 'log-in',
130157
},
131158
},

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages';
1313
import { AuthManager, type AuthManagerOptions } from './auth-manager.js';
1414
import { runSetInitialPassword } from './set-initial-password.js';
15-
import { runRegisterSsoProviderFromForm } from './register-sso-provider.js';
15+
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm } from './register-sso-provider.js';
1616
import {
1717
authIdentityObjects,
1818
authPluginManifestHeader,
@@ -1064,6 +1064,26 @@ export class AuthPlugin implements Plugin {
10641064
}
10651065
});
10661066

1067+
// ────────────────────────────────────────────────────────────────────
1068+
// ADR-0069 P3 — register a SAML 2.0 IdP. Mirrors the OIDC bridge above:
1069+
// the metadata `register_saml_provider` action posts FLAT fields; the shared
1070+
// helper reshapes them into better-auth's nested `samlConfig` (deriving the
1071+
// per-provider ACS URL) and re-dispatches through /sso/register so the
1072+
// admin gate + provisioning all run. Returns SP ACS + metadata URLs.
1073+
rawApp.post(`${basePath}/admin/sso/register-saml`, async (c: any) => {
1074+
try {
1075+
const { status, body } = await runRegisterSamlProviderFromForm(
1076+
(req) => this.authManager!.handleRequest(req),
1077+
c.req.raw,
1078+
);
1079+
return c.json(body, status as any);
1080+
} catch (error) {
1081+
const err = error instanceof Error ? error : new Error(String(error));
1082+
ctx.logger.error('[AuthPlugin] sso/register-saml bridge failed', err);
1083+
return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500);
1084+
}
1085+
});
1086+
10671087
// ────────────────────────────────────────────────────────────────────
10681088
// OAuth self-service: register an OAuth application for the signed-in
10691089
// user. Thin wrapper over better-auth's `/oauth2/create-client`
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { runRegisterSamlProviderFromForm } from './register-sso-provider';
4+
5+
const makeReq = (body: any) =>
6+
new Request('http://localhost:3000/api/v1/auth/admin/sso/register-saml', {
7+
method: 'POST',
8+
headers: { 'content-type': 'application/json', cookie: 'better-auth.session_token=abc' },
9+
body: JSON.stringify(body),
10+
});
11+
12+
describe('runRegisterSamlProviderFromForm (ADR-0069 P3)', () => {
13+
it('reshapes flat fields into nested samlConfig + derives the ACS URL, re-dispatching to /sso/register', async () => {
14+
let dispatched: { url: string; body: any } | null = null;
15+
const handle = vi.fn(async (req: Request) => {
16+
dispatched = { url: req.url, body: await req.clone().json() };
17+
return new Response(JSON.stringify({ providerId: 'acme-saml' }), { status: 200 });
18+
});
19+
20+
const res = await runRegisterSamlProviderFromForm(handle, makeReq({
21+
providerId: 'acme-saml',
22+
issuer: 'https://idp.acme.com/entity',
23+
domain: 'acme.com',
24+
entryPoint: 'https://idp.acme.com/sso',
25+
cert: 'MIICert...',
26+
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
27+
}));
28+
29+
expect(res.status).toBe(200);
30+
expect(res.body.success).toBe(true);
31+
expect(res.body.acsUrl).toBe('http://localhost:3000/api/v1/auth/sso/saml2/sp/acs/acme-saml');
32+
expect(res.body.spMetadataUrl).toBe('http://localhost:3000/api/v1/auth/sso/saml2/sp/metadata?providerId=acme-saml');
33+
// re-dispatched to the real /sso/register with the nested shape
34+
expect(dispatched!.url).toBe('http://localhost:3000/api/v1/auth/sso/register');
35+
expect(dispatched!.body).toMatchObject({
36+
providerId: 'acme-saml',
37+
issuer: 'https://idp.acme.com/entity',
38+
domain: 'acme.com',
39+
samlConfig: {
40+
entryPoint: 'https://idp.acme.com/sso',
41+
cert: 'MIICert...',
42+
callbackUrl: 'http://localhost:3000/api/v1/auth/sso/saml2/sp/acs/acme-saml',
43+
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
44+
spMetadata: { entityID: 'http://localhost:3000/api/v1/auth/sso/saml2/sp/metadata?providerId=acme-saml' },
45+
},
46+
});
47+
// forwards the caller's session cookie
48+
// (handle saw the inner request — cookie carried through)
49+
});
50+
51+
it('rejects with 400 when required SAML fields are missing', async () => {
52+
const handle = vi.fn();
53+
const res = await runRegisterSamlProviderFromForm(handle, makeReq({ providerId: 'x', domain: 'acme.com' }));
54+
expect(res.status).toBe(400);
55+
expect(res.body.error?.code).toBe('invalid_request');
56+
expect(handle).not.toHaveBeenCalled();
57+
});
58+
59+
it('surfaces a better-auth failure as saml_register_failed', async () => {
60+
const handle = vi.fn(async () => new Response(JSON.stringify({ message: 'bad cert' }), { status: 400 }));
61+
const res = await runRegisterSamlProviderFromForm(handle, makeReq({
62+
providerId: 'p', issuer: 'i', domain: 'd.com', entryPoint: 'e', cert: 'c',
63+
}));
64+
expect(res.status).toBe(400);
65+
expect(res.body.error?.code).toBe('saml_register_failed');
66+
expect(res.body.error?.message).toBe('bad cert');
67+
});
68+
});

packages/plugins/plugin-auth/src/register-sso-provider.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,87 @@ export async function runRegisterSsoProviderFromForm(
134134
}
135135
return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId } } };
136136
}
137+
138+
139+
/**
140+
* ADR-0069 P3 — SAML 2.0 sibling of {@link runRegisterSsoProviderFromForm}.
141+
*
142+
* `@better-auth/sso` (samlify-backed) registers a SAML IdP via the SAME
143+
* `/sso/register` endpoint, with the protocol fields nested under `samlConfig`
144+
* ({ entryPoint, cert, callbackUrl, identifierFormat? }) instead of `oidcConfig`.
145+
* The UI action collects FLAT fields; this helper reshapes them, derives the
146+
* per-provider ACS callback URL (`/sso/saml2/sp/acs/<providerId>`), and
147+
* re-dispatches through `/sso/register` so the admin gate + provisioning run.
148+
* Returns the SP ACS + metadata URLs the admin must configure on the IdP.
149+
*/
150+
export async function runRegisterSamlProviderFromForm(
151+
handle: AuthRequestHandler,
152+
request: Request,
153+
): Promise<RegisterSsoFormResult & { body: RegisterSsoFormResult['body'] & { acsUrl?: string; spMetadataUrl?: string } }> {
154+
let body: any;
155+
try { body = await request.json(); } catch { body = {}; }
156+
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
157+
const providerId = str(body?.providerId);
158+
const issuer = str(body?.issuer);
159+
const domain = str(body?.domain);
160+
const entryPoint = str(body?.entryPoint);
161+
const cert = str(body?.cert);
162+
const identifierFormat = str(body?.identifierFormat);
163+
164+
const missing = (
165+
[
166+
['providerId', providerId],
167+
['issuer', issuer],
168+
['domain', domain],
169+
['entryPoint', entryPoint],
170+
['cert', cert],
171+
] as const
172+
).filter(([, v]) => !v).map(([k]) => k);
173+
if (missing.length) {
174+
return { status: 400, body: { success: false, error: { code: 'invalid_request', message: `Missing required field(s): ${missing.join(', ')}` } } };
175+
}
176+
177+
let origin: string;
178+
let prefix: string;
179+
let innerUrl: string;
180+
try {
181+
const url = new URL(request.url);
182+
origin = url.origin;
183+
prefix = url.pathname.replace(/\/admin\/sso\/register-saml$/, '');
184+
innerUrl = `${origin}${prefix}/sso/register`;
185+
} catch {
186+
return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } };
187+
}
188+
const acsUrl = `${origin}${prefix}/sso/saml2/sp/acs/${encodeURIComponent(providerId)}`;
189+
const spMetadataUrl = `${origin}${prefix}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(providerId)}`;
190+
191+
const samlConfig: Record<string, unknown> = {
192+
entryPoint,
193+
cert,
194+
callbackUrl: acsUrl,
195+
// better-auth requires an SP descriptor (its inner fields are optional). Use
196+
// the SP metadata URL as our EntityID — the value the IdP keys this SP on.
197+
spMetadata: { entityID: spMetadataUrl },
198+
};
199+
if (identifierFormat) samlConfig.identifierFormat = identifierFormat;
200+
201+
const headers = new Headers({ 'content-type': 'application/json' });
202+
const cookie = request.headers.get('cookie');
203+
if (cookie) headers.set('cookie', cookie);
204+
const authz = request.headers.get('authorization');
205+
if (authz) headers.set('authorization', authz);
206+
headers.set('origin', request.headers.get('origin') || origin);
207+
208+
const innerReq = new Request(innerUrl, {
209+
method: 'POST',
210+
headers,
211+
body: JSON.stringify({ providerId, issuer, domain, samlConfig }),
212+
});
213+
const resp = await handle(innerReq);
214+
let parsed: any = {};
215+
try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; }
216+
if (!resp.ok) {
217+
return { status: resp.status, body: { success: false, error: { code: 'saml_register_failed', message: parsed?.message || 'SAML provider registration failed' } } };
218+
}
219+
return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId }, acsUrl, spMetadataUrl } };
220+
}

0 commit comments

Comments
 (0)