Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/adr0069-saml-sso.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions docs/adr/0069-enterprise-authentication-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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.

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.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
},
},
Expand Down
22 changes: 21 additions & 1 deletion packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`
Expand Down
68 changes: 68 additions & 0 deletions packages/plugins/plugin-auth/src/register-sso-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
84 changes: 84 additions & 0 deletions packages/plugins/plugin-auth/src/register-sso-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<providerId>`), 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<RegisterSsoFormResult & { body: RegisterSsoFormResult['body'] & { acsUrl?: string; spMetadataUrl?: string } }> {
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<string, unknown> = {
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 } };
}