From b8a6732d769747977577f0e8bd65ec692432bd30 Mon Sep 17 00:00:00 2001 From: Marcus Olsson <8396880+marcusolsson@users.noreply.github.com> Date: Thu, 21 May 2026 11:41:43 +0200 Subject: [PATCH] fix: include error_description in PKCE callback error page --- src/plugin/pkce-flow.test.ts | 90 ++++++++++++++++++++++++++++++++++++ src/plugin/pkce-flow.ts | 9 ++-- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/plugin/pkce-flow.test.ts b/src/plugin/pkce-flow.test.ts index cf2443d..7fbb281 100644 --- a/src/plugin/pkce-flow.test.ts +++ b/src/plugin/pkce-flow.test.ts @@ -1,3 +1,6 @@ +import type { AuthOAuthResult } from './types'; +import * as http from 'node:http'; +import * as url from 'node:url'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../constants', () => ({ @@ -40,11 +43,98 @@ async function loadSubject() { return mod.createPkceAuthorizeMethod; } +async function loadHandleCallbackRequest() { + const mod = await import('./pkce-flow'); + return mod.handleCallbackRequest; +} + async function loadExchangeCodeForTokens() { const mod = await import('./pkce-flow'); return mod.exchangeCodeForTokens; } +describe('handleCallbackRequest - Issue #4', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('includes error_description in the error message when present', async () => { + const handleCallbackRequest = await loadHandleCallbackRequest(); + + const mockResponse = { + end: vi.fn(), + writeHead: vi.fn(), + } as unknown as http.ServerResponse; + + const mockServer = { + close: vi.fn(), + } as unknown as http.Server; + + const parsedUrl = url.parse( + '/callback?error=access_denied&error_description=User+denied+consent', + true, + ); + + let resolvedResult: AuthOAuthResult | undefined; + + await handleCallbackRequest( + mockResponse, + mockServer, + parsedUrl, + 'valid-state', + 'verifier', + 'http://localhost:8787/callback', + (value) => { + resolvedResult = value as unknown as AuthOAuthResult; + }, + ); + + expect(resolvedResult).toBeDefined(); + if (typeof resolvedResult !== 'object' || resolvedResult === null) throw new Error('expected object'); + expect((resolvedResult as { type: string }).type).toBe('failed'); + if ((resolvedResult as { type: string }).type !== 'failed') throw new Error('expected failed'); + expect((resolvedResult as { error?: string }).error).toContain('access_denied'); + expect((resolvedResult as { error?: string }).error).toContain('User denied consent'); + }); + + it('falls back to error code alone when error_description is absent', async () => { + const handleCallbackRequest = await loadHandleCallbackRequest(); + + const mockResponse = { + end: vi.fn(), + writeHead: vi.fn(), + } as unknown as http.ServerResponse; + + const mockServer = { + close: vi.fn(), + } as unknown as http.Server; + + const parsedUrl = url.parse('/callback?error=invalid_scope', true); + + let resolvedResult: AuthOAuthResult | undefined; + + await handleCallbackRequest( + mockResponse, + mockServer, + parsedUrl, + 'valid-state', + 'verifier', + 'http://localhost:8787/callback', + (value) => { + resolvedResult = value as unknown as AuthOAuthResult; + }, + ); + + expect(resolvedResult).toBeDefined(); + if (typeof resolvedResult !== 'object' || resolvedResult === null) throw new Error('expected object'); + expect((resolvedResult as { type: string }).type).toBe('failed'); + if ((resolvedResult as { type: string }).type !== 'failed') throw new Error('expected failed'); + expect((resolvedResult as { error?: string }).error).toBe('Authentication failed: invalid_scope'); + }); +}); + + describe('createPkceAuthorizeMethod - Issue #1', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/plugin/pkce-flow.ts b/src/plugin/pkce-flow.ts index 62e24e4..ca50f35 100644 --- a/src/plugin/pkce-flow.ts +++ b/src/plugin/pkce-flow.ts @@ -290,7 +290,7 @@ function generateCodeVerifier(): string { /** * Handles the OAuth callback request */ -async function handleCallbackRequest( +export async function handleCallbackRequest( response: http.ServerResponse, server: http.Server, parsedUrl: url.UrlWithParsedQuery, @@ -302,13 +302,16 @@ async function handleCallbackRequest( const receivedState = parsedUrl.query.state as string; const code = parsedUrl.query.code as string; const error = parsedUrl.query.error as string; + const errorDescription = parsedUrl.query.error_description as string | undefined; if (error) { + const displayMessage = errorDescription ? `${error}: ${errorDescription}` : error; + response.writeHead(200, { 'Content-Type': 'text/html' }); - response.end(buildHtmlResponse(false, error)); + response.end(buildHtmlResponse(false, displayMessage)); server.close(); resolve({ - error: `Authentication failed: ${error}`, + error: `Authentication failed: ${displayMessage}`, type: 'failed', }); return;