diff --git a/.changeset/auth-url-scheme-hardening.md b/.changeset/auth-url-scheme-hardening.md new file mode 100644 index 000000000..f06febcd6 --- /dev/null +++ b/.changeset/auth-url-scheme-hardening.md @@ -0,0 +1,19 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(auth): guarantee an absolute https origin for every user-facing auth URL + +Follow-up to the invitation-link fix. Several other user-facing links were +built from the raw `config.baseUrl` with no scheme guarantee, so a bare-host +`baseUrl` (e.g. `cloud.objectos.ai`) produced relative-looking, unclickable +links. All now flow through the hardened `getCanonicalOrigin()` (prepends +`https://` when the scheme is missing, trims a trailing slash): + +- better-auth `baseURL` — the reset-password, verify-email and magic-link + email links are derived from it. +- OAuth `loginPage` / `consentPage` redirect targets. +- Device-authorization `verificationUri`. +- The phone-invite SMS `{{baseUrl}}`. + +Deployments that already configure an absolute `baseUrl` are unaffected. diff --git a/packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts b/packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts index e52d32a76..2ba1f7922 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts @@ -114,6 +114,25 @@ describe('canonical issuer / resource URLs', () => { expect(manager().getAuthIssuer()).toBe('https://acme.example.com/api/v1/auth'); }); + // getCanonicalOrigin() backs every user-facing URL (invitation, loginPage, + // consentPage, device verificationUri, and the reset/verify/magic-link email + // links better-auth derives from baseURL). A bare host must become https://. + it('a bare-host baseUrl is promoted to an absolute https:// origin', () => { + const m = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'cloud.objectos.ai', + }); + expect(m.getAuthIssuer()).toBe('https://cloud.objectos.ai/api/v1/auth'); + }); + + it('an explicit scheme and a trailing slash are preserved / trimmed, not doubled', () => { + const m = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'https://acme.example.com/', + }); + expect(m.getAuthIssuer()).toBe('https://acme.example.com/api/v1/auth'); + }); + it('MCP resource = baseUrl + api prefix + /mcp (derived from the auth basePath)', () => { expect(manager().getMcpResourceUrl()).toBe('https://acme.example.com/api/v1/mcp'); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 64ad82fc9..adcc649dc 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -801,6 +801,26 @@ describe('AuthManager', () => { warnSpy.mockRestore(); }); + it('passes an absolute https:// baseURL to better-auth when baseUrl is a bare host', async () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'cloud.objectos.ai', + }); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + + // reset-password / verify-email / magic-link email links are derived + // from this baseURL — it must carry a scheme or they are unclickable. + expect(capturedConfig.baseURL).toBe('https://cloud.objectos.ai'); + }); + it('should override the default fallback (localhost:3000) when no baseUrl was configured', async () => { let capturedConfig: any; (betterAuth as any).mockImplementation((config: any) => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 19524427c..b333636e2 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -580,7 +580,10 @@ export class AuthManager { const betterAuthConfig: BetterAuthOptions = { // Base configuration secret: this.config.secret || this.generateSecret(), - baseURL: this.config.baseUrl || 'http://localhost:3000', + // Absolute origin (getCanonicalOrigin prepends https:// when baseUrl is a + // bare host) so the reset-password / verify-email / magic-link URLs + // better-auth derives from baseURL are always clickable links. + baseURL: this.getCanonicalOrigin(), basePath: this.config.basePath || '/api/v1/auth', // Database adapter configuration @@ -1720,7 +1723,7 @@ export class AuthManager { plugins.push(jwt({ schema: buildJwtPluginSchema() })); const { oauthProvider } = await import('@better-auth/oauth-provider'); - const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, ''); + const baseUrl = this.getCanonicalOrigin(); const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, ''); const dcr = resolveDcrEnabled(pluginConfig); plugins.push(oauthProvider({ @@ -1812,7 +1815,7 @@ export class AuthManager { // `verification_uri_complete`. if (enabled.deviceAuthorization) { const { deviceAuthorization } = await import('better-auth/plugins/device-authorization'); - const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, ''); + const baseUrl = this.getCanonicalOrigin(); const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, ''); plugins.push(deviceAuthorization({ verificationUri: `${baseUrl}${uiBase}/auth/device`, @@ -2198,7 +2201,7 @@ export class AuthManager { if (!sms) { throw new Error('SMS_SERVICE_REQUIRED: no SMS service is configured for this deployment.'); } - const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, ''); + const baseUrl = this.getCanonicalOrigin(); // #2815 — localised, tenant-customisable body (see deliverPhoneOtp). const body = await this.renderPhoneSmsBody(PHONE_SMS_TOPICS.invite, { appName: this.getAppName(),