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
26 changes: 26 additions & 0 deletions .changeset/adr-0024-sso-domain-verification.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
};

Expand Down Expand Up @@ -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 } } : {}),
}));
}

Expand Down Expand Up @@ -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
Expand Down
37 changes: 36 additions & 1 deletion packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions packages/plugins/plugin-auth/src/auth-schema-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
153 changes: 153 additions & 0 deletions packages/plugins/plugin-auth/src/register-sso-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<providerId>.<domain>`, value
// `_better-auth-token-<providerId>=<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<RegisterSsoFormResult & { body: RegisterSsoFormResult['body'] & { data?: any } }> {
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<RegisterSsoFormResult & { body: RegisterSsoFormResult['body'] & { data?: any } }> {
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 } } };
}