@@ -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 ( / ^ h t t p s ? : \/ \/ / i. test ( d ) ) return new URL ( d ) . hostname ;
296+ } catch {
297+ /* fall through to manual strip */
298+ }
299+ return d . replace ( / ^ h t t p s ? : \/ \/ / 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 , / \/ 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' ) ;
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 , / \/ a d m i n \/ s s o \/ v e r i f y - d o m a i n $ / , '/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