feat(auth): add RP-initiated logout and OAuth unit tests

OAuth logout now terminates the IdP session via end_session_endpoint
with HTTPS-only URL validation. Adds 14 unit tests for PKCE and
OAuth discovery.
This commit is contained in:
Matthieu MALVACHE
2026-02-26 00:08:57 +01:00
committed by Matthieu MALVACHE
parent ec06b0c494
commit 7b6b8fc132
6 changed files with 218 additions and 16 deletions
+25 -7
View File
@@ -37,10 +37,9 @@ async function getTokenEndpoint(): Promise<string> {
return metadata.token_endpoint;
}
async function getRevocationEndpoint(): Promise<string | null> {
async function getMetadata(): Promise<import('@/lib/oauth/discovery').OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig();
const metadata = await discoverOAuth(discoveryUrl);
return metadata?.revocation_endpoint || null;
return discoverOAuth(discoveryUrl);
}
function buildOAuthParams(base: Record<string, string>): URLSearchParams {
@@ -159,17 +158,22 @@ export async function DELETE() {
try {
const cookieStore = await cookies();
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
const metadata = await getMetadata().catch((err) => {
logger.warn('Failed to discover OAuth metadata during logout', {
error: err instanceof Error ? err.message : 'Unknown error',
});
return null;
});
if (refreshToken) {
const revocationEndpoint = await getRevocationEndpoint();
if (revocationEndpoint) {
if (metadata?.revocation_endpoint) {
const params = buildOAuthParams({
token: refreshToken,
token_type_hint: 'refresh_token',
});
try {
const revocationResponse = await fetch(revocationEndpoint, {
const revocationResponse = await fetch(metadata.revocation_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
@@ -185,7 +189,21 @@ export async function DELETE() {
cookieStore.delete(REFRESH_TOKEN_COOKIE);
}
return NextResponse.json({ ok: true });
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;
} else {
logger.warn('Ignoring non-HTTPS end_session_endpoint', { url: metadata.end_session_endpoint });
}
} catch {
logger.warn('Invalid end_session_endpoint URL', { url: metadata.end_session_endpoint });
}
}
return NextResponse.json({ ok: true, ...(end_session_url && { end_session_url }) });
} catch (error) {
logger.error('Token revocation error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });