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
149 changes: 144 additions & 5 deletions __tests__/server/auth0-server.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -110,9 +111,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', () => {
Expand All @@ -136,7 +137,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');
}
});

Expand All @@ -149,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));
});
});
85 changes: 83 additions & 2 deletions __tests__/server/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ function makeAuth0(
completeInteractiveLogin: ReturnType<typeof vi.fn>;
logout: ReturnType<typeof vi.fn>;
handleBackchannelLogout: ReturnType<typeof vi.fn>;
appBaseUrl: string | undefined;
onCallback: ReturnType<typeof vi.fn>;
capturedSession: object | null;
}> = {}
): Auth0Server {
return {
Expand All @@ -65,8 +68,12 @@ function makeAuth0(
overrides.handleBackchannelLogout ??
vi.fn().mockResolvedValue(undefined)
},
stateStore: {
getCaptured: vi.fn().mockReturnValue(overrides.capturedSession ?? null)
},
onCallback: overrides.onCallback,
config: {
appBaseUrl: 'http://localhost:3000',
appBaseUrl: 'appBaseUrl' in overrides ? overrides.appBaseUrl : 'http://localhost:3000',
domain: 'test.auth0.com',
clientId: 'abc',
clientSecret: 'secret',
Expand Down Expand Up @@ -168,7 +175,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)
);
Expand Down Expand Up @@ -321,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
Expand Down Expand Up @@ -494,6 +558,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(
Expand Down
Loading