diff --git a/.changeset/adr-0024-sso-register-form.md b/.changeset/adr-0024-sso-register-form.md new file mode 100644 index 0000000000..79543f75f9 --- /dev/null +++ b/.changeset/adr-0024-sso-register-form.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/platform-objects': patch +--- + +Auth: make the open-source SSO-provider registration form produce a usable IdP (ADR-0024 / cloud#551) + +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"). + +- **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`.) +- **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. + +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. 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 8b4cdc39eb..f2297605d2 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -55,14 +55,25 @@ export const SysSsoProvider = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', method: 'POST', - target: '/api/v1/auth/sso/register', + // Routed through the env-side bridge (plugin-auth `auth-plugin.ts`), which + // reshapes these FLAT form fields into the nested `oidcConfig` body that + // `@better-auth/sso`'s /sso/register requires, then re-dispatches to it + // (so the admin gate + discovery hydration run). Posting straight to + // /sso/register would drop clientId/clientSecret (top-level → Zod-stripped) + // and persist an unusable `oidc_config = null` provider. + target: '/api/v1/auth/admin/sso/register', refreshAfter: true, params: [ { name: 'providerId', label: 'Provider ID', type: 'text', required: true, helpText: 'Stable identifier, e.g. "okta" or "acme-entra".' }, - { name: 'issuer', label: 'Issuer URL', type: 'text', required: true, helpText: 'IdP issuer / discovery base, e.g. https://acme.okta.com.' }, - { name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP.' }, - { name: 'clientId', label: 'Client ID', type: 'text', required: true }, - { name: 'clientSecret', label: 'Client Secret', type: 'text', required: true }, + { 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.' }, + { 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: 'clientId', label: 'Client ID', type: 'text', required: true, helpText: 'OAuth client ID issued by the IdP for this environment.' }, + { name: 'clientSecret', label: 'Client Secret', type: 'text', required: true, helpText: 'OAuth client secret (stored encrypted by better-auth).' }, + { name: 'discoveryEndpoint', label: 'Discovery URL', type: 'text', required: false, helpText: 'Optional. OIDC discovery document URL. Leave blank to derive `/.well-known/openid-configuration`.' }, + { 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".' }, + { 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".' }, + { name: 'mapEmail', label: 'Map: Email claim', type: 'text', required: false, placeholder: 'email', helpText: 'Optional. Claim mapped to email. Defaults to "email".' }, + { name: 'mapName', label: 'Map: Name claim', type: 'text', required: false, placeholder: 'name', helpText: 'Optional. Claim mapped to display name. Defaults to "name".' }, ], }, { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 632f114c30..1db62d8650 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -12,6 +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 { authIdentityObjects, authPluginManifestHeader, @@ -884,6 +885,35 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // SSO admin: register an external OIDC IdP from the flat metadata form + // (ADR-0024). `@better-auth/sso`'s POST /sso/register expects the protocol + // fields NESTED under `oidcConfig` ({ clientId, clientSecret, + // discoveryEndpoint, scopes, mapping }). The `sys_sso_provider` + // `register_sso_provider` action collects FLAT form fields (the action + // param schema has no nested-path support), so posting them straight to + // /sso/register lands them at the top level where better-auth's Zod schema + // strips them → a provider with `oidc_config = null` that can never + // complete a login. This thin bridge reshapes the flat form body into the + // nested shape and RE-DISPATCHES it through the real /sso/register endpoint + // (via the better-auth handler) so the admin gate, the public-routable + // trustedOrigins allowance, discovery hydration, and secret handling all + // still run. No bespoke persistence. Retire when the action framework + // gains nested-param support. + rawApp.post(`${basePath}/admin/sso/register`, async (c: any) => { + try { + const { status, body } = await runRegisterSsoProviderFromForm( + (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 bridge failed', err); + return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500); + } + }); + // ──────────────────────────────────────────────────────────────────── // ADR-0069 D2 — admin: clear a brute-force lockout on an account. // Lockout (`sys_user.locked_until` / `failed_login_count`) is a custom, diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index d6e37b4e64..355e5cd794 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -11,6 +11,7 @@ export * from './auth-plugin.js'; export * from './auth-manager.js'; export * from './set-initial-password.js'; +export * from './register-sso-provider.js'; export * from './objectql-adapter.js'; export * from './auth-schema-config.js'; export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system'; diff --git a/packages/plugins/plugin-auth/src/register-sso-provider.ts b/packages/plugins/plugin-auth/src/register-sso-provider.ts new file mode 100644 index 0000000000..01a9b957fc --- /dev/null +++ b/packages/plugins/plugin-auth/src/register-sso-provider.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Shared `register-sso-provider` (form) handler. + * + * `@better-auth/sso`'s `POST /sso/register` expects the OIDC protocol fields + * NESTED under `oidcConfig` ({ clientId, clientSecret, discoveryEndpoint, + * scopes, mapping }). The `sys_sso_provider` `register_sso_provider` UI action + * collects FLAT form fields (the action param schema has no nested-path + * support), so posting them straight to `/sso/register` drops + * clientId/clientSecret at the top level (Zod-stripped) and persists an + * unusable `oidc_config = null` provider that can never complete a login + * (ADR-0024). + * + * This helper reshapes the flat form body into the nested shape and + * RE-DISPATCHES it through the real `/sso/register` endpoint (via the + * better-auth universal handler passed in) so the admin gate, the + * public-routable `trustedOrigins` allowance, discovery hydration, and secret + * handling all still run — no logic is duplicated. It is the single source of + * truth for the two mount points that must stay in lockstep: the full + * `AuthPlugin` (self-host / OSS host kernel) and the cloud `AuthProxyPlugin` + * (per-environment runtime) — mirroring `runSetInitialPassword`. + */ + +export interface RegisterSsoFormResult { + /** HTTP status to return to the caller. */ + status: number; + /** JSON body; mirrors the `{ success, data?, error? }` envelope the client parses. */ + body: { + success: boolean; + data?: { providerId: string }; + error?: { code: string; message: string }; + }; +} + +/** A better-auth universal handler: `(request) => Response`. */ +export type AuthRequestHandler = (request: Request) => Promise; + +/** + * Reshape a flat SSO-provider registration form body and register it. + * + * @param handle the better-auth universal handler (`AuthManager.handleRequest` + * on the host kernel, or the resolved per-env handler in the + * cloud proxy). Used to re-dispatch the nested body to the real + * `/sso/register` route so all of its gates run. + * @param request the raw Web `Request` — its headers carry the caller's session + * cookie / bearer + Origin; its body carries the flat form + * fields ({ providerId, issuer, domain, clientId, clientSecret, + * discoveryEndpoint?, scopes?, mapId?, mapEmail?, mapName? }). + */ +export async function runRegisterSsoProviderFromForm( + 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 clientId = str(body?.clientId); + const clientSecret = str(body?.clientSecret); + const discoveryEndpoint = str(body?.discoveryEndpoint); + const scopesRaw = str(body?.scopes); + + const missing = ( + [ + ['providerId', providerId], + ['issuer', issuer], + ['domain', domain], + ['clientId', clientId], + ['clientSecret', clientSecret], + ] 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(', ')}` } }, + }; + } + + const oidcConfig: Record = { clientId, clientSecret }; + if (discoveryEndpoint) oidcConfig.discoveryEndpoint = discoveryEndpoint; + oidcConfig.scopes = scopesRaw ? scopesRaw.split(/[\s,]+/).filter(Boolean) : ['openid', 'email', 'profile']; + oidcConfig.mapping = { + id: str(body?.mapId) || 'sub', + email: str(body?.mapEmail) || 'email', + name: str(body?.mapName) || 'name', + }; + + // Re-dispatch to the real /sso/register (same origin, sibling path) so the + // admin gate + public-IdP trustedOrigins allowance + discovery hydration run. + let innerUrl: string; + let origin: string; + try { + const url = new URL(request.url); + origin = url.origin; + innerUrl = `${origin}${url.pathname.replace(/\/admin\/sso\/register$/, '/sso/register')}`; + } catch { + return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } }; + } + 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, oidcConfig }), + }); + + 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: 'sso_register_failed', message: parsed?.message || 'SSO provider registration failed' } }, + }; + } + return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId } } }; +}