Skip to content

Commit 160d565

Browse files
claudeos-zhuang
authored andcommitted
fix(plugin-auth): harden all user-facing auth URLs to an absolute https origin
Follow-up to the invitation-link fix (#2847). Audited every backend site that builds a user-facing link and found the same latent flaw: they read the raw `config.baseUrl`, so a bare-host value (no scheme) yielded relative-looking, unclickable links. Routed them all through the hardened getCanonicalOrigin(): - better-auth `baseURL` (583) — source of the reset-password / verify-email / magic-link email links. - OAuth `loginPage` / `consentPage` (1723-1724). - Device-authorization `verificationUri` (1811). - Phone-invite SMS `{{baseUrl}}` (sendPhoneInviteSms, 2201). Added tests: bare host promoted to https, explicit scheme/trailing slash preserved (getAuthIssuer), and better-auth baseURL carries the scheme. Full @objectstack/plugin-auth suite passes (406 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NT4mx1fHXtkxery6MtLTsp
1 parent ca98878 commit 160d565

4 files changed

Lines changed: 65 additions & 4 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(auth): guarantee an absolute https origin for every user-facing auth URL
6+
7+
Follow-up to the invitation-link fix. Several other user-facing links were
8+
built from the raw `config.baseUrl` with no scheme guarantee, so a bare-host
9+
`baseUrl` (e.g. `cloud.objectos.ai`) produced relative-looking, unclickable
10+
links. All now flow through the hardened `getCanonicalOrigin()` (prepends
11+
`https://` when the scheme is missing, trims a trailing slash):
12+
13+
- better-auth `baseURL` — the reset-password, verify-email and magic-link
14+
email links are derived from it.
15+
- OAuth `loginPage` / `consentPage` redirect targets.
16+
- Device-authorization `verificationUri`.
17+
- The phone-invite SMS `{{baseUrl}}`.
18+
19+
Deployments that already configure an absolute `baseUrl` are unaffected.

packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,25 @@ describe('canonical issuer / resource URLs', () => {
114114
expect(manager().getAuthIssuer()).toBe('https://acme.example.com/api/v1/auth');
115115
});
116116

117+
// getCanonicalOrigin() backs every user-facing URL (invitation, loginPage,
118+
// consentPage, device verificationUri, and the reset/verify/magic-link email
119+
// links better-auth derives from baseURL). A bare host must become https://.
120+
it('a bare-host baseUrl is promoted to an absolute https:// origin', () => {
121+
const m = new AuthManager({
122+
secret: 'test-secret-at-least-32-chars-long',
123+
baseUrl: 'cloud.objectos.ai',
124+
});
125+
expect(m.getAuthIssuer()).toBe('https://cloud.objectos.ai/api/v1/auth');
126+
});
127+
128+
it('an explicit scheme and a trailing slash are preserved / trimmed, not doubled', () => {
129+
const m = new AuthManager({
130+
secret: 'test-secret-at-least-32-chars-long',
131+
baseUrl: 'https://acme.example.com/',
132+
});
133+
expect(m.getAuthIssuer()).toBe('https://acme.example.com/api/v1/auth');
134+
});
135+
117136
it('MCP resource = baseUrl + api prefix + /mcp (derived from the auth basePath)', () => {
118137
expect(manager().getMcpResourceUrl()).toBe('https://acme.example.com/api/v1/mcp');
119138
});

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,26 @@ describe('AuthManager', () => {
801801
warnSpy.mockRestore();
802802
});
803803

804+
it('passes an absolute https:// baseURL to better-auth when baseUrl is a bare host', async () => {
805+
let capturedConfig: any;
806+
(betterAuth as any).mockImplementation((config: any) => {
807+
capturedConfig = config;
808+
return { handler: vi.fn(), api: {} };
809+
});
810+
811+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
812+
const manager = new AuthManager({
813+
secret: 'test-secret-at-least-32-chars-long',
814+
baseUrl: 'cloud.objectos.ai',
815+
});
816+
await manager.getAuthInstance();
817+
warnSpy.mockRestore();
818+
819+
// reset-password / verify-email / magic-link email links are derived
820+
// from this baseURL — it must carry a scheme or they are unclickable.
821+
expect(capturedConfig.baseURL).toBe('https://cloud.objectos.ai');
822+
});
823+
804824
it('should override the default fallback (localhost:3000) when no baseUrl was configured', async () => {
805825
let capturedConfig: any;
806826
(betterAuth as any).mockImplementation((config: any) => {

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,10 @@ export class AuthManager {
580580
const betterAuthConfig: BetterAuthOptions = {
581581
// Base configuration
582582
secret: this.config.secret || this.generateSecret(),
583-
baseURL: this.config.baseUrl || 'http://localhost:3000',
583+
// Absolute origin (getCanonicalOrigin prepends https:// when baseUrl is a
584+
// bare host) so the reset-password / verify-email / magic-link URLs
585+
// better-auth derives from baseURL are always clickable links.
586+
baseURL: this.getCanonicalOrigin(),
584587
basePath: this.config.basePath || '/api/v1/auth',
585588

586589
// Database adapter configuration
@@ -1720,7 +1723,7 @@ export class AuthManager {
17201723
plugins.push(jwt({ schema: buildJwtPluginSchema() }));
17211724

17221725
const { oauthProvider } = await import('@better-auth/oauth-provider');
1723-
const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, '');
1726+
const baseUrl = this.getCanonicalOrigin();
17241727
const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, '');
17251728
const dcr = resolveDcrEnabled(pluginConfig);
17261729
plugins.push(oauthProvider({
@@ -1812,7 +1815,7 @@ export class AuthManager {
18121815
// `verification_uri_complete`.
18131816
if (enabled.deviceAuthorization) {
18141817
const { deviceAuthorization } = await import('better-auth/plugins/device-authorization');
1815-
const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, '');
1818+
const baseUrl = this.getCanonicalOrigin();
18161819
const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, '');
18171820
plugins.push(deviceAuthorization({
18181821
verificationUri: `${baseUrl}${uiBase}/auth/device`,
@@ -2198,7 +2201,7 @@ export class AuthManager {
21982201
if (!sms) {
21992202
throw new Error('SMS_SERVICE_REQUIRED: no SMS service is configured for this deployment.');
22002203
}
2201-
const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, '');
2204+
const baseUrl = this.getCanonicalOrigin();
22022205
// #2815 — localised, tenant-customisable body (see deliverPhoneOtp).
22032206
const body = await this.renderPhoneSmsBody(PHONE_SMS_TOPICS.invite, {
22042207
appName: this.getAppName(),

0 commit comments

Comments
 (0)