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/adr-0024-sso-register-form.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 16 additions & 5 deletions packages/platform-objects/src/identity/sys-sso-provider.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<issuer>/.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".' },
],
},
{
Expand Down
30 changes: 30 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
136 changes: 136 additions & 0 deletions packages/plugins/plugin-auth/src/register-sso-provider.ts
Original file line number Diff line number Diff line change
@@ -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<Response>;

/**
* 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<RegisterSsoFormResult> {
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<string, unknown> = { 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 } } };
}