diff --git a/apps/api/src/middleware/cfAccessLogin.test.ts b/apps/api/src/middleware/cfAccessLogin.test.ts index 1763cf4fa..24e0c7e69 100644 --- a/apps/api/src/middleware/cfAccessLogin.test.ts +++ b/apps/api/src/middleware/cfAccessLogin.test.ts @@ -78,12 +78,30 @@ vi.mock('../db', () => { const tokenState = vi.hoisted(() => ({ lastPayload: null as Record | null, + lastOptions: null as Record | null, + mintCalls: [] as string[], + bindCalls: [] as Array<{ jti: string; familyId: string }>, })); vi.mock('../services', () => ({ - createTokenPair: vi.fn(async (payload: Record) => { - tokenState.lastPayload = payload; - return { accessToken: 'access-tok', refreshToken: 'refresh-tok', expiresInSeconds: 900 }; + createTokenPair: vi.fn( + async (payload: Record, options?: Record) => { + 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'), @@ -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 = { @@ -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; diff --git a/apps/api/src/middleware/cfAccessLogin.ts b/apps/api/src/middleware/cfAccessLogin.ts index 8505bc097..f9a0645aa 100644 --- a/apps/api/src/middleware/cfAccessLogin.ts +++ b/apps/api/src/middleware/cfAccessLogin.ts @@ -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'; @@ -161,16 +165,27 @@ export async function cfAccessLoginMiddleware(c: Context, next: Next): Promise { }; }); +const servicesState = vi.hoisted(() => ({ + lastTokenPayload: null as Record | null, + lastTokenOptions: null as Record | null, + verifyResult: null as Record | 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, options?: Record) => { + 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(() => ({ @@ -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 () => { @@ -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 = { @@ -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 = { diff --git a/apps/api/src/routes/auth/cfAccessRedirectLogin.ts b/apps/api/src/routes/auth/cfAccessRedirectLogin.ts index 51ffb95b1..012201346 100644 --- a/apps/api/src/routes/auth/cfAccessRedirectLogin.ts +++ b/apps/api/src/routes/auth/cfAccessRedirectLogin.ts @@ -13,7 +13,14 @@ import { CfAccessJwksUnavailableError, verifyCfAccessJwt, } from '../../services/cfAccessJwt'; -import { createTokenPair } from '../../services'; +import { + bindRefreshJtiToFamily, + createTokenPair, + mintRefreshTokenFamily, + revokeAllUserTokens, + revokeRefreshTokenJti, + verifyToken, +} from '../../services'; import { createAuditLogAsync } from '../../services/auditService'; import { TenantInactiveError } from '../../services/tenantStatus'; import { ENABLE_2FA } from './schemas'; @@ -22,6 +29,7 @@ import { clearRefreshTokenCookie, getClientIP, resolveCurrentUserTokenContext, + resolveRefreshToken, setRefreshTokenCookie, } from './helpers'; @@ -157,15 +165,26 @@ cfAccessRedirectLoginRoutes.get('/cf-access-login', async (c) => { 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, - }); + // 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). + 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, + }, + { refreshFam: familyId } + ); + + await bindRefreshJtiToFamily(tokens.refreshJti, familyId); await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id)); @@ -227,7 +246,31 @@ cfAccessRedirectLoginRoutes.get('/cf-access-login', async (c) => { * Bearer token. The refresh cookie is enough to identify the session and * the cookie is cleared regardless. */ -cfAccessRedirectLoginRoutes.get('/cf-access-logout', (c) => { +cfAccessRedirectLoginRoutes.get('/cf-access-logout', async (c) => { + // Server-side revocation, mirroring POST /logout (login.ts): identify the + // session from the refresh cookie (no Bearer token on a top-level GET), + // then revoke ALL of the user's tokens plus the specific refresh jti. + // Without this, "Sign out" via CF Access only cleared the cookie — the + // access + refresh tokens stayed live until natural expiry. Best-effort: + // a missing/invalid cookie or a Redis error still clears + redirects. + try { + const refreshToken = resolveRefreshToken(c); + if (refreshToken) { + const payload = await verifyToken(refreshToken); + if (payload && payload.type === 'refresh' && payload.sub) { + await revokeAllUserTokens(payload.sub); + if (payload.jti) { + await revokeRefreshTokenJti(payload.jti); + } + } + } + } catch (error) { + console.error( + '[cf-access-logout] Failed to revoke tokens during logout — clearing cookie anyway:', + error + ); + } + clearRefreshTokenCookie(c); if (!cfAccessTrustEnabled()) { @@ -239,15 +282,30 @@ cfAccessRedirectLoginRoutes.get('/cf-access-logout', (c) => { return new Response(null, { status: 302, headers: { Location: '/login?signedOut=1' } }); } - // Reconstruct the public origin. The api sits behind a TLS-terminating - // proxy so `c.req.url` shows `http://`. Honour X-Forwarded-Proto when - // the proxy is trusted, otherwise default to `https://`. - const forwardedProto = c.req.header('x-forwarded-proto')?.split(',')[0]?.trim(); - const trustProxy = (process.env.TRUST_PROXY_HEADERS ?? '').trim().toLowerCase(); - const trustProxyOn = ['true', '1', 'yes', 'on'].includes(trustProxy); - const scheme = trustProxyOn && forwardedProto ? forwardedProto : 'https'; - const host = c.req.header('host') ?? ''; - const origin = host ? `${scheme}://${host}` : ''; + // Resolve the public origin from configuration, NOT the request. The Host + // header is attacker-controllable, and the origin ends up in a Location + // header — deriving it from the request is an open redirect (a crafted + // Host would bounce the user's browser to an attacker domain after CF + // logout). DASHBOARD_URL / PUBLIC_APP_URL is the established pattern for + // building user-facing absolute URLs (see login.ts, password.ts). + const configuredBase = (process.env.DASHBOARD_URL || process.env.PUBLIC_APP_URL || '') + .trim() + .replace(/\/$/, ''); + let origin = ''; + if (configuredBase) { + try { + origin = new URL(configuredBase).origin; + } catch { + origin = ''; + } + } + if (!origin) { + // Last-resort fallback for deployments without DASHBOARD_URL / + // PUBLIC_APP_URL. Scheme is pinned to https — never trust the request + // to pick the scheme either. + const host = c.req.header('host') ?? ''; + origin = host ? `https://${host}` : ''; + } // CF Access stores TWO `CF_Authorization` cookies per session: // 1. Per-application cookie at the app domain (app.example.com) diff --git a/apps/docs/src/content/docs/deploy/cloudflare-access-trust.mdx b/apps/docs/src/content/docs/deploy/cloudflare-access-trust.mdx index 2b8c12c26..88592f652 100644 --- a/apps/docs/src/content/docs/deploy/cloudflare-access-trust.mdx +++ b/apps/docs/src/content/docs/deploy/cloudflare-access-trust.mdx @@ -59,7 +59,7 @@ The config validator refuses to boot if `CF_ACCESS_TRUST_ENABLED=true` but `CF_A ## Recommended Cloudflare Access setup -- Application path: cover the SPA root and `/api/v1/auth/login` only. Leave bypass rules on `/api/*` agent paths, `/health`, `/installers/*`, and any installer short-links (the agent fleet does not have a CF Access session). +- Application path: cover the SPA root, `/api/v1/auth/login`, `/api/v1/auth/cf-access-login`, and `/api/v1/auth/cf-access-logout`. Leave bypass rules on `/api/*` agent paths, `/health`, `/installers/*`, and any installer short-links (the agent fleet does not have a CF Access session). If you use a blanket bypass rule on `/api/*`, make sure it does **not** swallow `/api/v1/auth/cf-access-login` or `/api/v1/auth/cf-access-logout` — those two endpoints must be enforced by the Access application (more-specific paths win), otherwise the JWT never reaches the redirect login handler and sign-out cannot clear the CF Access session. - Identity provider: pick the same IdP whose `email` claim is the same one your Breeze users are provisioned with. If a Breeze user's email is `alice@acme.com` and your IdP issues the JWT with `email=alice@acme.com`, you're set. - Session duration: anything you like. Breeze mints its own refresh token independently of the CF Access cookie. - Application AUD: copy from the dashboard once the application is created. This is stable for the life of the application.