Skip to content

Commit dc6ed20

Browse files
committed
Auto-detect auth baseUrl at runtime
Detect and apply the actual server port for the Auth plugin at runtime. - Add optional getPort() to IHttpServer so code can query the bound port. - Add AuthManager.setRuntimeBaseUrl(url) to allow updating baseUrl before the auth instance is created; emits a warning and is a no-op if called after initialization. - Update AuthPlugin to auto-detect the server port (when available) and call setRuntimeBaseUrl if the configured origin differs from the actual server URL; logs the update. - Add unit tests covering setRuntimeBaseUrl behavior and the plugin's auto-detection logic. - Tweak objectstack.config.ts comment to note baseUrl is auto-detected at runtime. These changes ensure the auth instance uses the real server URL (useful when the runtime port differs from config) while preserving backward compatibility and emitting a warning if runtime updates are attempted after initialization.
1 parent 37bb10e commit dc6ed20

6 files changed

Lines changed: 206 additions & 1 deletion

File tree

objectstack.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default defineStack({
3232
}),
3333
new AuthPlugin({
3434
secret: 'dev-secret-please-change-in-production-min-32-chars',
35-
baseUrl: 'http://localhost:3000',
35+
// baseUrl is auto-detected from the running server port at runtime.
3636
// Optional: Enable OAuth providers
3737
// providers: [
3838
// {

packages/core/src/contracts/http-server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ export interface IHttpServer {
135135
* @returns Promise that resolves when server is ready
136136
*/
137137
listen(port: number): Promise<void>;
138+
139+
/**
140+
* Get the port the server is listening on.
141+
* Returns the actual bound port after `listen()` resolves, or the
142+
* configured port before that.
143+
*/
144+
getPort?(): number;
138145

139146
/**
140147
* Stop the HTTP server

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,73 @@ describe('AuthManager', () => {
472472
});
473473
});
474474

475+
describe('setRuntimeBaseUrl', () => {
476+
it('should update baseURL before auth instance is created', () => {
477+
let capturedConfig: any;
478+
(betterAuth as any).mockImplementation((config: any) => {
479+
capturedConfig = config;
480+
return { handler: vi.fn(), api: {} };
481+
});
482+
483+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
484+
const manager = new AuthManager({
485+
secret: 'test-secret-at-least-32-chars-long',
486+
baseUrl: 'http://localhost:3000',
487+
});
488+
489+
manager.setRuntimeBaseUrl('http://localhost:3002');
490+
manager.getAuthInstance();
491+
warnSpy.mockRestore();
492+
493+
expect(capturedConfig.baseURL).toBe('http://localhost:3002');
494+
});
495+
496+
it('should be a no-op and warn when called after auth instance is created', () => {
497+
let capturedConfig: any;
498+
(betterAuth as any).mockImplementation((config: any) => {
499+
capturedConfig = config;
500+
return { handler: vi.fn(), api: {} };
501+
});
502+
503+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
504+
const manager = new AuthManager({
505+
secret: 'test-secret-at-least-32-chars-long',
506+
baseUrl: 'http://localhost:3000',
507+
});
508+
509+
// Force auth instance creation
510+
manager.getAuthInstance();
511+
expect(capturedConfig.baseURL).toBe('http://localhost:3000');
512+
513+
// Now try to change — should warn and not affect the already-created instance
514+
manager.setRuntimeBaseUrl('http://localhost:4000');
515+
516+
expect(warnSpy).toHaveBeenCalledWith(
517+
expect.stringContaining('setRuntimeBaseUrl() called after the auth instance was already created'),
518+
);
519+
warnSpy.mockRestore();
520+
});
521+
522+
it('should override the default fallback (localhost:3000) when no baseUrl was configured', () => {
523+
let capturedConfig: any;
524+
(betterAuth as any).mockImplementation((config: any) => {
525+
capturedConfig = config;
526+
return { handler: vi.fn(), api: {} };
527+
});
528+
529+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
530+
const manager = new AuthManager({
531+
secret: 'test-secret-at-least-32-chars-long',
532+
});
533+
534+
manager.setRuntimeBaseUrl('http://localhost:3002');
535+
manager.getAuthInstance();
536+
warnSpy.mockRestore();
537+
538+
expect(capturedConfig.baseURL).toBe('http://localhost:3002');
539+
});
540+
});
541+
475542
describe('socialProviders passthrough', () => {
476543
it('should forward socialProviders to betterAuth when provided', () => {
477544
let capturedConfig: any;

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,27 @@ export class AuthManager {
272272
return envSecret;
273273
}
274274

275+
/**
276+
* Update the base URL at runtime.
277+
*
278+
* This **must** be called before the first request triggers lazy
279+
* initialisation of the better-auth instance — typically from a
280+
* `kernel:ready` hook where the actual server port is known.
281+
*
282+
* If the auth instance has already been created this is a no-op and
283+
* a warning is emitted.
284+
*/
285+
setRuntimeBaseUrl(url: string): void {
286+
if (this.auth) {
287+
console.warn(
288+
'[AuthManager] setRuntimeBaseUrl() called after the auth instance was already created — ignoring. ' +
289+
'Ensure this method is called before the first request.',
290+
);
291+
return;
292+
}
293+
this.config = { ...this.config, baseUrl: url };
294+
}
295+
275296
/**
276297
* Get the underlying better-auth instance
277298
* Useful for advanced use cases

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,95 @@ describe('AuthPlugin', () => {
265265
// Should NOT throw
266266
});
267267

268+
it('should auto-detect baseUrl from http-server port when port differs', async () => {
269+
const mockRawApp = { all: vi.fn() };
270+
const mockHttpServer = {
271+
post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
272+
patch: vi.fn(), use: vi.fn(),
273+
getRawApp: vi.fn(() => mockRawApp),
274+
getPort: vi.fn(() => 3002),
275+
};
276+
277+
mockContext.getService = vi.fn((name: string) => {
278+
if (name === 'http-server') return mockHttpServer;
279+
throw new Error(`Service not found: ${name}`);
280+
});
281+
282+
// AuthPlugin configured with default port 3000, but server will be on 3002
283+
const registeredAuthManager = (mockContext.registerService as any).mock.calls[0][1];
284+
const setRuntimeSpy = vi.spyOn(registeredAuthManager, 'setRuntimeBaseUrl');
285+
286+
await authPlugin.start(mockContext);
287+
await hookCapture.trigger('kernel:ready');
288+
289+
expect(setRuntimeSpy).toHaveBeenCalledWith('http://localhost:3002');
290+
expect(mockContext.logger.info).toHaveBeenCalledWith(
291+
expect.stringContaining('Auth baseUrl auto-updated to http://localhost:3002'),
292+
);
293+
});
294+
295+
it('should NOT update baseUrl when port matches configured value', async () => {
296+
const localHookCapture = createHookCapture();
297+
const localPlugin = new AuthPlugin({
298+
secret: 'test-secret-at-least-32-chars-long',
299+
baseUrl: 'http://localhost:3000',
300+
});
301+
mockContext.hook = localHookCapture.hookFn;
302+
await localPlugin.init(mockContext);
303+
304+
const mockRawApp = { all: vi.fn() };
305+
const mockHttpServer = {
306+
post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
307+
patch: vi.fn(), use: vi.fn(),
308+
getRawApp: vi.fn(() => mockRawApp),
309+
getPort: vi.fn(() => 3000),
310+
};
311+
312+
mockContext.getService = vi.fn((name: string) => {
313+
if (name === 'http-server') return mockHttpServer;
314+
throw new Error(`Service not found: ${name}`);
315+
});
316+
317+
const registeredAuthManager = (mockContext.registerService as any).mock.calls.at(-1)[1];
318+
const setRuntimeSpy = vi.spyOn(registeredAuthManager, 'setRuntimeBaseUrl');
319+
320+
await localPlugin.start(mockContext);
321+
await localHookCapture.trigger('kernel:ready');
322+
323+
expect(setRuntimeSpy).not.toHaveBeenCalled();
324+
});
325+
326+
it('should auto-detect baseUrl when no baseUrl configured (uses default fallback)', async () => {
327+
const localHookCapture = createHookCapture();
328+
// No baseUrl — defaults to http://localhost:3000 internally
329+
const localPlugin = new AuthPlugin({
330+
secret: 'test-secret-at-least-32-chars-long',
331+
});
332+
mockContext.hook = localHookCapture.hookFn;
333+
await localPlugin.init(mockContext);
334+
335+
const mockRawApp = { all: vi.fn() };
336+
const mockHttpServer = {
337+
post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
338+
patch: vi.fn(), use: vi.fn(),
339+
getRawApp: vi.fn(() => mockRawApp),
340+
getPort: vi.fn(() => 3002),
341+
};
342+
343+
mockContext.getService = vi.fn((name: string) => {
344+
if (name === 'http-server') return mockHttpServer;
345+
throw new Error(`Service not found: ${name}`);
346+
});
347+
348+
const registeredAuthManager = (mockContext.registerService as any).mock.calls.at(-1)[1];
349+
const setRuntimeSpy = vi.spyOn(registeredAuthManager, 'setRuntimeBaseUrl');
350+
351+
await localPlugin.start(mockContext);
352+
await localHookCapture.trigger('kernel:ready');
353+
354+
expect(setRuntimeSpy).toHaveBeenCalledWith('http://localhost:3002');
355+
});
356+
268357
it('should throw error if auth not initialized', async () => {
269358
const uninitializedPlugin = new AuthPlugin({
270359
secret: 'test-secret',

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,27 @@ export class AuthPlugin implements Plugin {
114114
}
115115

116116
if (httpServer) {
117+
// Auto-detect the actual server URL when no explicit baseUrl was
118+
// configured, or when the configured baseUrl uses a different port
119+
// than the running server (e.g. port 3000 configured but 3002 bound).
120+
// getPort() is optional on IHttpServer; duck-type check for it.
121+
const serverWithPort = httpServer as IHttpServer & { getPort?: () => number };
122+
if (this.authManager && typeof serverWithPort.getPort === 'function') {
123+
const actualPort = serverWithPort.getPort();
124+
if (actualPort) {
125+
const configuredUrl = this.options.baseUrl || 'http://localhost:3000';
126+
const configuredOrigin = new URL(configuredUrl).origin;
127+
const actualUrl = `http://localhost:${actualPort}`;
128+
129+
if (configuredOrigin !== actualUrl) {
130+
this.authManager.setRuntimeBaseUrl(actualUrl);
131+
ctx.logger.info(
132+
`Auth baseUrl auto-updated to ${actualUrl} (configured: ${configuredUrl})`,
133+
);
134+
}
135+
}
136+
}
137+
117138
// Route registration errors should propagate (server misconfiguration)
118139
this.registerAuthRoutes(httpServer, ctx);
119140
ctx.logger.info(`Auth routes registered at ${this.options.basePath}`);

0 commit comments

Comments
 (0)