Skip to content

Commit 4f8f108

Browse files
os-zhuangclaude
andauthored
fix(auth): make the SSO-provider registration form produce a usable IdP (ADR-0024 / cloud#551) (#2386)
The sys_sso_provider `register_sso_provider` UI action posted FLAT form fields to @better-auth/sso's /sso/register, which expects the OIDC fields NESTED under `oidcConfig`. Top-level clientId/clientSecret were Zod-stripped, so the form persisted an `oidc_config = null` provider that could never complete a login. - plugin-auth: shared `runRegisterSsoProviderFromForm` helper reshapes the flat body to nested and re-dispatches through the real /sso/register (so the admin gate + public-IdP trustedOrigins allowance + discovery hydration run); exposed via a new /admin/sso/register bridge on the host AuthPlugin. The cloud per-env AuthProxyPlugin mounts the same helper (mirrors set-initial-password). - platform-objects: register action retargets to /admin/sso/register and gains discoveryEndpoint, scopes, and attribute-mapping (mapId/mapEmail/mapName) fields. Keeps runtime self-service IdP registration in the OSS edition (open mechanism). Verified E2E: admin registers an IdP from the flat form → a member logs in through it (JIT, sys_account.provider_id set); a non-admin is rejected 403. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3e593a7 commit 4f8f108

5 files changed

Lines changed: 196 additions & 5 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
Auth: make the open-source SSO-provider registration form produce a usable IdP (ADR-0024 / cloud#551)
7+
8+
The `sys_sso_provider` `register_sso_provider` UI action posted FLAT form fields to `@better-auth/sso`'s `/sso/register`, which expects the OIDC fields NESTED under `oidcConfig`. The top-level `clientId`/`clientSecret` were Zod-stripped, so the form persisted an `oidc_config = null` provider that could never complete a login ("Invalid SSO provider").
9+
10+
- **plugin-auth**: new shared `runRegisterSsoProviderFromForm` helper reshapes the flat form body into the nested shape and re-dispatches it through the real `/sso/register` (so the admin gate, the public-routable `trustedOrigins` allowance, discovery hydration, and secret handling all still run). Exposed via a new `/admin/sso/register` bridge route on the host `AuthPlugin`. (The cloud per-env runtime mounts the same helper in its `AuthProxyPlugin` — mirrors `set-initial-password`.)
11+
- **platform-objects**: `register_sso_provider` retargets to `/api/v1/auth/admin/sso/register` and gains `discoveryEndpoint`, `scopes`, and attribute-mapping (`mapId`/`mapEmail`/`mapName`) fields. Open mechanism — keeps runtime IdP registration self-service in the OSS edition.
12+
13+
Verified E2E: an admin registers an external OIDC IdP from the flat form → a member logs in through it (JIT-provisioned, `sys_account.provider_id` set); a non-admin is rejected (403) before discovery runs.

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,25 @@ export const SysSsoProvider = ObjectSchema.create({
5555
locations: ['list_toolbar'],
5656
type: 'api',
5757
method: 'POST',
58-
target: '/api/v1/auth/sso/register',
58+
// Routed through the env-side bridge (plugin-auth `auth-plugin.ts`), which
59+
// reshapes these FLAT form fields into the nested `oidcConfig` body that
60+
// `@better-auth/sso`'s /sso/register requires, then re-dispatches to it
61+
// (so the admin gate + discovery hydration run). Posting straight to
62+
// /sso/register would drop clientId/clientSecret (top-level → Zod-stripped)
63+
// and persist an unusable `oidc_config = null` provider.
64+
target: '/api/v1/auth/admin/sso/register',
5965
refreshAfter: true,
6066
params: [
6167
{ name: 'providerId', label: 'Provider ID', type: 'text', required: true, helpText: 'Stable identifier, e.g. "okta" or "acme-entra".' },
62-
{ name: 'issuer', label: 'Issuer URL', type: 'text', required: true, helpText: 'IdP issuer / discovery base, e.g. https://acme.okta.com.' },
63-
{ name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP.' },
64-
{ name: 'clientId', label: 'Client ID', type: 'text', required: true },
65-
{ name: 'clientSecret', label: 'Client Secret', type: 'text', required: true },
68+
{ name: 'issuer', label: 'Issuer URL', type: 'text', required: true, helpText: 'IdP issuer, e.g. https://acme.okta.com. Discovery is fetched from here unless an explicit URL is given below.' },
69+
{ name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP, e.g. acme.com.' },
70+
{ name: 'clientId', label: 'Client ID', type: 'text', required: true, helpText: 'OAuth client ID issued by the IdP for this environment.' },
71+
{ name: 'clientSecret', label: 'Client Secret', type: 'text', required: true, helpText: 'OAuth client secret (stored encrypted by better-auth).' },
72+
{ name: 'discoveryEndpoint', label: 'Discovery URL', type: 'text', required: false, helpText: 'Optional. OIDC discovery document URL. Leave blank to derive `<issuer>/.well-known/openid-configuration`.' },
73+
{ name: 'scopes', label: 'Scopes', type: 'text', required: false, placeholder: 'openid email profile', helpText: 'Optional. Space- or comma-separated OAuth scopes. Defaults to "openid email profile".' },
74+
{ name: 'mapId', label: 'Map: User ID claim', type: 'text', required: false, placeholder: 'sub', helpText: 'Optional. ID-token claim mapped to the user ID. Defaults to "sub".' },
75+
{ name: 'mapEmail', label: 'Map: Email claim', type: 'text', required: false, placeholder: 'email', helpText: 'Optional. Claim mapped to email. Defaults to "email".' },
76+
{ name: 'mapName', label: 'Map: Name claim', type: 'text', required: false, placeholder: 'name', helpText: 'Optional. Claim mapped to display name. Defaults to "name".' },
6677
],
6778
},
6879
{

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +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';
1516
import {
1617
authIdentityObjects,
1718
authPluginManifestHeader,
@@ -884,6 +885,35 @@ export class AuthPlugin implements Plugin {
884885
}
885886
});
886887

888+
// ────────────────────────────────────────────────────────────────────
889+
// SSO admin: register an external OIDC IdP from the flat metadata form
890+
// (ADR-0024). `@better-auth/sso`'s POST /sso/register expects the protocol
891+
// fields NESTED under `oidcConfig` ({ clientId, clientSecret,
892+
// discoveryEndpoint, scopes, mapping }). The `sys_sso_provider`
893+
// `register_sso_provider` action collects FLAT form fields (the action
894+
// param schema has no nested-path support), so posting them straight to
895+
// /sso/register lands them at the top level where better-auth's Zod schema
896+
// strips them → a provider with `oidc_config = null` that can never
897+
// complete a login. This thin bridge reshapes the flat form body into the
898+
// nested shape and RE-DISPATCHES it through the real /sso/register endpoint
899+
// (via the better-auth handler) so the admin gate, the public-routable
900+
// trustedOrigins allowance, discovery hydration, and secret handling all
901+
// still run. No bespoke persistence. Retire when the action framework
902+
// gains nested-param support.
903+
rawApp.post(`${basePath}/admin/sso/register`, async (c: any) => {
904+
try {
905+
const { status, body } = await runRegisterSsoProviderFromForm(
906+
(req) => this.authManager!.handleRequest(req),
907+
c.req.raw,
908+
);
909+
return c.json(body, status as any);
910+
} catch (error) {
911+
const err = error instanceof Error ? error : new Error(String(error));
912+
ctx.logger.error('[AuthPlugin] sso/register bridge failed', err);
913+
return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500);
914+
}
915+
});
916+
887917
// ────────────────────────────────────────────────────────────────────
888918
// ADR-0069 D2 — admin: clear a brute-force lockout on an account.
889919
// Lockout (`sys_user.locked_until` / `failed_login_count`) is a custom,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
export * from './auth-plugin.js';
1212
export * from './auth-manager.js';
1313
export * from './set-initial-password.js';
14+
export * from './register-sso-provider.js';
1415
export * from './objectql-adapter.js';
1516
export * from './auth-schema-config.js';
1617
export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system';
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Shared `register-sso-provider` (form) handler.
5+
*
6+
* `@better-auth/sso`'s `POST /sso/register` expects the OIDC protocol fields
7+
* NESTED under `oidcConfig` ({ clientId, clientSecret, discoveryEndpoint,
8+
* scopes, mapping }). The `sys_sso_provider` `register_sso_provider` UI action
9+
* collects FLAT form fields (the action param schema has no nested-path
10+
* support), so posting them straight to `/sso/register` drops
11+
* clientId/clientSecret at the top level (Zod-stripped) and persists an
12+
* unusable `oidc_config = null` provider that can never complete a login
13+
* (ADR-0024).
14+
*
15+
* This helper reshapes the flat form body into the nested shape and
16+
* RE-DISPATCHES it through the real `/sso/register` endpoint (via the
17+
* better-auth universal handler passed in) so the admin gate, the
18+
* public-routable `trustedOrigins` allowance, discovery hydration, and secret
19+
* handling all still run — no logic is duplicated. It is the single source of
20+
* truth for the two mount points that must stay in lockstep: the full
21+
* `AuthPlugin` (self-host / OSS host kernel) and the cloud `AuthProxyPlugin`
22+
* (per-environment runtime) — mirroring `runSetInitialPassword`.
23+
*/
24+
25+
export interface RegisterSsoFormResult {
26+
/** HTTP status to return to the caller. */
27+
status: number;
28+
/** JSON body; mirrors the `{ success, data?, error? }` envelope the client parses. */
29+
body: {
30+
success: boolean;
31+
data?: { providerId: string };
32+
error?: { code: string; message: string };
33+
};
34+
}
35+
36+
/** A better-auth universal handler: `(request) => Response`. */
37+
export type AuthRequestHandler = (request: Request) => Promise<Response>;
38+
39+
/**
40+
* Reshape a flat SSO-provider registration form body and register it.
41+
*
42+
* @param handle the better-auth universal handler (`AuthManager.handleRequest`
43+
* on the host kernel, or the resolved per-env handler in the
44+
* cloud proxy). Used to re-dispatch the nested body to the real
45+
* `/sso/register` route so all of its gates run.
46+
* @param request the raw Web `Request` — its headers carry the caller's session
47+
* cookie / bearer + Origin; its body carries the flat form
48+
* fields ({ providerId, issuer, domain, clientId, clientSecret,
49+
* discoveryEndpoint?, scopes?, mapId?, mapEmail?, mapName? }).
50+
*/
51+
export async function runRegisterSsoProviderFromForm(
52+
handle: AuthRequestHandler,
53+
request: Request,
54+
): Promise<RegisterSsoFormResult> {
55+
let body: any;
56+
try {
57+
body = await request.json();
58+
} catch {
59+
body = {};
60+
}
61+
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
62+
const providerId = str(body?.providerId);
63+
const issuer = str(body?.issuer);
64+
const domain = str(body?.domain);
65+
const clientId = str(body?.clientId);
66+
const clientSecret = str(body?.clientSecret);
67+
const discoveryEndpoint = str(body?.discoveryEndpoint);
68+
const scopesRaw = str(body?.scopes);
69+
70+
const missing = (
71+
[
72+
['providerId', providerId],
73+
['issuer', issuer],
74+
['domain', domain],
75+
['clientId', clientId],
76+
['clientSecret', clientSecret],
77+
] as const
78+
)
79+
.filter(([, v]) => !v)
80+
.map(([k]) => k);
81+
if (missing.length) {
82+
return {
83+
status: 400,
84+
body: { success: false, error: { code: 'invalid_request', message: `Missing required field(s): ${missing.join(', ')}` } },
85+
};
86+
}
87+
88+
const oidcConfig: Record<string, unknown> = { clientId, clientSecret };
89+
if (discoveryEndpoint) oidcConfig.discoveryEndpoint = discoveryEndpoint;
90+
oidcConfig.scopes = scopesRaw ? scopesRaw.split(/[\s,]+/).filter(Boolean) : ['openid', 'email', 'profile'];
91+
oidcConfig.mapping = {
92+
id: str(body?.mapId) || 'sub',
93+
email: str(body?.mapEmail) || 'email',
94+
name: str(body?.mapName) || 'name',
95+
};
96+
97+
// Re-dispatch to the real /sso/register (same origin, sibling path) so the
98+
// admin gate + public-IdP trustedOrigins allowance + discovery hydration run.
99+
let innerUrl: string;
100+
let origin: string;
101+
try {
102+
const url = new URL(request.url);
103+
origin = url.origin;
104+
innerUrl = `${origin}${url.pathname.replace(/\/admin\/sso\/register$/, '/sso/register')}`;
105+
} catch {
106+
return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } };
107+
}
108+
const headers = new Headers({ 'content-type': 'application/json' });
109+
const cookie = request.headers.get('cookie');
110+
if (cookie) headers.set('cookie', cookie);
111+
const authz = request.headers.get('authorization');
112+
if (authz) headers.set('authorization', authz);
113+
headers.set('origin', request.headers.get('origin') || origin);
114+
115+
const innerReq = new Request(innerUrl, {
116+
method: 'POST',
117+
headers,
118+
body: JSON.stringify({ providerId, issuer, domain, oidcConfig }),
119+
});
120+
121+
const resp = await handle(innerReq);
122+
let parsed: any = {};
123+
try {
124+
const t = await resp.text();
125+
parsed = t ? JSON.parse(t) : {};
126+
} catch {
127+
parsed = {};
128+
}
129+
if (!resp.ok) {
130+
return {
131+
status: resp.status,
132+
body: { success: false, error: { code: 'sso_register_failed', message: parsed?.message || 'SSO provider registration failed' } },
133+
};
134+
}
135+
return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId } } };
136+
}

0 commit comments

Comments
 (0)