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
28 changes: 28 additions & 0 deletions .changeset/console-page-url-single-source.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand Down
33 changes: 33 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string> = isOidc
Expand Down
5 changes: 3 additions & 2 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 () => {
Expand Down
49 changes: 31 additions & 18 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`), 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/<id>`). 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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
}));
}
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/plugin-auth/src/phone-sms-texts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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,
},
Expand Down