mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-28 08:01:25 +00:00
fix: send id_token_hint and client_id on the OIDC end-session redirect
Keycloak rejects an end-session request that carries post_logout_redirect_uri without id_token_hint or client_id. Store the id_token from the token exchange in an httpOnly cookie, pass it as id_token_hint together with client_id on logout, and clear it with the refresh token. Fixes #102
This commit is contained in:
@@ -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<string, string>(),
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user