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
62 changes: 59 additions & 3 deletions apps/api/src/middleware/cfAccessLogin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,30 @@ vi.mock('../db', () => {

const tokenState = vi.hoisted(() => ({
lastPayload: null as Record<string, unknown> | null,
lastOptions: null as Record<string, unknown> | null,
mintCalls: [] as string[],
bindCalls: [] as Array<{ jti: string; familyId: string }>,
}));

vi.mock('../services', () => ({
createTokenPair: vi.fn(async (payload: Record<string, unknown>) => {
tokenState.lastPayload = payload;
return { accessToken: 'access-tok', refreshToken: 'refresh-tok', expiresInSeconds: 900 };
createTokenPair: vi.fn(
async (payload: Record<string, unknown>, options?: Record<string, unknown>) => {
tokenState.lastPayload = payload;
tokenState.lastOptions = options ?? null;
return {
accessToken: 'access-tok',
refreshToken: 'refresh-tok',
refreshJti: 'jti-new',
expiresInSeconds: 900,
};
}
),
mintRefreshTokenFamily: vi.fn(async (userId: string) => {
tokenState.mintCalls.push(userId);
return 'fam-1';
}),
bindRefreshJtiToFamily: vi.fn(async (jti: string, familyId: string) => {
tokenState.bindCalls.push({ jti, familyId });
}),
getRedis: vi.fn(() => ({
setex: vi.fn(async () => 'OK'),
Expand Down Expand Up @@ -212,6 +230,9 @@ describe('cfAccessLoginMiddleware', () => {
dbState.userRow = null;
dbState.lastUpdateId = null;
tokenState.lastPayload = null;
tokenState.lastOptions = null;
tokenState.mintCalls = [];
tokenState.bindCalls = [];
auditState.audits = [];
auditState.loginFailures = [];
contextState.value = {
Expand Down Expand Up @@ -347,6 +368,41 @@ describe('cfAccessLoginMiddleware', () => {
});
});

it('binds the minted refresh token to a fresh family (reuse-detection invariant)', async () => {
envState.enabled = true;
verifyState.next = {
kind: 'claims',
claims: { email: activeUser.email, sub: 'cf-1', aud: envState.audience, iss: `https://${envState.teamDomain}`, exp: 999, iat: 1 },
};
dbState.userRow = { ...activeUser };
const { next } = createNext();
const res = await cfAccessLoginMiddleware(
createContext({ 'Cf-Access-Jwt-Assertion': 'tok' }),
next
);
expect(res).toBeInstanceOf(Response);
// 1. A fresh family was minted for this user.
expect(tokenState.mintCalls).toEqual([activeUser.id]);
// 2. createTokenPair received the family id via refreshFam.
expect(tokenState.lastOptions).toMatchObject({ refreshFam: 'fam-1' });
// 3. The minted refresh jti was bound to the family in Redis.
expect(tokenState.bindCalls).toEqual([{ jti: 'jti-new', familyId: 'fam-1' }]);
});

it('does not mint a family when the MFA temp-token path short-circuits', async () => {
envState.enabled = true;
envState.trustsMfa = false;
verifyState.next = {
kind: 'claims',
claims: { email: activeUser.email, sub: 'cf-1', aud: envState.audience, iss: `https://${envState.teamDomain}`, exp: 999, iat: 1 },
};
dbState.userRow = { ...activeUser, mfaEnabled: true, mfaSecret: 'encrypted', mfaMethod: 'totp' };
const { next } = createNext();
await cfAccessLoginMiddleware(createContext({ 'Cf-Access-Jwt-Assertion': 'tok' }), next);
expect(tokenState.mintCalls).toEqual([]);
expect(tokenState.bindCalls).toEqual([]);
});

it('issues an MFA temp token when user has MFA and TRUSTS_MFA is false', async () => {
envState.enabled = true;
envState.trustsMfa = false;
Expand Down
37 changes: 26 additions & 11 deletions apps/api/src/middleware/cfAccessLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import {
CfAccessJwksUnavailableError,
verifyCfAccessJwt,
} from '../services/cfAccessJwt';
import { createTokenPair } from '../services';
import {
bindRefreshJtiToFamily,
createTokenPair,
mintRefreshTokenFamily,
} from '../services';
import { getRedis } from '../services';
import { createAuditLogAsync } from '../services/auditService';
import { TenantInactiveError } from '../services/tenantStatus';
Expand Down Expand Up @@ -161,16 +165,27 @@ export async function cfAccessLoginMiddleware(c: Context, next: Next): Promise<R

const mfaSatisfied = trustsMfa || !(ENABLE_2FA && user.mfaEnabled);

const tokens = await createTokenPair({
sub: user.id,
email: user.email,
roleId: context.roleId,
orgId: context.orgId,
partnerId: context.partnerId,
scope: context.scope,
mfa: mfaSatisfied,
mdid: readMobileDeviceId(c) ?? undefined,
});
// Mint a fresh refresh-token family for this login so the rotation chain
// participates in OAuth 2.1 reuse-detection — same invariant as every
// other authenticated mint path (see services/refreshTokenFamily.ts and
// the /login handler this middleware short-circuits).
const familyId = await mintRefreshTokenFamily(user.id);

const tokens = await createTokenPair(
{
sub: user.id,
email: user.email,
roleId: context.roleId,
orgId: context.orgId,
partnerId: context.partnerId,
scope: context.scope,
mfa: mfaSatisfied,
mdid: readMobileDeviceId(c) ?? undefined,
},
{ refreshFam: familyId }
);

await bindRefreshJtiToFamily(tokens.refreshJti, familyId);

await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));

Expand Down
177 changes: 172 additions & 5 deletions apps/api/src/routes/auth/cfAccessRedirectLogin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,44 @@ vi.mock('../../db', () => {
};
});

const servicesState = vi.hoisted(() => ({
lastTokenPayload: null as Record<string, unknown> | null,
lastTokenOptions: null as Record<string, unknown> | null,
verifyResult: null as Record<string, unknown> | null,
mintCalls: [] as string[],
bindCalls: [] as Array<{ jti: string; familyId: string }>,
revokeAllCalls: [] as string[],
revokeJtiCalls: [] as string[],
}));

vi.mock('../../services', () => ({
createTokenPair: vi.fn(async () => ({
accessToken: 'access-tok',
refreshToken: 'refresh-tok',
expiresInSeconds: 900,
})),
createTokenPair: vi.fn(
async (payload: Record<string, unknown>, options?: Record<string, unknown>) => {
servicesState.lastTokenPayload = payload;
servicesState.lastTokenOptions = options ?? null;
return {
accessToken: 'access-tok',
refreshToken: 'refresh-tok',
refreshJti: 'jti-new',
expiresInSeconds: 900,
};
}
),
mintRefreshTokenFamily: vi.fn(async (userId: string) => {
servicesState.mintCalls.push(userId);
return 'fam-1';
}),
bindRefreshJtiToFamily: vi.fn(async (jti: string, familyId: string) => {
servicesState.bindCalls.push({ jti, familyId });
}),
revokeAllUserTokens: vi.fn(async (userId: string) => {
servicesState.revokeAllCalls.push(userId);
}),
revokeRefreshTokenJti: vi.fn(async (jti: string) => {
servicesState.revokeJtiCalls.push(jti);
return true;
}),
verifyToken: vi.fn(async () => servicesState.verifyResult),
}));

const auditState = vi.hoisted(() => ({
Expand Down Expand Up @@ -157,6 +189,15 @@ describe('GET /cf-access-login', () => {
auditState.loginFailures = [];
cookieState.set = null;
cookieState.cleared = false;
servicesState.lastTokenPayload = null;
servicesState.lastTokenOptions = null;
servicesState.verifyResult = null;
servicesState.mintCalls = [];
servicesState.bindCalls = [];
servicesState.revokeAllCalls = [];
servicesState.revokeJtiCalls = [];
delete process.env.DASHBOARD_URL;
delete process.env.PUBLIC_APP_URL;
});

it('redirects to /login with error=disabled when trust is off', async () => {
Expand Down Expand Up @@ -263,6 +304,30 @@ describe('GET /cf-access-login', () => {
});
});

it('binds the minted refresh token to a fresh family (reuse-detection invariant)', async () => {
envState.enabled = true;
verifyState.next = {
kind: 'claims',
claims: {
email: activeUser.email,
sub: 'cf-1',
aud: envState.audience,
iss: `https://${envState.teamDomain}`,
exp: 999,
iat: 1,
},
};
dbState.userRow = { ...activeUser };
const res = await callGet('/cf-access-login', { 'Cf-Access-Jwt-Assertion': 'tok' });
expect(res.status).toBe(302);
// 1. A fresh family was minted for this user.
expect(servicesState.mintCalls).toEqual([activeUser.id]);
// 2. createTokenPair received the family id via refreshFam.
expect(servicesState.lastTokenOptions).toMatchObject({ refreshFam: 'fam-1' });
// 3. The minted refresh jti was bound to the family in Redis.
expect(servicesState.bindCalls).toEqual([{ jti: 'jti-new', familyId: 'fam-1' }]);
});

it('preserves a safe next param and appends cf-access-login=success', async () => {
envState.enabled = true;
verifyState.next = {
Expand Down Expand Up @@ -310,6 +375,108 @@ describe('GET /cf-access-login', () => {
expect(cookieState.cleared).toBe(true);
});

it('logout revokes all user tokens + the refresh jti when a valid refresh cookie is present', async () => {
envState.enabled = true;
servicesState.verifyResult = { type: 'refresh', sub: 'user-1', jti: 'jti-current' };
const res = await cfAccessRedirectLoginRoutes.request('http://api.example/cf-access-logout', {
method: 'GET',
headers: {
host: 'breeze.example.com',
cookie: 'breeze_refresh_token=refresh-cookie-tok',
},
});
expect(res.status).toBe(302);
expect(servicesState.revokeAllCalls).toEqual(['user-1']);
expect(servicesState.revokeJtiCalls).toEqual(['jti-current']);
expect(cookieState.cleared).toBe(true);
});

it('logout with no refresh cookie still clears + 302s without calling revocation', async () => {
envState.enabled = true;
const res = await cfAccessRedirectLoginRoutes.request('http://api.example/cf-access-logout', {
method: 'GET',
headers: { host: 'breeze.example.com' },
});
expect(res.status).toBe(302);
expect(servicesState.revokeAllCalls).toEqual([]);
expect(servicesState.revokeJtiCalls).toEqual([]);
expect(cookieState.cleared).toBe(true);
});

it('logout with an invalid refresh cookie still clears + 302s (no 500)', async () => {
envState.enabled = true;
servicesState.verifyResult = null; // verifyToken rejects the cookie
const res = await cfAccessRedirectLoginRoutes.request('http://api.example/cf-access-logout', {
method: 'GET',
headers: {
host: 'breeze.example.com',
cookie: 'breeze_refresh_token=garbage',
},
});
expect(res.status).toBe(302);
expect(servicesState.revokeAllCalls).toEqual([]);
expect(servicesState.revokeJtiCalls).toEqual([]);
expect(cookieState.cleared).toBe(true);
});

it('logout still clears + 302s when revocation throws (e.g. Redis down)', async () => {
envState.enabled = true;
servicesState.verifyResult = { type: 'refresh', sub: 'user-1', jti: 'jti-current' };
const services = await import('../../services');
vi.mocked(services.revokeAllUserTokens).mockRejectedValueOnce(new Error('redis down'));
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const res = await cfAccessRedirectLoginRoutes.request('http://api.example/cf-access-logout', {
method: 'GET',
headers: {
host: 'breeze.example.com',
cookie: 'breeze_refresh_token=refresh-cookie-tok',
},
});
expect(res.status).toBe(302);
expect(cookieState.cleared).toBe(true);
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});

it('logout builds the redirect origin from DASHBOARD_URL, ignoring a spoofed Host header', async () => {
envState.enabled = true;
process.env.DASHBOARD_URL = 'https://breeze.example.com';
const res = await cfAccessRedirectLoginRoutes.request('http://api.example/cf-access-logout', {
method: 'GET',
headers: { host: 'evil.attacker.example' },
});
expect(res.status).toBe(302);
const loc = res.headers.get('Location') ?? '';
expect(loc.startsWith('https://breeze.example.com/cdn-cgi/access/logout?returnTo=')).toBe(true);
expect(loc).not.toContain('evil.attacker.example');
const inner = decodeURIComponent(loc.split('returnTo=')[1] ?? '');
const finalReturn = decodeURIComponent(inner.split('returnTo=')[1] ?? '');
expect(finalReturn).toBe('https://breeze.example.com/login?signedOut=1');
});

it('logout falls back to PUBLIC_APP_URL when DASHBOARD_URL is unset', async () => {
envState.enabled = true;
process.env.PUBLIC_APP_URL = 'https://app.example.net/';
const res = await cfAccessRedirectLoginRoutes.request('http://api.example/cf-access-logout', {
method: 'GET',
headers: { host: 'evil.attacker.example' },
});
const loc = res.headers.get('Location') ?? '';
expect(loc.startsWith('https://app.example.net/cdn-cgi/access/logout?returnTo=')).toBe(true);
expect(loc).not.toContain('evil.attacker.example');
});

it('logout falls back to https + Host only when neither env is set', async () => {
envState.enabled = true;
const res = await cfAccessRedirectLoginRoutes.request('http://api.example/cf-access-logout', {
method: 'GET',
headers: { host: 'breeze.example.com', 'x-forwarded-proto': 'http' },
});
const loc = res.headers.get('Location') ?? '';
// Scheme is pinned to https even when the request claims otherwise.
expect(loc.startsWith('https://breeze.example.com/cdn-cgi/access/logout?returnTo=')).toBe(true);
});

it('rejects an unsafe next param and falls back to /', async () => {
envState.enabled = true;
verifyState.next = {
Expand Down
Loading
Loading