diff --git a/.changeset/console-page-url-single-source.md b/.changeset/console-page-url-single-source.md new file mode 100644 index 0000000000..d4c4efbecb --- /dev/null +++ b/.changeset/console-page-url-single-source.md @@ -0,0 +1,28 @@ +--- +"@objectstack/plugin-auth": patch +"@objectstack/client": patch +--- + +fix(auth): single-source Console page-URL construction; correct SMS + OAuth-callback landing paths + +Root-cause hardening after the invitation-link fixes. Every user-facing link +to a Console page is `${origin}${uiBasePath}${path}`, but that composition was +hand-written at each call site — which is how the scheme / `/_console` prefix +kept getting dropped one link at a time. + +**plugin-auth** +- New single-source `getConsolePageUrl(path)` helper; `loginPage`, + `consentPage`, device `verificationUri` and the invitation accept URL all + compose through it, so future page links can't drift. +- Phone-invite SMS now links to the actual Console sign-in page + (`${origin}${uiBasePath}/login`) via a new `{{loginUrl}}` template variable + instead of the bare origin. `{{baseUrl}}` is still provided for backward + compatibility with tenant-overridden templates. + +**client** +- `signInWithProvider` now defaults `callbackURL` to the current page + (`window.location.href`) instead of a hard-coded `origin + '/login'`. The + SDK cannot know the app's mount path (Console lives under `/_console`), so + returning the user to where they started is the only base-path-correct + default; it also mirrors `linkSocial`. Pass an explicit `callbackURL` to + override. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 567651b13b..0b5478d6f8 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -409,6 +409,9 @@ override the env-derived configuration. ```typescript // Recommended: use the client SDK, which posts to /sign-in/social and redirects. +// `callbackURL` defaults to the current page (window.location.href) — the SDK +// can't know your app's mount path, so it returns you where you started. Pass +// an explicit `callbackURL` to land elsewhere. await client.auth.signInWithProvider('google'); // Equivalent direct API call: POST /sign-in/social returns a redirect URL. @@ -417,7 +420,7 @@ const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/social', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'google', - callbackURL: window.location.origin + '/login', + callbackURL: window.location.href, }), }); const data = await response.json(); diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 344ea06a8d..e5b85d8981 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -447,6 +447,39 @@ describe('Auth enhancements', () => { // Token should be auto-set expect((client as any).token).toBe('refreshed-token'); }); + + it('signInWithProvider defaults callbackURL to the current page (base-path-correct)', async () => { + const assign = vi.fn(); + vi.stubGlobal('window', { + location: { href: 'https://app.example.com/_console/login', assign }, + }); + try { + const { client, fetchMock } = createMockClient({ url: 'https://accounts.google.com/o/oauth2/auth' }); + await client.auth.signInWithProvider('google'); + const [, opts] = fetchMock.mock.calls[0]; + // The SDK can't know the app's mount path, so it returns the user to + // where they started rather than a hard-coded root '/login'. + expect(JSON.parse(opts.body).callbackURL).toBe('https://app.example.com/_console/login'); + expect(assign).toHaveBeenCalledWith('https://accounts.google.com/o/oauth2/auth'); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('signInWithProvider honours an explicit callbackURL', async () => { + const assign = vi.fn(); + vi.stubGlobal('window', { + location: { href: 'https://app.example.com/_console/login', assign }, + }); + try { + const { client, fetchMock } = createMockClient({ url: 'https://accounts.google.com/o/oauth2/auth' }); + await client.auth.signInWithProvider('google', { callbackURL: 'https://app.example.com/_console/home' }); + const [, opts] = fetchMock.mock.calls[0]; + expect(JSON.parse(opts.body).callbackURL).toBe('https://app.example.com/_console/home'); + } finally { + vi.unstubAllGlobals(); + } + }); }); describe('Notifications namespace', () => { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 2fec75a751..387a9d344f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1655,6 +1655,12 @@ export class ObjectStackClient { * - OIDC/enterprise providers: calls POST /sign-in/oauth2 with `{ providerId }`. * * After the provider callback better-auth sets the session cookie and redirects to `callbackURL`. + * + * The default `callbackURL` is the CURRENT page (`window.location.href`), + * not a hard-coded `/login`: the SDK cannot know the app's mount path (the + * Console lives under `/_console`, others differ), so returning the user to + * where they started is the only base-path-correct default. This mirrors + * `linkSocial`. Pass an explicit `callbackURL` to land somewhere else. */ signInWithProvider: async ( provider: string, @@ -1664,7 +1670,7 @@ export class ObjectStackClient { throw new Error('signInWithProvider requires a browser environment'); } const route = this.getRoute('auth'); - const callbackURL = opts?.callbackURL ?? window.location.origin + '/login'; + const callbackURL = opts?.callbackURL ?? window.location.href; const isOidc = opts?.type === 'oidc'; const endpoint = isOidc ? '/sign-in/oauth2' : '/sign-in/social'; const body: Record = isOidc diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index adcc649dc9..b48acdacd3 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1251,7 +1251,8 @@ describe('AuthManager', () => { expect(sms.sent).toHaveLength(1); expect(sms.sent[0].to).toBe(PHONE); expect(sms.sent[0].body).toMatch(/verification code/); - expect(sms.sent[0].body).toContain('http://localhost:3000'); + // Links to the actual Console sign-in page, not the bare origin. + expect(sms.sent[0].body).toContain('http://localhost:3000/_console/login'); }); // #2815 — localised, tenant-customisable SMS bodies. @@ -1268,7 +1269,7 @@ describe('AuthManager', () => { await manager.sendPhoneInviteSms(PHONE); expect(sms.sent[1].body).toContain('账号已开通'); - expect(sms.sent[1].body).toContain('http://localhost:3000'); + expect(sms.sent[1].body).toContain('http://localhost:3000/_console/login'); }); it('a tenant sys_notification_template row overrides the built-in text', async () => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index b333636e24..da2226c66f 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1516,15 +1516,12 @@ export class AuthManager { // operators / UI can fall back to copy-paste flows. Replace this // with a real mail integration when available. sendInvitationEmail: async ({ email: recipientEmail, invitation, organization: org, inviter }) => { - // The accept-invitation page is a Console SPA route mounted under - // `uiBasePath` (default `/_console`) — the SAME router/basename as - // /login, /register and /oauth/consent. The Console builds the very - // same link for its "copy invitation link" action - // (`${origin}${BASE_URL}accept-invitation/`), so the emailed link - // MUST carry that prefix (and an absolute https origin) or it 404s at - // the host root instead of resolving to the page. - const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, ''); - const acceptUrl = `${this.getCanonicalOrigin()}${uiBase}/accept-invitation/${invitation.id}`; + // The accept-invitation page is a Console SPA route; the Console + // builds the very same link for its "copy invitation link" action + // (`${origin}${BASE_URL}accept-invitation/`). Compose it through + // the single-source helper so the `/_console` prefix and absolute + // https origin are guaranteed. + const acceptUrl = this.getConsolePageUrl(`/accept-invitation/${invitation.id}`); // #2766 V1.5 — placeholder addresses are never real recipients. if (isPlaceholderEmail(recipientEmail)) { throw new Error( @@ -1723,15 +1720,13 @@ export class AuthManager { plugins.push(jwt({ schema: buildJwtPluginSchema() })); const { oauthProvider } = await import('@better-auth/oauth-provider'); - const baseUrl = this.getCanonicalOrigin(); - const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, ''); const dcr = resolveDcrEnabled(pluginConfig); plugins.push(oauthProvider({ // Console SPA renders both pages (replaces the legacy Account SPA at // /_account). Override `uiBasePath` in AuthConfig if Console is // mounted elsewhere. - loginPage: `${baseUrl}${uiBase}/login`, - consentPage: `${baseUrl}${uiBase}/oauth/consent`, + loginPage: this.getConsolePageUrl('/login'), + consentPage: this.getConsolePageUrl('/oauth/consent'), schema: buildOauthProviderPluginSchema(), // ── MCP OAuth track (#2698) ──────────────────────────────── // Coarse tool-family scopes for the platform's own MCP endpoint, @@ -1815,10 +1810,8 @@ export class AuthManager { // `verification_uri_complete`. if (enabled.deviceAuthorization) { const { deviceAuthorization } = await import('better-auth/plugins/device-authorization'); - const baseUrl = this.getCanonicalOrigin(); - const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, ''); plugins.push(deviceAuthorization({ - verificationUri: `${baseUrl}${uiBase}/auth/device`, + verificationUri: this.getConsolePageUrl('/auth/device'), schema: buildDeviceAuthorizationPluginSchema(), })); } @@ -2201,11 +2194,14 @@ export class AuthManager { if (!sms) { throw new Error('SMS_SERVICE_REQUIRED: no SMS service is configured for this deployment.'); } - const baseUrl = this.getCanonicalOrigin(); // #2815 — localised, tenant-customisable body (see deliverPhoneOtp). + // `loginUrl` points at the actual Console sign-in page; `baseUrl` (bare + // origin) is kept for backward-compatibility with tenant-overridden + // templates that still interpolate `{{baseUrl}}`. const body = await this.renderPhoneSmsBody(PHONE_SMS_TOPICS.invite, { appName: this.getAppName(), - baseUrl, + baseUrl: this.getCanonicalOrigin(), + loginUrl: this.getConsolePageUrl('/login'), }); const result = await sms.send({ to: phone, body, templateParams: { content: body } }); if (result.status === 'failed') { @@ -2378,6 +2374,23 @@ export class AuthManager { return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; } + /** + * Absolute URL of a page served by the Console SPA. The Console is mounted + * under `uiBasePath` (default `/_console`) — it owns `/login`, `/register`, + * `/oauth/consent`, `/auth/device`, `/accept-invitation/:id`, … — so EVERY + * link we hand to a user (email, SMS, OAuth redirect, device flow) must be + * `${origin}${uiBasePath}${path}`. This is the single source of truth for + * that composition: build page links through here, never by hand, so a bare + * host (missing scheme) or a missing `/_console` prefix can't creep back in + * one link at a time. Set `uiBasePath` in AuthConfig if Console is mounted + * elsewhere. + */ + private getConsolePageUrl(path: string): string { + const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, ''); + const rel = path.startsWith('/') ? path : `/${path}`; + return `${this.getCanonicalOrigin()}${uiBase}${rel}`; + } + /** * The OAuth issuer identifier: better-auth's `baseURL` INCLUDING `basePath` * (e.g. `https://acme.example.com/api/v1/auth`) — this is the `iss` claim diff --git a/packages/plugins/plugin-auth/src/phone-sms-texts.ts b/packages/plugins/plugin-auth/src/phone-sms-texts.ts index 456a3c06e8..9e41fe47e0 100644 --- a/packages/plugins/plugin-auth/src/phone-sms-texts.ts +++ b/packages/plugins/plugin-auth/src/phone-sms-texts.ts @@ -71,7 +71,7 @@ export const BUILTIN_PHONE_SMS_TEMPLATES: readonly PhoneSmsTemplateRow[] = [ channel: 'sms', locale: 'en', subject: 'Account invitation', - body: 'Your {{appName}} account is ready. Sign in with this phone number using a verification code at {{baseUrl}}, then set your password.', + body: 'Your {{appName}} account is ready. Sign in with this phone number using a verification code at {{loginUrl}}, then set your password.', format: 'text', is_active: true, }, @@ -80,7 +80,7 @@ export const BUILTIN_PHONE_SMS_TEMPLATES: readonly PhoneSmsTemplateRow[] = [ channel: 'sms', locale: 'zh', subject: '账号邀请', - body: '您的 {{appName}} 账号已开通。请访问 {{baseUrl}},使用本手机号通过验证码登录,然后设置您的密码。', + body: '您的 {{appName}} 账号已开通。请访问 {{loginUrl}},使用本手机号通过验证码登录,然后设置您的密码。', format: 'text', is_active: true, },