@@ -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 , / \/ a d m i n \/ s s o \/ r e q u e s t - d o m a i n - v e r i f i c a t i o n $ / , '/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 , / \/ a d m i n \/ s s o \/ v e r i f y - d o m a i n $ / , '/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