Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions src/plugin/pkce-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('handleCallbackRequest - Issue #4', () => {

const mockResponse = {
end: vi.fn(),
writeHead: vi.fn(),
writeHead: vi.fn() as unknown as http.ServerResponse['writeHead'],
} as unknown as http.ServerResponse;

const mockServer = {
Expand Down Expand Up @@ -103,7 +103,7 @@ describe('handleCallbackRequest - Issue #4', () => {

const mockResponse = {
end: vi.fn(),
writeHead: vi.fn(),
writeHead: vi.fn() as unknown as http.ServerResponse['writeHead'],
} as unknown as http.ServerResponse;

const mockServer = {
Expand Down Expand Up @@ -132,6 +132,37 @@ describe('handleCallbackRequest - Issue #4', () => {
if ((resolvedResult as { type: string }).type !== 'failed') throw new Error('expected failed');
expect((resolvedResult as { error?: string }).error).toBe('Authentication failed: invalid_scope');
});

it('sets Cache-Control: no-store on error callback response', async () => {
const handleCallbackRequest = await loadHandleCallbackRequest();

const mockResponse = {
end: vi.fn(),
writeHead: vi.fn() as unknown as http.ServerResponse['writeHead'],
} as unknown as http.ServerResponse;

const mockServer = {
close: vi.fn(),
} as unknown as http.Server;

const parsedUrl = url.parse('/callback?error=access_denied', true);

await handleCallbackRequest(
mockResponse,
mockServer,
parsedUrl,
'valid-state',
'verifier',
'http://localhost:8787/callback',
() => {},
);

expect(mockResponse.writeHead).toHaveBeenCalledTimes(1);
const [, headers] = (mockResponse.writeHead as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(headers['Cache-Control']).toBe('no-store, no-cache, must-revalidate, proxy-revalidate');
expect(headers['Pragma']).toBe('no-cache');
expect(headers['Expires']).toBe('0');
});
});


Expand Down
27 changes: 18 additions & 9 deletions src/plugin/pkce-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,19 @@ export function generateCodeVerifier(): string {
return Buffer.from(bytes).toString('base64url');
}

/**
* Sends an HTML response with cache-control headers appropriate for OAuth callbacks
*/
function writeHtmlResponse(response: http.ServerResponse, html: string): void {
response.writeHead(200, {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Content-Type': 'text/html',
'Expires': '0',
'Pragma': 'no-cache',
});
response.end(html);
}

/**
* Handles the OAuth callback request
*/
Expand All @@ -317,8 +330,7 @@ export async function handleCallbackRequest(
if (error) {
const displayMessage = errorDescription ? `${error}: ${errorDescription}` : error;

response.writeHead(200, { 'Content-Type': 'text/html' });
response.end(buildHtmlResponse(false, displayMessage));
writeHtmlResponse(response, buildHtmlResponse(false, displayMessage));
server.close();
resolve({
error: `Authentication failed: ${displayMessage}`,
Expand All @@ -328,8 +340,7 @@ export async function handleCallbackRequest(
}

if (receivedState !== state) {
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end(buildHtmlResponse(false, 'Invalid state parameter'));
writeHtmlResponse(response, buildHtmlResponse(false, 'Invalid state parameter'));
server.close();
resolve({
error: 'Invalid state parameter. Please try again.',
Expand All @@ -339,8 +350,7 @@ export async function handleCallbackRequest(
}

if (!code) {
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end(buildHtmlResponse(false, 'No authorization code received'));
writeHtmlResponse(response, buildHtmlResponse(false, 'No authorization code received'));
server.close();
resolve({
error: 'No authorization code received.',
Expand All @@ -350,9 +360,8 @@ export async function handleCallbackRequest(
}

// Exchange code for tokens
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end(buildHtmlResponse(true, 'You can close this window and return to OpenCode.'));
server.close();
writeHtmlResponse(response, buildHtmlResponse(true, 'You can close this window and return to OpenCode.'));
server.close();

const result = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
resolve(result);
Expand Down
Loading