Skip to content

Commit cbc8c02

Browse files
os-zhuangclaude
andauthored
feat(auth): opt-in SSO domain verification — DNS-TXT proof + resultDialog UI (ADR-0024 ②) (#2410)
* 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> * fix(auth): regex-free hostname strip in domain-verification bridge (CodeQL js/polynomial-redos) bareHostname's fallback used a regex on request-controlled input → high-sev ReDoS alert. Replace with indexOf-based truncation (URL() fast-path kept). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7cf81a7 commit cbc8c02

6 files changed

Lines changed: 313 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: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,156 @@ 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+
/**
291+
* Strip protocol / path / port so `https://acme.com/` → `acme.com` for the DNS
292+
* record. Regex-free on purpose — `domain` is request-controlled input, so a
293+
* backtracking pattern here would be a ReDoS vector (CodeQL js/polynomial-redos).
294+
*/
295+
function bareHostname(domain: string): string {
296+
let d = domain.trim();
297+
if (!d) return d;
298+
const schemeIdx = d.indexOf('://');
299+
if (schemeIdx !== -1) {
300+
try {
301+
return new URL(d).hostname;
302+
} catch {
303+
d = d.slice(schemeIdx + 3); // malformed URL — drop the scheme and strip manually
304+
}
305+
}
306+
// Truncate at the first path / port / query / fragment separator.
307+
for (const sep of ['/', ':', '?', '#']) {
308+
const i = d.indexOf(sep);
309+
if (i !== -1) d = d.slice(0, i);
310+
}
311+
return d;
312+
}
313+
314+
function rewriteSsoAdminUrl(request: Request, fromSuffix: RegExp, toPath: string): { innerUrl: string; origin: string } | null {
315+
try {
316+
const url = new URL(request.url);
317+
return { origin: url.origin, innerUrl: `${url.origin}${url.pathname.replace(fromSuffix, toPath)}` };
318+
} catch {
319+
return null;
320+
}
321+
}
322+
323+
function forwardAuthHeaders(request: Request, origin: string): Headers {
324+
const headers = new Headers({ 'content-type': 'application/json' });
325+
const cookie = request.headers.get('cookie');
326+
if (cookie) headers.set('cookie', cookie);
327+
const authz = request.headers.get('authorization');
328+
if (authz) headers.set('authorization', authz);
329+
headers.set('origin', request.headers.get('origin') || origin);
330+
return headers;
331+
}
332+
333+
/**
334+
* Request a DNS-TXT domain-verification challenge for a registered provider and
335+
* return the ready-to-paste DNS record (for a one-shot `resultDialog`).
336+
*
337+
* Body: `{ providerId, domain? }` (domain only shapes the displayed record name).
338+
*/
339+
export async function runRequestDomainVerification(
340+
handle: AuthRequestHandler,
341+
request: Request,
342+
): Promise<RegisterSsoFormResult & { body: RegisterSsoFormResult['body'] & { data?: any } }> {
343+
let body: any;
344+
try { body = await request.json(); } catch { body = {}; }
345+
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
346+
const providerId = str(body?.providerId);
347+
const domain = bareHostname(str(body?.domain));
348+
if (!providerId) {
349+
return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Missing required field: providerId' } } };
350+
}
351+
352+
const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/request-domain-verification$/, '/sso/request-domain-verification');
353+
if (!rw) return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } };
354+
const headers = forwardAuthHeaders(request, rw.origin);
355+
356+
const resp = await handle(new Request(rw.innerUrl, { method: 'POST', headers, body: JSON.stringify({ providerId }) }));
357+
let parsed: any = {};
358+
try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; }
359+
if (!resp.ok) {
360+
if (resp.status === 404 && !parsed?.code) {
361+
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).' } } };
362+
}
363+
return { status: resp.status, body: { success: false, error: { code: parsed?.code || 'request_domain_verification_failed', message: parsed?.message || 'Failed to request domain verification' } } };
364+
}
365+
366+
const token = str(parsed?.domainVerificationToken);
367+
const label = `_${SSO_DOMAIN_TOKEN_PREFIX}-${providerId}`;
368+
const dnsRecordName = domain ? `${label}.${domain}` : label;
369+
const dnsRecordValue = `${label}=${token}`;
370+
return {
371+
status: 200,
372+
body: {
373+
success: true,
374+
data: { providerId, domain, token, dnsRecordType: 'TXT', dnsRecordName, dnsRecordValue },
375+
},
376+
};
377+
}
378+
379+
/**
380+
* Verify a provider's domain ownership (re-checks the DNS-TXT record). Reshapes
381+
* @better-auth/sso's empty `204` / `502` into a `{ success, data:{ message } }`
382+
* envelope so the action surfaces a clear toast.
383+
*
384+
* Body: `{ providerId }`.
385+
*/
386+
export async function runVerifyDomain(
387+
handle: AuthRequestHandler,
388+
request: Request,
389+
): Promise<RegisterSsoFormResult & { body: RegisterSsoFormResult['body'] & { data?: any } }> {
390+
let body: any;
391+
try { body = await request.json(); } catch { body = {}; }
392+
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
393+
const providerId = str(body?.providerId);
394+
if (!providerId) {
395+
return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Missing required field: providerId' } } };
396+
}
397+
398+
const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/verify-domain$/, '/sso/verify-domain');
399+
if (!rw) return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } };
400+
const headers = forwardAuthHeaders(request, rw.origin);
401+
402+
const resp = await handle(new Request(rw.innerUrl, { method: 'POST', headers, body: JSON.stringify({ providerId }) }));
403+
let parsed: any = {};
404+
try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; }
405+
if (resp.ok) {
406+
return { status: 200, body: { success: true, data: { providerId, verified: true, message: 'Domain ownership verified — this provider can now sign users in.' } } };
407+
}
408+
// Friendlier copy for the expected failure modes.
409+
let message = parsed?.message || 'Domain verification failed';
410+
if (resp.status === 404 && !parsed?.code) {
411+
message = 'Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION).';
412+
} else if (parsed?.code === 'NO_PENDING_VERIFICATION') {
413+
message = 'No pending verification — click “Request Domain Verification” first to get the DNS record.';
414+
} else if (parsed?.code === 'DOMAIN_VERIFICATION_FAILED') {
415+
message = 'DNS TXT record not found yet. Add the record shown when you requested verification, allow time for DNS to propagate, then retry.';
416+
}
417+
return { status: resp.status, body: { success: false, error: { code: parsed?.code || 'verify_domain_failed', message } } };
418+
}

0 commit comments

Comments
 (0)