/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * Shared OAuth 2.0 Authorization Code flow primitives with PKCE support. * * These utilities are protocol-agnostic and can be used by both MCP OAuth * and A2A OAuth authentication providers. */ import * as http from 'node:http'; import * as crypto from 'node:crypto'; import type * as net from 'node:net'; import { URL } from 'node:url'; import { debugLogger } from './debugLogger.js'; /** * Configuration for an OAuth 2.0 Authorization Code flow. * Contains only the fields needed by the shared flow utilities. */ export interface OAuthFlowConfig { clientId: string; clientSecret?: string; authorizationUrl: string; tokenUrl: string; scopes?: string[]; audiences?: string[]; redirectUri?: string; } /** * Configuration subset needed for token refresh operations. */ export type OAuthRefreshConfig = Pick< OAuthFlowConfig, 'clientId' | 'clientSecret' | 'scopes' | 'audiences' >; /** * PKCE (Proof Key for Code Exchange) parameters. */ export interface PKCEParams { codeVerifier: string; codeChallenge: string; state: string; } /** * OAuth authorization response from the callback server. */ export interface OAuthAuthorizationResponse { code: string; state: string; } /** * OAuth token response from the authorization server. */ export interface OAuthTokenResponse { access_token: string; token_type: string; expires_in?: number; refresh_token?: string; scope?: string; } /** The path the local callback server listens on. */ export const REDIRECT_PATH = '/oauth/callback'; const HTTP_OK = 200; /** * Generate PKCE parameters for OAuth flow. * * @returns PKCE parameters including code verifier, challenge, and state */ export function generatePKCEParams(): PKCEParams { // Generate code verifier (43-128 characters) // using 64 bytes results in ~86 characters, safely above the minimum of 43 const codeVerifier = crypto.randomBytes(64).toString('base64url'); // Generate code challenge using SHA256 const codeChallenge = crypto .createHash('sha256') .update(codeVerifier) .digest('base64url'); // Generate state for CSRF protection const state = crypto.randomBytes(16).toString('base64url'); return { codeVerifier, codeChallenge, state }; } /** * Start a local HTTP server to handle OAuth callback. * The server will listen on the specified port (or port 0 for OS assignment). * * @param expectedState The state parameter to validate * @param port Optional preferred port to listen on * @returns Object containing the port (available immediately) and a promise for the auth response */ export function startCallbackServer( expectedState: string, port?: number, ): { port: Promise; response: Promise; } { let portResolve: (port: number) => void; let portReject: (error: Error) => void; const portPromise = new Promise((resolve, reject) => { portResolve = resolve; portReject = reject; }); const responsePromise = new Promise( (resolve, reject) => { let serverPort: number; const server = http.createServer( async (req: http.IncomingMessage, res: http.ServerResponse) => { try { const url = new URL(req.url!, `http://localhost:${serverPort}`); if (url.pathname !== REDIRECT_PATH) { res.writeHead(404); res.end('Not found'); return; } const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); if (error) { res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); res.end(`

Authentication Failed

Error: ${error.replace(//g, '>')}

${(url.searchParams.get('error_description') || '').replace(//g, '>')}

You can close this window.

`); server.close(); reject(new Error(`OAuth error: ${error}`)); return; } if (!code || !state) { res.writeHead(400); res.end('Missing code or state parameter'); return; } if (state !== expectedState) { res.writeHead(400); res.end('Invalid state parameter'); server.close(); reject(new Error('State mismatch - possible CSRF attack')); return; } // Send success response to browser res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); res.end(`

Authentication Successful!

You can close this window and return to Gemini CLI.

`); server.close(); resolve({ code, state }); } catch (error) { server.close(); reject(error); } }, ); server.on('error', (error) => { portReject(error); reject(error); }); // Determine which port to use (env var, argument, or OS-assigned) let listenPort = 0; // Default to OS-assigned port const portStr = process.env['OAUTH_CALLBACK_PORT']; if (portStr) { const envPort = parseInt(portStr, 10); if (isNaN(envPort) || envPort <= 0 || envPort > 65535) { const error = new Error( `Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`, ); portReject(error); reject(error); return; } listenPort = envPort; } else if (port !== undefined) { listenPort = port; } server.listen(listenPort, () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const address = server.address() as net.AddressInfo; serverPort = address.port; debugLogger.log( `OAuth callback server listening on port ${serverPort}`, ); portResolve(serverPort); // Resolve port promise immediately }); // Timeout after 5 minutes setTimeout( () => { server.close(); reject(new Error('OAuth callback timeout')); }, 5 * 60 * 1000, ); }, ); return { port: portPromise, response: responsePromise }; } /** * Extract the port number from a URL string if available and valid. * * @param urlString The URL string to parse * @returns The port number or undefined if not found or invalid */ export function getPortFromUrl(urlString?: string): number | undefined { if (!urlString) { return undefined; } try { const url = new URL(urlString); if (url.port) { const parsedPort = parseInt(url.port, 10); if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) { return parsedPort; } } } catch { // Ignore invalid URL } return undefined; } /** * Build the authorization URL for the OAuth flow. * * @param config OAuth flow configuration * @param pkceParams PKCE parameters * @param redirectPort The port to use for the redirect URI * @param resource Optional resource parameter value (RFC 8707) * @returns The authorization URL */ export function buildAuthorizationUrl( config: OAuthFlowConfig, pkceParams: PKCEParams, redirectPort: number, resource?: string, ): string { const redirectUri = config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`; const params = new URLSearchParams({ client_id: config.clientId, response_type: 'code', redirect_uri: redirectUri, state: pkceParams.state, code_challenge: pkceParams.codeChallenge, code_challenge_method: 'S256', }); if (config.scopes && config.scopes.length > 0) { params.append('scope', config.scopes.join(' ')); } if (config.audiences && config.audiences.length > 0) { params.append('audience', config.audiences.join(' ')); } if (resource) { params.append('resource', resource); } const url = new URL(config.authorizationUrl); params.forEach((value, key) => { url.searchParams.append(key, value); }); return url.toString(); } /** * Parse a token endpoint response, handling both JSON and form-urlencoded formats. * * @param response The HTTP response from the token endpoint * @param operationName Human-readable operation name for error messages (e.g., "Token exchange", "Token refresh") * @param defaultErrorCode Default error code when access_token is missing (e.g., "no_access_token", "unknown_error") * @returns The parsed token response */ async function parseTokenEndpointResponse( response: Response, operationName: string, defaultErrorCode: string, ): Promise { const responseText = await response.text(); const contentType = response.headers.get('content-type') || ''; if (!response.ok) { // Try to parse error from form-urlencoded response let errorMessage: string | null = null; try { const errorParams = new URLSearchParams(responseText); const error = errorParams.get('error'); const errorDescription = errorParams.get('error_description'); if (error) { errorMessage = `${operationName} failed: ${error} - ${errorDescription || 'No description'}`; } } catch { // Fall back to raw error } throw new Error( errorMessage || `${operationName} failed: ${response.status} - ${responseText}`, ); } // Log unexpected content types for debugging if ( !contentType.includes('application/json') && !contentType.includes('application/x-www-form-urlencoded') ) { debugLogger.warn( `${operationName} endpoint returned unexpected content-type: ${contentType}. ` + `Expected application/json or application/x-www-form-urlencoded. ` + `Will attempt to parse response.`, ); } // Try to parse as JSON first, fall back to form-urlencoded try { const data: unknown = JSON.parse(responseText); if ( data && typeof data === 'object' && 'access_token' in data && typeof (data as Record)['access_token'] === 'string' ) { const obj = data as Record; const result: OAuthTokenResponse = { access_token: String(obj['access_token']), token_type: typeof obj['token_type'] === 'string' ? obj['token_type'] : 'Bearer', expires_in: typeof obj['expires_in'] === 'number' ? obj['expires_in'] : undefined, refresh_token: typeof obj['refresh_token'] === 'string' ? obj['refresh_token'] : undefined, scope: typeof obj['scope'] === 'string' ? obj['scope'] : undefined, }; return result; } // JSON parsed but doesn't look like a token response — fall through } catch { // Not JSON — fall through to form-urlencoded parsing } // Parse form-urlencoded response const tokenParams = new URLSearchParams(responseText); const accessToken = tokenParams.get('access_token'); const tokenType = tokenParams.get('token_type') || 'Bearer'; const expiresIn = tokenParams.get('expires_in'); const refreshToken = tokenParams.get('refresh_token'); const scope = tokenParams.get('scope'); if (!accessToken) { // Check for error in response const error = tokenParams.get('error'); const errorDescription = tokenParams.get('error_description'); throw new Error( `${operationName} failed: ${error || defaultErrorCode} - ${errorDescription || responseText}`, ); } return { access_token: accessToken, token_type: tokenType, expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined, refresh_token: refreshToken || undefined, scope: scope || undefined, } as OAuthTokenResponse; } /** * Exchange an authorization code for tokens. * * @param config OAuth flow configuration * @param code Authorization code * @param codeVerifier PKCE code verifier * @param redirectPort The port to use for the redirect URI * @param resource Optional resource parameter value (RFC 8707) * @returns The token response */ export async function exchangeCodeForToken( config: OAuthFlowConfig, code: string, codeVerifier: string, redirectPort: number, resource?: string, ): Promise { const redirectUri = config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`; const params = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: codeVerifier, client_id: config.clientId, }); if (config.clientSecret) { params.append('client_secret', config.clientSecret); } if (config.audiences && config.audiences.length > 0) { params.append('audience', config.audiences.join(' ')); } if (resource) { params.append('resource', resource); } const response = await fetch(config.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json, application/x-www-form-urlencoded', }, body: params.toString(), }); return parseTokenEndpointResponse( response, 'Token exchange', 'no_access_token', ); } /** * Refresh an access token using a refresh token. * * @param config OAuth configuration subset needed for refresh * @param refreshToken The refresh token * @param tokenUrl The token endpoint URL * @param resource Optional resource parameter value (RFC 8707) * @returns The new token response */ export async function refreshAccessToken( config: OAuthRefreshConfig, refreshToken: string, tokenUrl: string, resource?: string, ): Promise { const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: config.clientId, }); if (config.clientSecret) { params.append('client_secret', config.clientSecret); } if (config.scopes && config.scopes.length > 0) { params.append('scope', config.scopes.join(' ')); } if (config.audiences && config.audiences.length > 0) { params.append('audience', config.audiences.join(' ')); } if (resource) { params.append('resource', resource); } const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json, application/x-www-form-urlencoded', }, body: params.toString(), }); return parseTokenEndpointResponse(response, 'Token refresh', 'unknown_error'); }