Skip to content

Commit 4d45bcc

Browse files
os-zhuangclaude
andcommitted
feat(auth): opt-in SSO domain verification — DNS-TXT proof + resultDialog UI (ADR-0024 ②)
OS_SSO_DOMAIN_VERIFICATION (off by default) mounts @better-auth/sso's /sso/{request-domain-verification,verify-domain} and enforces DNS domain ownership before an external IdP may sign users in. Adds the request/verify bridges (envelope-reshaped for the action resultDialog → ready-to-paste DNS TXT record), the rawApp routes, and the sys_sso_provider domain_verified field/column + Request/Verify actions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7cf81a7 commit 4d45bcc

6 files changed

Lines changed: 301 additions & 2 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
"@objectstack/platform-objects": minor
4+
---
5+
6+
feat(auth): opt-in SSO domain verification (ADR-0024 ②)
7+
8+
Add DNS-TXT domain-ownership verification for external SSO providers, gated
9+
behind a new `OS_SSO_DOMAIN_VERIFICATION` flag (off by default — today's
10+
register→login behavior is unchanged). When enabled, `@better-auth/sso` mounts
11+
`/sso/request-domain-verification` + `/sso/verify-domain` and enforces that a
12+
provider's email domain be DNS-verified before it may complete a login.
13+
14+
- `auth-manager.ts`: new `ssoDomainVerification` enabled-flag (readBooleanEnv) →
15+
passes `domainVerification: { enabled: true }` to `sso()`; public
16+
`isSsoDomainVerificationEnabled()` helper.
17+
- `register-sso-provider.ts`: `runRequestDomainVerification` /
18+
`runVerifyDomain` bridges — re-dispatch through the gated better-auth
19+
endpoints and reshape the response into the `{ success, data }` envelope the
20+
`sys_sso_provider` action `resultDialog` reads (request → ready-to-paste DNS
21+
TXT record; verify → clear success/error). A bare 404 from the inner endpoint
22+
is surfaced as "not enabled for this environment".
23+
- `auth-plugin.ts`: mount the two bridges as rawApp routes
24+
(`/admin/sso/{request-domain-verification,verify-domain}`).
25+
- `sys_sso_provider`: `domain_verified` field + list column + the two actions;
26+
`domainVerified` documented in `AUTH_SSO_PROVIDER_SCHEMA`.

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,56 @@ export const SysSsoProvider = ObjectSchema.create({
119119
{ 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.' },
120120
],
121121
},
122+
{
123+
name: 'request_domain_verification',
124+
label: 'Request Domain Verification',
125+
icon: 'globe',
126+
variant: 'secondary',
127+
locations: ['list_item', 'record_header'],
128+
type: 'api',
129+
method: 'POST',
130+
// ADR-0024 ② (opt-in OS_SSO_DOMAIN_VERIFICATION). Asks @better-auth/sso
131+
// for a one-time DNS-TXT challenge and reveals the ready-to-paste record
132+
// ONCE via `resultDialog`. Routed through the env bridge (plugin-auth /
133+
// cloud AuthProxyPlugin) which reshapes the `{domainVerificationToken}`
134+
// response into the `{ data: { dnsRecordName, dnsRecordValue } }` envelope
135+
// the dialog reads. When the feature is OFF the bridge returns a clear
136+
// "not enabled for this environment" error instead of a bare 404.
137+
target: '/api/v1/auth/admin/sso/request-domain-verification',
138+
params: [
139+
{ name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true },
140+
{ name: 'domain', field: 'domain', defaultFromRow: true, required: false },
141+
],
142+
resultDialog: {
143+
title: 'Verify your domain',
144+
description:
145+
'Add the DNS TXT record below at your domain’s DNS provider, then run “Verify Domain”. The token is shown once.',
146+
acknowledge: 'Done',
147+
fields: [
148+
{ path: 'data.dnsRecordType', label: 'Record type', format: 'text' },
149+
{ path: 'data.dnsRecordName', label: 'Name / Host', format: 'secret' },
150+
{ path: 'data.dnsRecordValue', label: 'Value', format: 'secret' },
151+
],
152+
},
153+
},
154+
{
155+
name: 'verify_domain',
156+
label: 'Verify Domain',
157+
icon: 'shield-check',
158+
variant: 'secondary',
159+
locations: ['list_item', 'record_header'],
160+
type: 'api',
161+
method: 'POST',
162+
// ADR-0024 ②. Re-checks the DNS-TXT record and flips `domain_verified`
163+
// on success. Routed through the env bridge, which maps @better-auth/sso's
164+
// empty 204 / 502 into a clear success/error toast.
165+
target: '/api/v1/auth/admin/sso/verify-domain',
166+
successMessage: 'Domain ownership verified',
167+
refreshAfter: true,
168+
params: [
169+
{ name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true },
170+
],
171+
},
122172
{
123173
name: 'delete_sso_provider',
124174
label: 'Delete SSO Provider',
@@ -144,7 +194,7 @@ export const SysSsoProvider = ObjectSchema.create({
144194
name: 'all',
145195
label: 'All',
146196
data: { provider: 'object', object: 'sys_sso_provider' },
147-
columns: ['provider_id', 'issuer', 'domain', 'created_at'],
197+
columns: ['provider_id', 'issuer', 'domain', 'domain_verified', 'created_at'],
148198
sort: [{ field: 'provider_id', order: 'asc' }],
149199
pagination: { pageSize: 50 },
150200
// Per-object empty state — the shared identity-object copy ("created
@@ -186,6 +236,15 @@ export const SysSsoProvider = ObjectSchema.create({
186236
group: 'Identity',
187237
}),
188238

239+
domain_verified: Field.boolean({
240+
label: 'Domain Verified',
241+
defaultValue: false,
242+
readonly: true,
243+
description:
244+
'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.',
245+
group: 'Identity',
246+
}),
247+
189248
oidc_config: Field.textarea({
190249
label: 'OIDC Config',
191250
required: false,

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,15 @@ export class AuthManager {
11011101
const oidcFromEnv = readBooleanEnv('OS_OIDC_PROVIDER_ENABLED');
11021102
const ssoFromEnv = readBooleanEnv('OS_SSO_ENABLED');
11031103
const scimFromEnv = readBooleanEnv('OS_SCIM_ENABLED');
1104+
// Opt-in DNS domain-verification for external SSO providers (ADR-0024 ②).
1105+
// OFF by default → today's behavior exactly (register → login immediately).
1106+
// ON → @better-auth/sso mounts /sso/{request-domain-verification,verify-domain}
1107+
// AND enforces a HARD login gate: a provider whose domain is not DNS-verified
1108+
// rejects logins ("Provider domain has not been verified"). The two are
1109+
// coupled in @better-auth/sso (the endpoints only register when
1110+
// `domainVerification.enabled`), so this single flag governs both. Requires
1111+
// `OS_SSO_ENABLED` (the sso plugin must be loaded to honor it).
1112+
const ssoDomainVerifyFromEnv = readBooleanEnv('OS_SSO_DOMAIN_VERIFICATION');
11041113
// @better-auth/scim's `active:false` → ban runs through the admin plugin,
11051114
// and org-scoped tokens need the organization plugin — so enabling SCIM
11061115
// forces `admin` on (organization already defaults on). See ADR-0071.
@@ -1117,6 +1126,7 @@ export class AuthManager {
11171126
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
11181127
admin: pluginConfig.admin ?? scimEffective,
11191128
sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false,
1129+
ssoDomainVerification: ssoDomainVerifyFromEnv ?? (pluginConfig as any).ssoDomainVerification ?? false,
11201130
scim: scimEffective,
11211131
};
11221132

@@ -1482,8 +1492,15 @@ export class AuthManager {
14821492
// an explicit default role (belt-and-suspenders over SecurityPlugin's
14831493
// `member_default` fallback, which already grants baseline access to any
14841494
// authenticated user). Requires the `organization` plugin — on by default.
1495+
// `domainVerification.enabled` (ADR-0024 ②, opt-in via OS_SSO_DOMAIN_VERIFICATION):
1496+
// when on, @better-auth/sso mounts /sso/request-domain-verification +
1497+
// /sso/verify-domain (DNS TXT proof-of-ownership) AND enforces that an
1498+
// external IdP's email domain be DNS-verified before it may complete a
1499+
// login — preventing an org admin from registering a provider for a domain
1500+
// they don't control. Off by default to preserve the register→login flow.
14851501
plugins.push(sso({
14861502
organizationProvisioning: { defaultRole: 'member' },
1503+
...(enabled.ssoDomainVerification ? { domainVerification: { enabled: true } } : {}),
14871504
}));
14881505
}
14891506

@@ -2052,6 +2069,21 @@ export class AuthManager {
20522069
return ssoFromEnv ?? (this.config.plugins as any)?.sso ?? false;
20532070
}
20542071

2072+
/**
2073+
* Whether opt-in DNS domain-verification (ADR-0024 ②) is wired — i.e. the
2074+
* `/sso/request-domain-verification` + `/sso/verify-domain` endpoints are
2075+
* mounted (and the hard "domain must be verified to log in" gate is active).
2076+
* Resolved with the EXACT logic `buildPluginList` uses for the `sso()`
2077+
* `domainVerification.enabled` option, so the bridge can return a clear
2078+
* "not enabled for this environment" instead of a bare 404 when off.
2079+
* Implies `isSsoWired()` (the sso plugin must be loaded to honor it).
2080+
*/
2081+
public isSsoDomainVerificationEnabled(): boolean {
2082+
if (!this.isSsoWired()) return false;
2083+
const fromEnv = readBooleanEnv('OS_SSO_DOMAIN_VERIFICATION');
2084+
return fromEnv ?? (this.config.plugins as any)?.ssoDomainVerification ?? false;
2085+
}
2086+
20552087
/**
20562088
* Whether enterprise SSO is actually *usable*, not merely wired: the plugin
20572089
* is on AND at least one `sys_sso_provider` row exists. Per-email domain→IdP

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +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, runRegisterSamlProviderFromForm } from './register-sso-provider.js';
15+
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
1616
import {
1717
authIdentityObjects,
1818
authPluginManifestHeader,
@@ -1084,6 +1084,41 @@ export class AuthPlugin implements Plugin {
10841084
}
10851085
});
10861086

1087+
// ────────────────────────────────────────────────────────────────────
1088+
// SSO domain verification (ADR-0024 ②, opt-in OS_SSO_DOMAIN_VERIFICATION).
1089+
// Re-dispatch through @better-auth/sso's /sso/{request-domain-verification,
1090+
// verify-domain} (so the per-provider admin gate runs) and reshape into the
1091+
// `{ success, data }` envelope the action `resultDialog` / toast reads:
1092+
// request returns the ready-to-paste DNS TXT record, verify returns a clear
1093+
// success/error. A 404 from the inner endpoint = feature OFF for this env.
1094+
rawApp.post(`${basePath}/admin/sso/request-domain-verification`, async (c: any) => {
1095+
try {
1096+
const { status, body } = await runRequestDomainVerification(
1097+
(req) => this.authManager!.handleRequest(req),
1098+
c.req.raw,
1099+
);
1100+
return c.json(body, status as any);
1101+
} catch (error) {
1102+
const err = error instanceof Error ? error : new Error(String(error));
1103+
ctx.logger.error('[AuthPlugin] sso/request-domain-verification bridge failed', err);
1104+
return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500);
1105+
}
1106+
});
1107+
1108+
rawApp.post(`${basePath}/admin/sso/verify-domain`, async (c: any) => {
1109+
try {
1110+
const { status, body } = await runVerifyDomain(
1111+
(req) => this.authManager!.handleRequest(req),
1112+
c.req.raw,
1113+
);
1114+
return c.json(body, status as any);
1115+
} catch (error) {
1116+
const err = error instanceof Error ? error : new Error(String(error));
1117+
ctx.logger.error('[AuthPlugin] sso/verify-domain bridge failed', err);
1118+
return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500);
1119+
}
1120+
});
1121+
10871122
// ────────────────────────────────────────────────────────────────────
10881123
// OAuth self-service: register an OAuth application for the signed-in
10891124
// user. Thin wrapper over better-auth's `/oauth2/create-client`

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,12 @@ export const AUTH_SSO_PROVIDER_SCHEMA = {
679679
samlConfig: 'saml_config',
680680
userId: 'user_id',
681681
organizationId: 'organization_id',
682+
// DNS domain-ownership proof (ADR-0024 ②). @better-auth/sso writes
683+
// `domainVerified` on its `ssoProvider` model when domain verification is
684+
// enabled; map it so the env can surface a verified/unverified badge. The
685+
// one-time `domainVerificationToken` is NOT a provider column — it lives in
686+
// the verification table and is returned only from request-domain-verification.
687+
domainVerified: 'domain_verified',
682688
},
683689
} as const;
684690

packages/plugins/plugin-auth/src/register-sso-provider.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,144 @@ export async function runRegisterSamlProviderFromForm(
263263
}
264264
return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId }, acsUrl, spMetadataUrl } };
265265
}
266+
267+
268+
// ── Domain verification (ADR-0024 ②, opt-in OS_SSO_DOMAIN_VERIFICATION) ──────
269+
//
270+
// `@better-auth/sso` proves an external IdP's email DOMAIN is controlled by the
271+
// registrant via a DNS-TXT challenge, mounted ONLY when `domainVerification` is
272+
// enabled on `sso()`. The two endpoints are:
273+
// • POST /sso/request-domain-verification {providerId} → 201 {domainVerificationToken}
274+
// • POST /sso/verify-domain {providerId} → 204 (or 502 if the TXT
275+
// record is absent / not yet propagated)
276+
// The token alone is not actionable — the admin needs the full DNS record
277+
// (name `_better-auth-token-<providerId>.<domain>`, value
278+
// `_better-auth-token-<providerId>=<token>`; the prefix is @better-auth/sso's
279+
// default `tokenPrefix`, which we do not override). These bridges re-dispatch
280+
// through the real endpoints (so the per-provider admin gate runs) and reshape
281+
// the response into the `{ success, data }` envelope the action `resultDialog`
282+
// reads — request returns the ready-to-paste DNS record; verify returns a
283+
// friendly success/error message. A `404` from the inner endpoint means the
284+
// feature is OFF for this env (endpoints unmounted) → surfaced as such, not a
285+
// bare "not found".
286+
287+
/** @better-auth/sso default verification token prefix (we don't override `tokenPrefix`). */
288+
const SSO_DOMAIN_TOKEN_PREFIX = 'better-auth-token';
289+
290+
/** Strip protocol / path / port so `https://acme.com/` → `acme.com` for the DNS record. */
291+
function bareHostname(domain: string): string {
292+
const d = domain.trim();
293+
if (!d) return d;
294+
try {
295+
if (/^https?:\/\//i.test(d)) return new URL(d).hostname;
296+
} catch {
297+
/* fall through to manual strip */
298+
}
299+
return d.replace(/^https?:\/\//i, '').replace(/[/:].*$/, '');
300+
}
301+
302+
function rewriteSsoAdminUrl(request: Request, fromSuffix: RegExp, toPath: string): { innerUrl: string; origin: string } | null {
303+
try {
304+
const url = new URL(request.url);
305+
return { origin: url.origin, innerUrl: `${url.origin}${url.pathname.replace(fromSuffix, toPath)}` };
306+
} catch {
307+
return null;
308+
}
309+
}
310+
311+
function forwardAuthHeaders(request: Request, origin: string): Headers {
312+
const headers = new Headers({ 'content-type': 'application/json' });
313+
const cookie = request.headers.get('cookie');
314+
if (cookie) headers.set('cookie', cookie);
315+
const authz = request.headers.get('authorization');
316+
if (authz) headers.set('authorization', authz);
317+
headers.set('origin', request.headers.get('origin') || origin);
318+
return headers;
319+
}
320+
321+
/**
322+
* Request a DNS-TXT domain-verification challenge for a registered provider and
323+
* return the ready-to-paste DNS record (for a one-shot `resultDialog`).
324+
*
325+
* Body: `{ providerId, domain? }` (domain only shapes the displayed record name).
326+
*/
327+
export async function runRequestDomainVerification(
328+
handle: AuthRequestHandler,
329+
request: Request,
330+
): Promise<RegisterSsoFormResult & { body: RegisterSsoFormResult['body'] & { data?: any } }> {
331+
let body: any;
332+
try { body = await request.json(); } catch { body = {}; }
333+
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
334+
const providerId = str(body?.providerId);
335+
const domain = bareHostname(str(body?.domain));
336+
if (!providerId) {
337+
return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Missing required field: providerId' } } };
338+
}
339+
340+
const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/request-domain-verification$/, '/sso/request-domain-verification');
341+
if (!rw) return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } };
342+
const headers = forwardAuthHeaders(request, rw.origin);
343+
344+
const resp = await handle(new Request(rw.innerUrl, { method: 'POST', headers, body: JSON.stringify({ providerId }) }));
345+
let parsed: any = {};
346+
try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; }
347+
if (!resp.ok) {
348+
if (resp.status === 404 && !parsed?.code) {
349+
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).' } } };
350+
}
351+
return { status: resp.status, body: { success: false, error: { code: parsed?.code || 'request_domain_verification_failed', message: parsed?.message || 'Failed to request domain verification' } } };
352+
}
353+
354+
const token = str(parsed?.domainVerificationToken);
355+
const label = `_${SSO_DOMAIN_TOKEN_PREFIX}-${providerId}`;
356+
const dnsRecordName = domain ? `${label}.${domain}` : label;
357+
const dnsRecordValue = `${label}=${token}`;
358+
return {
359+
status: 200,
360+
body: {
361+
success: true,
362+
data: { providerId, domain, token, dnsRecordType: 'TXT', dnsRecordName, dnsRecordValue },
363+
},
364+
};
365+
}
366+
367+
/**
368+
* Verify a provider's domain ownership (re-checks the DNS-TXT record). Reshapes
369+
* @better-auth/sso's empty `204` / `502` into a `{ success, data:{ message } }`
370+
* envelope so the action surfaces a clear toast.
371+
*
372+
* Body: `{ providerId }`.
373+
*/
374+
export async function runVerifyDomain(
375+
handle: AuthRequestHandler,
376+
request: Request,
377+
): Promise<RegisterSsoFormResult & { body: RegisterSsoFormResult['body'] & { data?: any } }> {
378+
let body: any;
379+
try { body = await request.json(); } catch { body = {}; }
380+
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
381+
const providerId = str(body?.providerId);
382+
if (!providerId) {
383+
return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Missing required field: providerId' } } };
384+
}
385+
386+
const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/verify-domain$/, '/sso/verify-domain');
387+
if (!rw) return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } };
388+
const headers = forwardAuthHeaders(request, rw.origin);
389+
390+
const resp = await handle(new Request(rw.innerUrl, { method: 'POST', headers, body: JSON.stringify({ providerId }) }));
391+
let parsed: any = {};
392+
try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; }
393+
if (resp.ok) {
394+
return { status: 200, body: { success: true, data: { providerId, verified: true, message: 'Domain ownership verified — this provider can now sign users in.' } } };
395+
}
396+
// Friendlier copy for the expected failure modes.
397+
let message = parsed?.message || 'Domain verification failed';
398+
if (resp.status === 404 && !parsed?.code) {
399+
message = 'Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION).';
400+
} else if (parsed?.code === 'NO_PENDING_VERIFICATION') {
401+
message = 'No pending verification — click “Request Domain Verification” first to get the DNS record.';
402+
} else if (parsed?.code === 'DOMAIN_VERIFICATION_FAILED') {
403+
message = 'DNS TXT record not found yet. Add the record shown when you requested verification, allow time for DNS to propagate, then retry.';
404+
}
405+
return { status: resp.status, body: { success: false, error: { code: parsed?.code || 'verify_domain_failed', message } } };
406+
}

0 commit comments

Comments
 (0)