From 4442ff1a76db7b39647ce7d774117842c692239c Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Wed, 19 Aug 2026 19:17:13 +0530 Subject: [PATCH 1/4] make AUTH0_APP_BASE_URL optional, infer from request when not set --- __tests__/server/auth0-server.test.ts | 5 +-- __tests__/server/handlers.test.ts | 58 ++++++++++++++++++++++++++- src/server/auth0-server.ts | 14 +------ src/server/handlers.ts | 14 ++++--- 4 files changed, 68 insertions(+), 23 deletions(-) diff --git a/__tests__/server/auth0-server.test.ts b/__tests__/server/auth0-server.test.ts index e1f6750..904d645 100644 --- a/__tests__/server/auth0-server.test.ts +++ b/__tests__/server/auth0-server.test.ts @@ -110,9 +110,9 @@ describe('Auth0Server', () => { expect(() => new Auth0Server(rest)).toThrowError(ConfigurationError); }); - it('throws ConfigurationError when appBaseUrl is missing', () => { + it('does not throw when appBaseUrl is missing (inferred from request at runtime)', () => { const { appBaseUrl: _, ...rest } = validConfig; - expect(() => new Auth0Server(rest)).toThrowError(ConfigurationError); + expect(() => new Auth0Server(rest)).not.toThrow(); }); it('error message names the missing env var', () => { @@ -136,7 +136,6 @@ describe('Auth0Server', () => { expect(message).toContain('AUTH0_CLIENT_ID'); expect(message).toContain('AUTH0_CLIENT_SECRET'); expect(message).toContain('AUTH0_SESSION_SECRET'); - expect(message).toContain('AUTH0_APP_BASE_URL'); } }); diff --git a/__tests__/server/handlers.test.ts b/__tests__/server/handlers.test.ts index 9bc8960..ffa5c64 100644 --- a/__tests__/server/handlers.test.ts +++ b/__tests__/server/handlers.test.ts @@ -40,6 +40,7 @@ function makeAuth0( completeInteractiveLogin: ReturnType; logout: ReturnType; handleBackchannelLogout: ReturnType; + appBaseUrl: string | undefined; }> = {} ): Auth0Server { return { @@ -66,7 +67,7 @@ function makeAuth0( vi.fn().mockResolvedValue(undefined) }, config: { - appBaseUrl: 'http://localhost:3000', + appBaseUrl: 'appBaseUrl' in overrides ? overrides.appBaseUrl : 'http://localhost:3000', domain: 'test.auth0.com', clientId: 'abc', clientSecret: 'secret', @@ -168,7 +169,43 @@ describe('handleLogin', () => { expect(startInteractiveLogin).toHaveBeenCalledWith( expect.objectContaining({ - authorizationParams: { prompt: 'login', screen_hint: 'signup' } + authorizationParams: expect.objectContaining({ prompt: 'login', screen_hint: 'signup' }) + }), + expect.any(Object) + ); + }); + + it('always passes redirect_uri derived from appBaseUrl to startInteractiveLogin', async () => { + const startInteractiveLogin = vi + .fn() + .mockResolvedValue(new URL('https://test.auth0.com/authorize')); + const auth0 = makeAuth0({ startInteractiveLogin }); + + await handleLogin(auth0, makeRequest('http://localhost:3000/auth/login')); + + expect(startInteractiveLogin).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationParams: expect.objectContaining({ + redirect_uri: 'http://localhost:3000/auth/callback' + }) + }), + expect.any(Object) + ); + }); + + it('infers redirect_uri from the request origin when appBaseUrl is not configured', async () => { + const startInteractiveLogin = vi + .fn() + .mockResolvedValue(new URL('https://test.auth0.com/authorize')); + const auth0 = makeAuth0({ startInteractiveLogin, appBaseUrl: undefined }); + + await handleLogin(auth0, makeRequest('https://myapp.com/auth/login')); + + expect(startInteractiveLogin).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationParams: expect.objectContaining({ + redirect_uri: 'https://myapp.com/auth/callback' + }) }), expect.any(Object) ); @@ -494,6 +531,23 @@ describe('handleLogout', () => { ); }); + it('infers returnTo from the request origin when appBaseUrl is not configured', async () => { + const logoutFn = vi + .fn() + .mockResolvedValue(new URL('https://test.auth0.com/v2/logout')); + const auth0 = makeAuth0({ logout: logoutFn, appBaseUrl: undefined }); + + await handleLogout( + auth0, + makeRequest('https://myapp.com/auth/logout', { method: 'POST' }) + ); + + expect(logoutFn).toHaveBeenCalledWith( + { returnTo: 'https://myapp.com' }, + expect.any(Object) + ); + }); + it('copies Set-Cookie headers (cleared session) onto the redirect', async () => { const logoutFn = vi.fn().mockImplementation(async (_opts, storeOptions) => { storeOptions.response.headers.append( diff --git a/src/server/auth0-server.ts b/src/server/auth0-server.ts index 2ca4e36..52cc33c 100644 --- a/src/server/auth0-server.ts +++ b/src/server/auth0-server.ts @@ -32,7 +32,7 @@ export interface ResolvedAuth0ServerConfig { clientId: string; clientSecret: string; secret: string; - appBaseUrl: string; + appBaseUrl?: string; audience?: string; scope: string; } @@ -63,11 +63,6 @@ const REQUIRED_FIELDS: Array<{ key: 'secret', envVar: 'AUTH0_SESSION_SECRET', hint: 'A 32+ character random string. Generate one with: openssl rand -hex 32' - }, - { - key: 'appBaseUrl', - envVar: 'AUTH0_APP_BASE_URL', - hint: 'The base URL of your app, e.g. https://example.com or http://localhost:3000' } ]; @@ -142,19 +137,12 @@ export class Auth0Server { cookieHandler ); - // Callback URL — Auth0 redirects here after the user authenticates - const callbackUrl = new URL( - '/auth/callback', - this.config.appBaseUrl - ).toString(); - // ServerClient is stateless — no network calls happen here this.serverClient = new ServerClient({ domain: this.config.domain, clientId: this.config.clientId, clientSecret: this.config.clientSecret, authorizationParams: { - redirect_uri: callbackUrl, scope: this.config.scope, ...(this.config.audience ? { audience: this.config.audience } : {}) }, diff --git a/src/server/handlers.ts b/src/server/handlers.ts index 57be389..af1549d 100644 --- a/src/server/handlers.ts +++ b/src/server/handlers.ts @@ -100,12 +100,15 @@ export async function handleLogin( : null) ?? '/'; + const appBaseUrl = auth0.config.appBaseUrl ?? new URL(request.url).origin; + const authUrl = await auth0.serverClient.startInteractiveLogin( { appState: { returnTo }, - ...(options.authorizationParams - ? { authorizationParams: options.authorizationParams } - : {}) + authorizationParams: { + redirect_uri: new URL('/auth/callback', appBaseUrl).toString(), + ...options.authorizationParams + } }, storeOptions ); @@ -203,13 +206,14 @@ export async function handleLogout( // Query-string returnTo is validated as a relative path (same rules as login) // and resolved against the app origin before being forwarded to Auth0. // This prevents open redirects: only paths on the same domain are accepted. + const appBaseUrl = auth0.config.appBaseUrl ?? new URL(request.url).origin; const queryReturnTo = new URL(request.url).searchParams.get('returnTo'); const returnTo = options.returnTo ?? (queryReturnTo && isSafeRelativeUrl(queryReturnTo) - ? new URL(auth0.config.appBaseUrl).origin + queryReturnTo + ? new URL(appBaseUrl).origin + queryReturnTo : null) ?? - new URL(auth0.config.appBaseUrl).origin; + new URL(appBaseUrl).origin; const logoutUrl = await auth0.serverClient.logout({ returnTo }, storeOptions); From c0948cfd05b463edf91914b2da309e7849016d68 Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Thu, 20 Aug 2026 10:06:05 +0530 Subject: [PATCH 2/4] add beforeSessionSaved and onCallback hooks to Auth0Server --- __tests__/server/auth0-server.test.ts | 144 +++++++++++++++++++++++++- __tests__/server/handlers.test.ts | 27 +++++ src/server/auth0-server.ts | 70 ++++++++++++- src/server/handlers.ts | 5 + 4 files changed, 241 insertions(+), 5 deletions(-) diff --git a/__tests__/server/auth0-server.test.ts b/__tests__/server/auth0-server.test.ts index 904d645..5003e4a 100644 --- a/__tests__/server/auth0-server.test.ts +++ b/__tests__/server/auth0-server.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { Auth0Server } from '../../src/server/auth0-server.js'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Auth0Server, HookedStateStore } from '../../src/server/auth0-server.js'; import { ConfigurationError } from '../../src/errors/index.js'; +import type { Auth0Session } from '../../src/types/index.js'; const validConfig = { domain: 'test.auth0.com', @@ -148,4 +149,143 @@ describe('Auth0Server', () => { } }); }); + + // ─── Hooks ────────────────────────────────────────────────────────────────── + + describe('hooks', () => { + it('accepts a beforeSessionSaved hook without throwing', () => { + const auth0 = new Auth0Server({ + ...validConfig, + beforeSessionSaved: session => session + }); + expect(auth0).toBeDefined(); + }); + + it('accepts an onCallback hook and exposes it', () => { + const hook = vi.fn(); + const auth0 = new Auth0Server({ ...validConfig, onCallback: hook }); + expect(auth0.onCallback).toBe(hook); + }); + + it('onCallback is undefined when not provided', () => { + const auth0 = new Auth0Server(validConfig); + expect(auth0.onCallback).toBeUndefined(); + }); + }); +}); + +// ─── HookedStateStore ───────────────────────────────────────────────────────── + +function makeMockInner() { + return { + set: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue(null), + delete: vi.fn().mockResolvedValue(undefined) + }; +} + +function makeSessionData() { + return { + user: { sub: 'auth0|1', name: 'Test User' }, + tokenSets: [], + idToken: undefined, + refreshToken: undefined, + domain: 'test.auth0.com' + }; +} + +describe('HookedStateStore', () => { + it('calls the inner store set without modification when no hook is provided', async () => { + const inner = makeMockInner(); + const store = new HookedStateStore(inner as never); + const data = makeSessionData(); + const cookieJar = new Response(); + + await store.set('key', data as never, false, { + request: new Request('http://localhost'), + response: cookieJar + }); + + expect(inner.set).toHaveBeenCalledWith('key', expect.objectContaining({ user: data.user }), false, expect.any(Object)); + }); + + it('calls beforeSessionSaved and writes the modified session', async () => { + const inner = makeMockInner(); + const beforeSessionSaved = vi.fn((s: Auth0Session) => ({ + ...s, + user: { ...s.user, name: 'Modified' } + })); + const store = new HookedStateStore(inner as never, beforeSessionSaved); + const data = makeSessionData(); + const cookieJar = new Response(); + + await store.set('key', data as never, false, { + request: new Request('http://localhost'), + response: cookieJar + }); + + expect(beforeSessionSaved).toHaveBeenCalled(); + expect(inner.set).toHaveBeenCalledWith( + 'key', + expect.objectContaining({ user: expect.objectContaining({ name: 'Modified' }) }), + false, + expect.any(Object) + ); + }); + + it('captures the session keyed by the cookieJar response', async () => { + const inner = makeMockInner(); + const store = new HookedStateStore(inner as never); + const data = makeSessionData(); + const cookieJar = new Response(); + + await store.set('key', data as never, false, { + request: new Request('http://localhost'), + response: cookieJar + }); + + const captured = store.getCaptured(cookieJar); + expect(captured?.user.sub).toBe('auth0|1'); + }); + + it('getCaptured returns null for an unknown cookieJar', () => { + const inner = makeMockInner(); + const store = new HookedStateStore(inner as never); + expect(store.getCaptured(new Response())).toBeNull(); + }); + + it('different cookieJars do not share captured data', async () => { + const inner = makeMockInner(); + const store = new HookedStateStore(inner as never); + const jarA = new Response(); + const jarB = new Response(); + + await store.set('key', makeSessionData() as never, false, { + request: new Request('http://localhost'), + response: jarA + }); + + expect(store.getCaptured(jarA)).not.toBeNull(); + expect(store.getCaptured(jarB)).toBeNull(); + }); + + it('delegates get to the inner store', async () => { + const inner = makeMockInner(); + inner.get.mockResolvedValue({ user: { sub: 'auth0|1' } }); + const store = new HookedStateStore(inner as never); + + const result = await store.get('key', { request: new Request('http://localhost'), response: new Response() }); + + expect(inner.get).toHaveBeenCalledWith('key', expect.any(Object)); + expect(result).toEqual({ user: { sub: 'auth0|1' } }); + }); + + it('delegates delete to the inner store', async () => { + const inner = makeMockInner(); + const store = new HookedStateStore(inner as never); + + await store.delete('key', { request: new Request('http://localhost'), response: new Response() }); + + expect(inner.delete).toHaveBeenCalledWith('key', expect.any(Object)); + }); }); diff --git a/__tests__/server/handlers.test.ts b/__tests__/server/handlers.test.ts index ffa5c64..b934996 100644 --- a/__tests__/server/handlers.test.ts +++ b/__tests__/server/handlers.test.ts @@ -41,6 +41,8 @@ function makeAuth0( logout: ReturnType; handleBackchannelLogout: ReturnType; appBaseUrl: string | undefined; + onCallback: ReturnType; + capturedSession: object | null; }> = {} ): Auth0Server { return { @@ -66,6 +68,10 @@ function makeAuth0( overrides.handleBackchannelLogout ?? vi.fn().mockResolvedValue(undefined) }, + stateStore: { + getCaptured: vi.fn().mockReturnValue(overrides.capturedSession ?? null) + }, + onCallback: overrides.onCallback, config: { appBaseUrl: 'appBaseUrl' in overrides ? overrides.appBaseUrl : 'http://localhost:3000', domain: 'test.auth0.com', @@ -358,6 +364,27 @@ describe('handleCallback', () => { ); }); + it('calls onCallback with the captured session after a successful callback', async () => { + const onCallback = vi.fn().mockResolvedValue(undefined); + const capturedSession = { user: { sub: 'auth0|1' }, tokenSets: [], domain: 'test.auth0.com' }; + const auth0 = makeAuth0({ onCallback, capturedSession }); + + await handleCallback( + auth0, + makeRequest('http://localhost:3000/auth/callback?code=abc&state=xyz') + ); + + expect(onCallback).toHaveBeenCalledWith(capturedSession); + }); + + it('does not call onCallback when no hook is configured', async () => { + const auth0 = makeAuth0({ capturedSession: { user: { sub: 'auth0|1' }, tokenSets: [], domain: 'test.auth0.com' } }); + + await expect( + handleCallback(auth0, makeRequest('http://localhost:3000/auth/callback?code=abc&state=xyz')) + ).resolves.not.toThrow(); + }); + it('throws CallbackError when the transaction is missing', async () => { const auth0 = makeAuth0({ completeInteractiveLogin: vi diff --git a/src/server/auth0-server.ts b/src/server/auth0-server.ts index 52cc33c..0083bef 100644 --- a/src/server/auth0-server.ts +++ b/src/server/auth0-server.ts @@ -3,9 +3,11 @@ import { CookieTransactionStore, StatelessStateStore } from '@auth0/auth0-server-js'; +import type { StateData, TokenSet as UpstreamTokenSet } from '@auth0/auth0-server-js'; import { ConfigurationError } from '../errors/index.js'; import { ReactRouterCookieHandler } from './cookie-handler.js'; import type { StoreOptions } from './cookie-handler.js'; +import type { Auth0Session, Auth0User, TokenSet } from '../types/index.js'; // ─── Config ─────────────────────────────────────────────────────────────────── @@ -21,6 +23,8 @@ export interface Auth0ServerConfig { appBaseUrl?: string; // AUTH0_APP_BASE_URL audience?: string; // AUTH0_AUDIENCE (optional) scope?: string; // AUTH0_SCOPE (optional, default: openid profile email) + beforeSessionSaved?: (session: Auth0Session) => Auth0Session | Promise; + onCallback?: (session: Auth0Session) => void | Promise; } /** @@ -95,6 +99,63 @@ function resolveConfig( return resolved as ResolvedAuth0ServerConfig; } +// ─── HookedStateStore ───────────────────────────────────────────────────────── + +export class HookedStateStore { + private captured = new WeakMap(); + + constructor( + private inner: StatelessStateStore, + private beforeSessionSaved?: (session: Auth0Session) => Auth0Session | Promise + ) {} + + async set( + identifier: string, + data: StateData, + removeIfExists: boolean, + storeOptions?: StoreOptions + ): Promise { + let session: Auth0Session = { + user: data.user as Auth0User, + idToken: data.idToken, + refreshToken: data.refreshToken, + tokenSets: data.tokenSets as TokenSet[], + domain: data.domain ?? '' + }; + + if (this.beforeSessionSaved) { + session = await this.beforeSessionSaved(session); + } + + const finalData: StateData = { + ...data, + user: session.user, + idToken: session.idToken, + refreshToken: session.refreshToken, + tokenSets: session.tokenSets as UpstreamTokenSet[], + domain: session.domain + }; + + if (storeOptions?.response) { + this.captured.set(storeOptions.response, session); + } + + return this.inner.set(identifier, finalData, removeIfExists, storeOptions); + } + + get(identifier: string, storeOptions?: StoreOptions) { + return this.inner.get(identifier, storeOptions); + } + + delete(identifier: string, storeOptions?: StoreOptions) { + return this.inner.delete(identifier, storeOptions); + } + + getCaptured(cookieJar: Response): Auth0Session | null { + return this.captured.get(cookieJar) ?? null; + } +} + // ─── Auth0Server ────────────────────────────────────────────────────────────── /** @@ -114,13 +175,15 @@ const STATE_IDENTIFIER = '__a0_session'; export class Auth0Server { readonly serverClient: ServerClient; - readonly stateStore: StatelessStateStore; + readonly stateStore: HookedStateStore; readonly stateIdentifier = STATE_IDENTIFIER; readonly config: ResolvedAuth0ServerConfig; + readonly onCallback?: (session: Auth0Session) => void | Promise; constructor(options: Auth0ServerConfig = {}) { // Resolve and validate config — throws ConfigurationError if anything is missing this.config = resolveConfig(options); + this.onCallback = options.onCallback; // One shared cookie handler — both stores use the same instance const cookieHandler = new ReactRouterCookieHandler(); @@ -131,11 +194,12 @@ export class Auth0Server { cookieHandler ); - // Holds the encrypted session (user + tokens) for the duration of the session - this.stateStore = new StatelessStateStore( + // Wraps the stateless store to apply beforeSessionSaved and capture session data for onCallback + const innerStore = new StatelessStateStore( { secret: this.config.secret }, cookieHandler ); + this.stateStore = new HookedStateStore(innerStore, options.beforeSessionSaved); // ServerClient is stateless — no network calls happen here this.serverClient = new ServerClient({ diff --git a/src/server/handlers.ts b/src/server/handlers.ts index af1549d..625c781 100644 --- a/src/server/handlers.ts +++ b/src/server/handlers.ts @@ -154,6 +154,11 @@ export async function handleCallback( returnTo?: string; }>(new URL(request.url), storeOptions); appState = result.appState; + + if (auth0.onCallback) { + const session = auth0.stateStore.getCaptured(cookieJar); + if (session) await auth0.onCallback(session); + } } catch (err) { if (err instanceof MissingTransactionError) { throw new CallbackError( From 26d3e94aff0c9844ddd946f5469eb25cc2c263b4 Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Thu, 20 Aug 2026 10:15:24 +0530 Subject: [PATCH 3/4] fix: add deleteByLogoutToken to HookedStateStore to satisfy StateStore interface --- src/server/auth0-server.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server/auth0-server.ts b/src/server/auth0-server.ts index 0083bef..328efc9 100644 --- a/src/server/auth0-server.ts +++ b/src/server/auth0-server.ts @@ -3,7 +3,7 @@ import { CookieTransactionStore, StatelessStateStore } from '@auth0/auth0-server-js'; -import type { StateData, TokenSet as UpstreamTokenSet } from '@auth0/auth0-server-js'; +import type { StateData, TokenSet as UpstreamTokenSet, LogoutTokenClaims } from '@auth0/auth0-server-js'; import { ConfigurationError } from '../errors/index.js'; import { ReactRouterCookieHandler } from './cookie-handler.js'; import type { StoreOptions } from './cookie-handler.js'; @@ -151,6 +151,10 @@ export class HookedStateStore { return this.inner.delete(identifier, storeOptions); } + deleteByLogoutToken(_claims: LogoutTokenClaims, _storeOptions?: StoreOptions) { + return this.inner.deleteByLogoutToken(); + } + getCaptured(cookieJar: Response): Auth0Session | null { return this.captured.get(cookieJar) ?? null; } From 51859926fa678c58ede78062f9dbd5ad3fea8c19 Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Thu, 20 Aug 2026 17:44:27 +0530 Subject: [PATCH 4/4] fix: address review comments on hooks and session domain type --- src/server/auth0-server.ts | 14 +++++++++++++- src/server/handlers.ts | 10 +++++----- src/types/index.ts | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/server/auth0-server.ts b/src/server/auth0-server.ts index 328efc9..5c3b58c 100644 --- a/src/server/auth0-server.ts +++ b/src/server/auth0-server.ts @@ -23,7 +23,19 @@ export interface Auth0ServerConfig { appBaseUrl?: string; // AUTH0_APP_BASE_URL audience?: string; // AUTH0_AUDIENCE (optional) scope?: string; // AUTH0_SCOPE (optional, default: openid profile email) + /** + * Called every time the session is written — at login, on token refresh, and + * on updateSession. Use it to modify or trim the session before it is + * encrypted into the cookie. Avoid expensive work here (DB calls, HTTP + * requests) as it runs on every session write, not just at login. + */ beforeSessionSaved?: (session: Auth0Session) => Auth0Session | Promise; + /** + * Called once after the user successfully completes the login callback. + * Use it to provision the user in your own database or trigger side effects. + * If this hook throws, the error propagates and the session cookie is not + * sent to the browser. + */ onCallback?: (session: Auth0Session) => void | Promise; } @@ -120,7 +132,7 @@ export class HookedStateStore { idToken: data.idToken, refreshToken: data.refreshToken, tokenSets: data.tokenSets as TokenSet[], - domain: data.domain ?? '' + domain: data.domain }; if (this.beforeSessionSaved) { diff --git a/src/server/handlers.ts b/src/server/handlers.ts index 625c781..06ad099 100644 --- a/src/server/handlers.ts +++ b/src/server/handlers.ts @@ -154,11 +154,6 @@ export async function handleCallback( returnTo?: string; }>(new URL(request.url), storeOptions); appState = result.appState; - - if (auth0.onCallback) { - const session = auth0.stateStore.getCaptured(cookieJar); - if (session) await auth0.onCallback(session); - } } catch (err) { if (err instanceof MissingTransactionError) { throw new CallbackError( @@ -171,6 +166,11 @@ export async function handleCallback( ); } + if (auth0.onCallback) { + const session = auth0.stateStore.getCaptured(cookieJar); + if (session) await auth0.onCallback(session); + } + const returnTo = appState?.returnTo ?? options.returnTo ?? '/'; const headers = new Headers({ Location: returnTo }); diff --git a/src/types/index.ts b/src/types/index.ts index 0b64486..693e6e4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -37,7 +37,7 @@ export interface Auth0Session { idToken?: string; refreshToken?: string; tokenSets: TokenSet[]; - domain: string; + domain?: string; } // ─── Context ──────────────────────────────────────────────────────────────────