diff --git a/.changeset/adr-0024-sso-domain-verification.md b/.changeset/adr-0024-sso-domain-verification.md new file mode 100644 index 0000000000..ad3119afbd --- /dev/null +++ b/.changeset/adr-0024-sso-domain-verification.md @@ -0,0 +1,26 @@ +--- +"@objectstack/plugin-auth": minor +"@objectstack/platform-objects": minor +--- + +feat(auth): opt-in SSO domain verification (ADR-0024 ②) + +Add DNS-TXT domain-ownership verification for external SSO providers, gated +behind a new `OS_SSO_DOMAIN_VERIFICATION` flag (off by default — today's +register→login behavior is unchanged). When enabled, `@better-auth/sso` mounts +`/sso/request-domain-verification` + `/sso/verify-domain` and enforces that a +provider's email domain be DNS-verified before it may complete a login. + +- `auth-manager.ts`: new `ssoDomainVerification` enabled-flag (readBooleanEnv) → + passes `domainVerification: { enabled: true }` to `sso()`; public + `isSsoDomainVerificationEnabled()` helper. +- `register-sso-provider.ts`: `runRequestDomainVerification` / + `runVerifyDomain` bridges — re-dispatch through the gated better-auth + endpoints and reshape the response into the `{ success, data }` envelope the + `sys_sso_provider` action `resultDialog` reads (request → ready-to-paste DNS + TXT record; verify → clear success/error). A bare 404 from the inner endpoint + is surfaced as "not enabled for this environment". +- `auth-plugin.ts`: mount the two bridges as rawApp routes + (`/admin/sso/{request-domain-verification,verify-domain}`). +- `sys_sso_provider`: `domain_verified` field + list column + the two actions; + `domainVerified` documented in `AUTH_SSO_PROVIDER_SCHEMA`. 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 f376709d36..b6c601699a 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -119,6 +119,56 @@ export const SysSsoProvider = ObjectSchema.create({ { 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: 'request_domain_verification', + label: 'Request Domain Verification', + icon: 'globe', + variant: 'secondary', + locations: ['list_item', 'record_header'], + type: 'api', + method: 'POST', + // ADR-0024 ② (opt-in OS_SSO_DOMAIN_VERIFICATION). Asks @better-auth/sso + // for a one-time DNS-TXT challenge and reveals the ready-to-paste record + // ONCE via `resultDialog`. Routed through the env bridge (plugin-auth / + // cloud AuthProxyPlugin) which reshapes the `{domainVerificationToken}` + // response into the `{ data: { dnsRecordName, dnsRecordValue } }` envelope + // the dialog reads. When the feature is OFF the bridge returns a clear + // "not enabled for this environment" error instead of a bare 404. + target: '/api/v1/auth/admin/sso/request-domain-verification', + params: [ + { name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true }, + { name: 'domain', field: 'domain', defaultFromRow: true, required: false }, + ], + resultDialog: { + title: 'Verify your domain', + description: + 'Add the DNS TXT record below at your domain’s DNS provider, then run “Verify Domain”. The token is shown once.', + acknowledge: 'Done', + fields: [ + { path: 'data.dnsRecordType', label: 'Record type', format: 'text' }, + { path: 'data.dnsRecordName', label: 'Name / Host', format: 'secret' }, + { path: 'data.dnsRecordValue', label: 'Value', format: 'secret' }, + ], + }, + }, + { + name: 'verify_domain', + label: 'Verify Domain', + icon: 'shield-check', + variant: 'secondary', + locations: ['list_item', 'record_header'], + type: 'api', + method: 'POST', + // ADR-0024 ②. Re-checks the DNS-TXT record and flips `domain_verified` + // on success. Routed through the env bridge, which maps @better-auth/sso's + // empty 204 / 502 into a clear success/error toast. + target: '/api/v1/auth/admin/sso/verify-domain', + successMessage: 'Domain ownership verified', + refreshAfter: true, + params: [ + { name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true }, + ], + }, { name: 'delete_sso_provider', label: 'Delete SSO Provider', @@ -144,7 +194,7 @@ export const SysSsoProvider = ObjectSchema.create({ name: 'all', label: 'All', data: { provider: 'object', object: 'sys_sso_provider' }, - columns: ['provider_id', 'issuer', 'domain', 'created_at'], + columns: ['provider_id', 'issuer', 'domain', 'domain_verified', 'created_at'], sort: [{ field: 'provider_id', order: 'asc' }], pagination: { pageSize: 50 }, // Per-object empty state — the shared identity-object copy ("created @@ -186,6 +236,15 @@ export const SysSsoProvider = ObjectSchema.create({ group: 'Identity', }), + domain_verified: Field.boolean({ + label: 'Domain Verified', + defaultValue: false, + readonly: true, + description: + 'Whether DNS ownership of the email domain has been proven (ADR-0024 ②). Set by “Verify Domain” after the DNS TXT record resolves. Managed by better-auth — not directly editable. Only enforced when domain verification is enabled for the environment.', + group: 'Identity', + }), + oidc_config: Field.textarea({ label: 'OIDC Config', required: false, diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 1086c99408..4c332edd9d 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1101,6 +1101,15 @@ export class AuthManager { const oidcFromEnv = readBooleanEnv('OS_OIDC_PROVIDER_ENABLED'); const ssoFromEnv = readBooleanEnv('OS_SSO_ENABLED'); const scimFromEnv = readBooleanEnv('OS_SCIM_ENABLED'); + // Opt-in DNS domain-verification for external SSO providers (ADR-0024 ②). + // OFF by default → today's behavior exactly (register → login immediately). + // ON → @better-auth/sso mounts /sso/{request-domain-verification,verify-domain} + // AND enforces a HARD login gate: a provider whose domain is not DNS-verified + // rejects logins ("Provider domain has not been verified"). The two are + // coupled in @better-auth/sso (the endpoints only register when + // `domainVerification.enabled`), so this single flag governs both. Requires + // `OS_SSO_ENABLED` (the sso plugin must be loaded to honor it). + const ssoDomainVerifyFromEnv = readBooleanEnv('OS_SSO_DOMAIN_VERIFICATION'); // @better-auth/scim's `active:false` → ban runs through the admin plugin, // and org-scoped tokens need the organization plugin — so enabling SCIM // forces `admin` on (organization already defaults on). See ADR-0071. @@ -1117,6 +1126,7 @@ export class AuthManager { deviceAuthorization: pluginConfig.deviceAuthorization ?? false, admin: pluginConfig.admin ?? scimEffective, sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false, + ssoDomainVerification: ssoDomainVerifyFromEnv ?? (pluginConfig as any).ssoDomainVerification ?? false, scim: scimEffective, }; @@ -1482,8 +1492,15 @@ export class AuthManager { // an explicit default role (belt-and-suspenders over SecurityPlugin's // `member_default` fallback, which already grants baseline access to any // authenticated user). Requires the `organization` plugin — on by default. + // `domainVerification.enabled` (ADR-0024 ②, opt-in via OS_SSO_DOMAIN_VERIFICATION): + // when on, @better-auth/sso mounts /sso/request-domain-verification + + // /sso/verify-domain (DNS TXT proof-of-ownership) AND enforces that an + // external IdP's email domain be DNS-verified before it may complete a + // login — preventing an org admin from registering a provider for a domain + // they don't control. Off by default to preserve the register→login flow. plugins.push(sso({ organizationProvisioning: { defaultRole: 'member' }, + ...(enabled.ssoDomainVerification ? { domainVerification: { enabled: true } } : {}), })); } @@ -2052,6 +2069,21 @@ export class AuthManager { return ssoFromEnv ?? (this.config.plugins as any)?.sso ?? false; } + /** + * Whether opt-in DNS domain-verification (ADR-0024 ②) is wired — i.e. the + * `/sso/request-domain-verification` + `/sso/verify-domain` endpoints are + * mounted (and the hard "domain must be verified to log in" gate is active). + * Resolved with the EXACT logic `buildPluginList` uses for the `sso()` + * `domainVerification.enabled` option, so the bridge can return a clear + * "not enabled for this environment" instead of a bare 404 when off. + * Implies `isSsoWired()` (the sso plugin must be loaded to honor it). + */ + public isSsoDomainVerificationEnabled(): boolean { + if (!this.isSsoWired()) return false; + const fromEnv = readBooleanEnv('OS_SSO_DOMAIN_VERIFICATION'); + return fromEnv ?? (this.config.plugins as any)?.ssoDomainVerification ?? false; + } + /** * Whether enterprise SSO is actually *usable*, not merely wired: the plugin * is on AND at least one `sys_sso_provider` row exists. Per-email domain→IdP diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 9089e3f525..f92b365ec8 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -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, runRegisterSamlProviderFromForm } from './register-sso-provider.js'; +import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js'; import { authIdentityObjects, authPluginManifestHeader, @@ -1084,6 +1084,41 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // SSO domain verification (ADR-0024 ②, opt-in OS_SSO_DOMAIN_VERIFICATION). + // Re-dispatch through @better-auth/sso's /sso/{request-domain-verification, + // verify-domain} (so the per-provider admin gate runs) and reshape into the + // `{ success, data }` envelope the action `resultDialog` / toast reads: + // request returns the ready-to-paste DNS TXT record, verify returns a clear + // success/error. A 404 from the inner endpoint = feature OFF for this env. + rawApp.post(`${basePath}/admin/sso/request-domain-verification`, async (c: any) => { + try { + const { status, body } = await runRequestDomainVerification( + (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/request-domain-verification bridge failed', err); + return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500); + } + }); + + rawApp.post(`${basePath}/admin/sso/verify-domain`, async (c: any) => { + try { + const { status, body } = await runVerifyDomain( + (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/verify-domain 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` diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts index b6a4fe51b9..db982ba0db 100644 --- a/packages/plugins/plugin-auth/src/auth-schema-config.ts +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -679,6 +679,12 @@ export const AUTH_SSO_PROVIDER_SCHEMA = { samlConfig: 'saml_config', userId: 'user_id', organizationId: 'organization_id', + // DNS domain-ownership proof (ADR-0024 ②). @better-auth/sso writes + // `domainVerified` on its `ssoProvider` model when domain verification is + // enabled; map it so the env can surface a verified/unverified badge. The + // one-time `domainVerificationToken` is NOT a provider column — it lives in + // the verification table and is returned only from request-domain-verification. + domainVerified: 'domain_verified', }, } as const; diff --git a/packages/plugins/plugin-auth/src/register-sso-provider.ts b/packages/plugins/plugin-auth/src/register-sso-provider.ts index 3fddbf2c6b..7fff6655f7 100644 --- a/packages/plugins/plugin-auth/src/register-sso-provider.ts +++ b/packages/plugins/plugin-auth/src/register-sso-provider.ts @@ -263,3 +263,156 @@ export async function runRegisterSamlProviderFromForm( } return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId }, acsUrl, spMetadataUrl } }; } + + +// ── Domain verification (ADR-0024 ②, opt-in OS_SSO_DOMAIN_VERIFICATION) ────── +// +// `@better-auth/sso` proves an external IdP's email DOMAIN is controlled by the +// registrant via a DNS-TXT challenge, mounted ONLY when `domainVerification` is +// enabled on `sso()`. The two endpoints are: +// • POST /sso/request-domain-verification {providerId} → 201 {domainVerificationToken} +// • POST /sso/verify-domain {providerId} → 204 (or 502 if the TXT +// record is absent / not yet propagated) +// The token alone is not actionable — the admin needs the full DNS record +// (name `_better-auth-token-.`, value +// `_better-auth-token-=`; the prefix is @better-auth/sso's +// default `tokenPrefix`, which we do not override). These bridges re-dispatch +// through the real endpoints (so the per-provider admin gate runs) and reshape +// the response into the `{ success, data }` envelope the action `resultDialog` +// reads — request returns the ready-to-paste DNS record; verify returns a +// friendly success/error message. A `404` from the inner endpoint means the +// feature is OFF for this env (endpoints unmounted) → surfaced as such, not a +// bare "not found". + +/** @better-auth/sso default verification token prefix (we don't override `tokenPrefix`). */ +const SSO_DOMAIN_TOKEN_PREFIX = 'better-auth-token'; + +/** + * Strip protocol / path / port so `https://acme.com/` → `acme.com` for the DNS + * record. Regex-free on purpose — `domain` is request-controlled input, so a + * backtracking pattern here would be a ReDoS vector (CodeQL js/polynomial-redos). + */ +function bareHostname(domain: string): string { + let d = domain.trim(); + if (!d) return d; + const schemeIdx = d.indexOf('://'); + if (schemeIdx !== -1) { + try { + return new URL(d).hostname; + } catch { + d = d.slice(schemeIdx + 3); // malformed URL — drop the scheme and strip manually + } + } + // Truncate at the first path / port / query / fragment separator. + for (const sep of ['/', ':', '?', '#']) { + const i = d.indexOf(sep); + if (i !== -1) d = d.slice(0, i); + } + return d; +} + +function rewriteSsoAdminUrl(request: Request, fromSuffix: RegExp, toPath: string): { innerUrl: string; origin: string } | null { + try { + const url = new URL(request.url); + return { origin: url.origin, innerUrl: `${url.origin}${url.pathname.replace(fromSuffix, toPath)}` }; + } catch { + return null; + } +} + +function forwardAuthHeaders(request: Request, origin: string): Headers { + 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); + return headers; +} + +/** + * Request a DNS-TXT domain-verification challenge for a registered provider and + * return the ready-to-paste DNS record (for a one-shot `resultDialog`). + * + * Body: `{ providerId, domain? }` (domain only shapes the displayed record name). + */ +export async function runRequestDomainVerification( + 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 domain = bareHostname(str(body?.domain)); + if (!providerId) { + return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Missing required field: providerId' } } }; + } + + const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/request-domain-verification$/, '/sso/request-domain-verification'); + if (!rw) return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } }; + const headers = forwardAuthHeaders(request, rw.origin); + + const resp = await handle(new Request(rw.innerUrl, { method: 'POST', headers, body: JSON.stringify({ providerId }) })); + let parsed: any = {}; + try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; } + if (!resp.ok) { + if (resp.status === 404 && !parsed?.code) { + return { status: 400, body: { success: false, error: { code: 'domain_verification_disabled', message: 'Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION).' } } }; + } + return { status: resp.status, body: { success: false, error: { code: parsed?.code || 'request_domain_verification_failed', message: parsed?.message || 'Failed to request domain verification' } } }; + } + + const token = str(parsed?.domainVerificationToken); + const label = `_${SSO_DOMAIN_TOKEN_PREFIX}-${providerId}`; + const dnsRecordName = domain ? `${label}.${domain}` : label; + const dnsRecordValue = `${label}=${token}`; + return { + status: 200, + body: { + success: true, + data: { providerId, domain, token, dnsRecordType: 'TXT', dnsRecordName, dnsRecordValue }, + }, + }; +} + +/** + * Verify a provider's domain ownership (re-checks the DNS-TXT record). Reshapes + * @better-auth/sso's empty `204` / `502` into a `{ success, data:{ message } }` + * envelope so the action surfaces a clear toast. + * + * Body: `{ providerId }`. + */ +export async function runVerifyDomain( + 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); + if (!providerId) { + return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Missing required field: providerId' } } }; + } + + const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/verify-domain$/, '/sso/verify-domain'); + if (!rw) return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } }; + const headers = forwardAuthHeaders(request, rw.origin); + + const resp = await handle(new Request(rw.innerUrl, { method: 'POST', headers, body: JSON.stringify({ providerId }) })); + let parsed: any = {}; + try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; } + if (resp.ok) { + return { status: 200, body: { success: true, data: { providerId, verified: true, message: 'Domain ownership verified — this provider can now sign users in.' } } }; + } + // Friendlier copy for the expected failure modes. + let message = parsed?.message || 'Domain verification failed'; + if (resp.status === 404 && !parsed?.code) { + message = 'Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION).'; + } else if (parsed?.code === 'NO_PENDING_VERIFICATION') { + message = 'No pending verification — click “Request Domain Verification” first to get the DNS record.'; + } else if (parsed?.code === 'DOMAIN_VERIFICATION_FAILED') { + message = 'DNS TXT record not found yet. Add the record shown when you requested verification, allow time for DNS to propagate, then retry.'; + } + return { status: resp.status, body: { success: false, error: { code: parsed?.code || 'verify_domain_failed', message } } }; +}