diff --git a/app/api/auth/token/__tests__/route.test.ts b/app/api/auth/token/__tests__/route.test.ts new file mode 100644 index 0000000..fc3c3aa --- /dev/null +++ b/app/api/auth/token/__tests__/route.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('@/lib/logger', () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})); + +const discoverOAuth = vi.fn(); +vi.mock('@/lib/oauth/discovery', () => ({ + discoverOAuth: (...args: unknown[]) => discoverOAuth(...args), +})); + +const cookieStore = { + jar: new Map(), + get(name: string) { + const value = this.jar.get(name); + return value === undefined ? undefined : { name, value }; + }, + set: vi.fn(function (this: typeof cookieStore, name: string, value: string) { + this.jar.set(name, value); + }), + delete: vi.fn(function (this: typeof cookieStore, name: string) { + this.jar.delete(name); + }), +}; + +vi.mock('next/headers', () => ({ + cookies: async () => cookieStore, +})); + +const METADATA = { + token_endpoint: 'https://idp.example/token', + revocation_endpoint: 'https://idp.example/revoke', + end_session_endpoint: 'https://idp.example/logout', +}; + +async function loadRoute() { + vi.resetModules(); + return import('../route'); +} + +describe('OIDC token route', () => { + beforeEach(() => { + process.env.OAUTH_CLIENT_ID = 'webmail-client'; + process.env.JMAP_SERVER_URL = 'https://mail.example'; + cookieStore.jar.clear(); + cookieStore.set.mockClear(); + cookieStore.delete.mockClear(); + discoverOAuth.mockReset(); + discoverOAuth.mockResolvedValue(METADATA); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ + access_token: 'at-1', + refresh_token: 'rt-1', + id_token: 'idt-1', + expires_in: 3600, + }), + text: async () => '', + })), + ); + }); + + it('stores the id_token in a cookie at code exchange', async () => { + const { POST } = await loadRoute(); + const request = { + json: async () => ({ code: 'c', code_verifier: 'v', redirect_uri: 'https://app/cb' }), + }; + + const response = await POST(request as never); + + expect(response.status).toBe(200); + expect(cookieStore.jar.get('jmap_idt')).toBe('idt-1'); + }); + + it('sends id_token_hint and client_id on the end-session URL at logout', async () => { + cookieStore.jar.set('jmap_rt', 'rt-1'); + cookieStore.jar.set('jmap_idt', 'idt-1'); + const { DELETE } = await loadRoute(); + + const response = await DELETE(); + const body = await response.json(); + + const url = new URL(body.end_session_url); + expect(url.origin + url.pathname).toBe('https://idp.example/logout'); + expect(url.searchParams.get('id_token_hint')).toBe('idt-1'); + expect(url.searchParams.get('client_id')).toBe('webmail-client'); + expect(cookieStore.jar.has('jmap_idt')).toBe(false); + }); + + it('falls back to client_id alone when no id_token was stored', async () => { + cookieStore.jar.set('jmap_rt', 'rt-1'); + const { DELETE } = await loadRoute(); + + const response = await DELETE(); + const body = await response.json(); + + const url = new URL(body.end_session_url); + expect(url.searchParams.get('client_id')).toBe('webmail-client'); + expect(url.searchParams.get('id_token_hint')).toBeNull(); + }); + + it('rotates the stored id_token on refresh', async () => { + cookieStore.jar.set('jmap_rt', 'rt-1'); + cookieStore.jar.set('jmap_idt', 'idt-old'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ access_token: 'at-2', id_token: 'idt-2', expires_in: 3600 }), + text: async () => '', + })), + ); + const { PUT } = await loadRoute(); + + const response = await PUT(); + + expect(response.status).toBe(200); + expect(cookieStore.jar.get('jmap_idt')).toBe('idt-2'); + }); +}); diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index f67a536..75c3f5a 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { discoverOAuth } from '@/lib/oauth/discovery'; -import { REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens'; +import { ID_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens'; const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || ''; @@ -92,9 +92,14 @@ export async function POST(request: NextRequest) { expires_in: tokens.expires_in || 3600, }); - if (tokens.refresh_token) { + if (tokens.refresh_token || tokens.id_token) { const cookieStore = await cookies(); - cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS); + if (tokens.refresh_token) { + cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS); + } + if (tokens.id_token) { + cookieStore.set(ID_TOKEN_COOKIE, tokens.id_token, COOKIE_OPTIONS); + } } return response; @@ -144,6 +149,10 @@ export async function PUT() { cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS); } + if (tokens.id_token) { + cookieStore.set(ID_TOKEN_COOKIE, tokens.id_token, COOKIE_OPTIONS); + } + return NextResponse.json({ access_token: tokens.access_token, expires_in: tokens.expires_in || 3600, @@ -189,12 +198,23 @@ export async function DELETE() { cookieStore.delete(REFRESH_TOKEN_COOKIE); } + const idToken = cookieStore.get(ID_TOKEN_COOKIE)?.value; + if (idToken) { + cookieStore.delete(ID_TOKEN_COOKIE); + } + let end_session_url: string | undefined; if (metadata?.end_session_endpoint) { try { const parsed = new URL(metadata.end_session_endpoint); if (parsed.protocol === 'https:') { - end_session_url = metadata.end_session_endpoint; + // RP-initiated logout: the OP requires id_token_hint or client_id + // whenever post_logout_redirect_uri is sent (Keycloak enforces this). + parsed.searchParams.set('client_id', getRequiredConfig().clientId); + if (idToken) { + parsed.searchParams.set('id_token_hint', idToken); + } + end_session_url = parsed.toString(); } else { logger.warn('Ignoring non-HTTPS end_session_endpoint', { url: metadata.end_session_endpoint }); } diff --git a/lib/oauth/tokens.ts b/lib/oauth/tokens.ts index f91df7c..fbf98d3 100644 --- a/lib/oauth/tokens.ts +++ b/lib/oauth/tokens.ts @@ -1,2 +1,3 @@ export const OAUTH_SCOPES = 'openid email profile'; export const REFRESH_TOKEN_COOKIE = 'jmap_rt'; +export const ID_TOKEN_COOKIE = 'jmap_idt';