@@ -1516,15 +1516,12 @@ export class AuthManager {
15161516 // operators / UI can fall back to copy-paste flows. Replace this
15171517 // with a real mail integration when available.
15181518 sendInvitationEmail : async ( { email : recipientEmail , invitation, organization : org , inviter } ) => {
1519- // The accept-invitation page is a Console SPA route mounted under
1520- // `uiBasePath` (default `/_console`) — the SAME router/basename as
1521- // /login, /register and /oauth/consent. The Console builds the very
1522- // same link for its "copy invitation link" action
1523- // (`${origin}${BASE_URL}accept-invitation/<id>`), so the emailed link
1524- // MUST carry that prefix (and an absolute https origin) or it 404s at
1525- // the host root instead of resolving to the page.
1526- const uiBase = ( this . config . uiBasePath ?? '/_console' ) . replace ( / \/ $ / , '' ) ;
1527- const acceptUrl = `${ this . getCanonicalOrigin ( ) } ${ uiBase } /accept-invitation/${ invitation . id } ` ;
1519+ // The accept-invitation page is a Console SPA route; the Console
1520+ // builds the very same link for its "copy invitation link" action
1521+ // (`${origin}${BASE_URL}accept-invitation/<id>`). Compose it through
1522+ // the single-source helper so the `/_console` prefix and absolute
1523+ // https origin are guaranteed.
1524+ const acceptUrl = this . getConsolePageUrl ( `/accept-invitation/${ invitation . id } ` ) ;
15281525 // #2766 V1.5 — placeholder addresses are never real recipients.
15291526 if ( isPlaceholderEmail ( recipientEmail ) ) {
15301527 throw new Error (
@@ -1723,15 +1720,13 @@ export class AuthManager {
17231720 plugins . push ( jwt ( { schema : buildJwtPluginSchema ( ) } ) ) ;
17241721
17251722 const { oauthProvider } = await import ( '@better-auth/oauth-provider' ) ;
1726- const baseUrl = this . getCanonicalOrigin ( ) ;
1727- const uiBase = ( this . config . uiBasePath ?? '/_console' ) . replace ( / \/ $ / , '' ) ;
17281723 const dcr = resolveDcrEnabled ( pluginConfig ) ;
17291724 plugins . push ( oauthProvider ( {
17301725 // Console SPA renders both pages (replaces the legacy Account SPA at
17311726 // /_account). Override `uiBasePath` in AuthConfig if Console is
17321727 // mounted elsewhere.
1733- loginPage : ` ${ baseUrl } ${ uiBase } /login` ,
1734- consentPage : ` ${ baseUrl } ${ uiBase } /oauth/consent` ,
1728+ loginPage : this . getConsolePageUrl ( ' /login' ) ,
1729+ consentPage : this . getConsolePageUrl ( ' /oauth/consent' ) ,
17351730 schema : buildOauthProviderPluginSchema ( ) ,
17361731 // ── MCP OAuth track (#2698) ────────────────────────────────
17371732 // Coarse tool-family scopes for the platform's own MCP endpoint,
@@ -1815,10 +1810,8 @@ export class AuthManager {
18151810 // `verification_uri_complete`.
18161811 if ( enabled . deviceAuthorization ) {
18171812 const { deviceAuthorization } = await import ( 'better-auth/plugins/device-authorization' ) ;
1818- const baseUrl = this . getCanonicalOrigin ( ) ;
1819- const uiBase = ( this . config . uiBasePath ?? '/_console' ) . replace ( / \/ $ / , '' ) ;
18201813 plugins . push ( deviceAuthorization ( {
1821- verificationUri : ` ${ baseUrl } ${ uiBase } /auth/device` ,
1814+ verificationUri : this . getConsolePageUrl ( ' /auth/device' ) ,
18221815 schema : buildDeviceAuthorizationPluginSchema ( ) ,
18231816 } ) ) ;
18241817 }
@@ -2201,11 +2194,14 @@ export class AuthManager {
22012194 if ( ! sms ) {
22022195 throw new Error ( 'SMS_SERVICE_REQUIRED: no SMS service is configured for this deployment.' ) ;
22032196 }
2204- const baseUrl = this . getCanonicalOrigin ( ) ;
22052197 // #2815 — localised, tenant-customisable body (see deliverPhoneOtp).
2198+ // `loginUrl` points at the actual Console sign-in page; `baseUrl` (bare
2199+ // origin) is kept for backward-compatibility with tenant-overridden
2200+ // templates that still interpolate `{{baseUrl}}`.
22062201 const body = await this . renderPhoneSmsBody ( PHONE_SMS_TOPICS . invite , {
22072202 appName : this . getAppName ( ) ,
2208- baseUrl,
2203+ baseUrl : this . getCanonicalOrigin ( ) ,
2204+ loginUrl : this . getConsolePageUrl ( '/login' ) ,
22092205 } ) ;
22102206 const result = await sms . send ( { to : phone , body, templateParams : { content : body } } ) ;
22112207 if ( result . status === 'failed' ) {
@@ -2378,6 +2374,23 @@ export class AuthManager {
23782374 return / ^ h t t p s ? : \/ \/ / i. test ( raw ) ? raw : `https://${ raw } ` ;
23792375 }
23802376
2377+ /**
2378+ * Absolute URL of a page served by the Console SPA. The Console is mounted
2379+ * under `uiBasePath` (default `/_console`) — it owns `/login`, `/register`,
2380+ * `/oauth/consent`, `/auth/device`, `/accept-invitation/:id`, … — so EVERY
2381+ * link we hand to a user (email, SMS, OAuth redirect, device flow) must be
2382+ * `${origin}${uiBasePath}${path}`. This is the single source of truth for
2383+ * that composition: build page links through here, never by hand, so a bare
2384+ * host (missing scheme) or a missing `/_console` prefix can't creep back in
2385+ * one link at a time. Set `uiBasePath` in AuthConfig if Console is mounted
2386+ * elsewhere.
2387+ */
2388+ private getConsolePageUrl ( path : string ) : string {
2389+ const uiBase = ( this . config . uiBasePath ?? '/_console' ) . replace ( / \/ $ / , '' ) ;
2390+ const rel = path . startsWith ( '/' ) ? path : `/${ path } ` ;
2391+ return `${ this . getCanonicalOrigin ( ) } ${ uiBase } ${ rel } ` ;
2392+ }
2393+
23812394 /**
23822395 * The OAuth issuer identifier: better-auth's `baseURL` INCLUDING `basePath`
23832396 * (e.g. `https://acme.example.com/api/v1/auth`) — this is the `iss` claim
0 commit comments