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
19 changes: 19 additions & 0 deletions .changeset/auth-url-scheme-hardening.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
20 changes: 20 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
11 changes: 7 additions & 4 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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(),
Expand Down