diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 95cec40f50..6aaafa6054 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -4,9 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -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 { openBrowserSecurely } from '../utils/secure-browser-launcher.js'; import type { OAuthToken } from './token-storage/types.js'; @@ -16,6 +14,23 @@ import { OAuthUtils, ResourceMismatchError } from './oauth-utils.js'; import { coreEvents } from '../utils/events.js'; import { debugLogger } from '../utils/debugLogger.js'; import { getConsentForOauth } from '../utils/authConsent.js'; +import { + generatePKCEParams, + startCallbackServer, + getPortFromUrl, + buildAuthorizationUrl, + exchangeCodeForToken, + refreshAccessToken as refreshAccessTokenShared, + REDIRECT_PATH, + type OAuthFlowConfig, + type OAuthTokenResponse, +} from '../utils/oauth-flow.js'; + +// Re-export types that were moved to oauth-flow.ts for backward compatibility. +export type { + OAuthAuthorizationResponse, + OAuthTokenResponse, +} from '../utils/oauth-flow.js'; /** * OAuth configuration for an MCP server. @@ -34,25 +49,6 @@ export interface MCPOAuthConfig { registrationUrl?: string; } -/** - * OAuth authorization response. - */ -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; -} - /** * Dynamic client registration request (RFC 7591). */ @@ -80,18 +76,6 @@ export interface OAuthClientRegistrationResponse { scope?: string; } -/** - * PKCE (Proof Key for Code Exchange) parameters. - */ -interface PKCEParams { - codeVerifier: string; - codeChallenge: string; - state: string; -} - -const REDIRECT_PATH = '/oauth/callback'; -const HTTP_OK = 200; - /** * Provider for handling OAuth authentication for MCP servers. */ @@ -239,375 +223,18 @@ export class MCPOAuthProvider { } /** - * Generate PKCE parameters for OAuth flow. - * - * @returns PKCE parameters including code verifier, challenge, and state + * Build the OAuth resource parameter from an MCP server URL, if available. + * Returns undefined if the URL is not provided or cannot be processed. */ - private 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 - * @returns Object containing the port (available immediately) and a promise for the auth response - */ - private 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 - */ - private getPortFromUrl(urlString?: string): number | undefined { - if (!urlString) { - return undefined; - } - + private buildResourceParam(mcpServerUrl?: string): string | undefined { + if (!mcpServerUrl) 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 configuration - * @param pkceParams PKCE parameters - * @param redirectPort The port to use for the redirect URI - * @param mcpServerUrl The MCP server URL to use as the resource parameter - * @returns The authorization URL - */ - private buildAuthorizationUrl( - config: MCPOAuthConfig, - pkceParams: PKCEParams, - redirectPort: number, - mcpServerUrl?: 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(' ')); - } - - // Add resource parameter for MCP OAuth spec compliance - // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth) - if (mcpServerUrl) { - try { - params.append( - 'resource', - OAuthUtils.buildResourceParameter(mcpServerUrl), - ); - } catch (error) { - debugLogger.warn( - `Could not add resource parameter: ${getErrorMessage(error)}`, - ); - } - } - - const url = new URL(config.authorizationUrl!); - params.forEach((value, key) => { - url.searchParams.append(key, value); - }); - return url.toString(); - } - - /** - * Exchange authorization code for tokens. - * - * @param config OAuth configuration - * @param code Authorization code - * @param codeVerifier PKCE code verifier - * @param redirectPort The port to use for the redirect URI - * @param mcpServerUrl The MCP server URL to use as the resource parameter - * @returns The token response - */ - private async exchangeCodeForToken( - config: MCPOAuthConfig, - code: string, - codeVerifier: string, - redirectPort: number, - mcpServerUrl?: 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(' ')); - } - - // Add resource parameter for MCP OAuth spec compliance - // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth) - if (mcpServerUrl) { - const resourceUrl = mcpServerUrl; - try { - params.append( - 'resource', - OAuthUtils.buildResourceParameter(resourceUrl), - ); - } catch (error) { - debugLogger.warn( - `Could not add resource parameter: ${getErrorMessage(error)}`, - ); - } - } - - 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(), - }); - - 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 = `Token exchange failed: ${error} - ${errorDescription || 'No description'}`; - } - } catch { - // Fall back to raw error - } - throw new Error( - errorMessage || - `Token exchange failed: ${response.status} - ${responseText}`, - ); - } - - // Log unexpected content types for debugging - if ( - !contentType.includes('application/json') && - !contentType.includes('application/x-www-form-urlencoded') - ) { + return OAuthUtils.buildResourceParameter(mcpServerUrl); + } catch (error) { debugLogger.warn( - `Token endpoint returned unexpected content-type: ${contentType}. ` + - `Expected application/json or application/x-www-form-urlencoded. ` + - `Will attempt to parse response.`, + `Could not add resource parameter: ${getErrorMessage(error)}`, ); - } - - // Try to parse as JSON first, fall back to form-urlencoded - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return JSON.parse(responseText) as OAuthTokenResponse; - } catch { - // 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( - `Token exchange failed: ${error || 'no_access_token'} - ${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; + return undefined; } } @@ -626,112 +253,21 @@ export class MCPOAuthProvider { tokenUrl: string, mcpServerUrl?: 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.clientId) { + throw new Error('Missing required clientId for token refresh'); } - 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(' ')); - } - - // Add resource parameter for MCP OAuth spec compliance - // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth) - if (mcpServerUrl) { - try { - params.append( - 'resource', - OAuthUtils.buildResourceParameter(mcpServerUrl), - ); - } catch (error) { - debugLogger.warn( - `Could not add resource parameter: ${getErrorMessage(error)}`, - ); - } - } - - const response = await fetch(tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json, application/x-www-form-urlencoded', + return refreshAccessTokenShared( + { + clientId: config.clientId, + clientSecret: config.clientSecret, + scopes: config.scopes, + audiences: config.audiences, }, - body: params.toString(), - }); - - 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 = `Token refresh failed: ${error} - ${errorDescription || 'No description'}`; - } - } catch { - // Fall back to raw error - } - throw new Error( - errorMessage || - `Token refresh failed: ${response.status} - ${responseText}`, - ); - } - - // Log unexpected content types for debugging - if ( - !contentType.includes('application/json') && - !contentType.includes('application/x-www-form-urlencoded') - ) { - debugLogger.warn( - `Token refresh 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 { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return JSON.parse(responseText) as OAuthTokenResponse; - } catch { - // 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( - `Token refresh failed: ${error || 'unknown_error'} - ${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; - } + refreshToken, + tokenUrl, + this.buildResourceParam(mcpServerUrl), + ); } /** @@ -830,17 +366,14 @@ export class MCPOAuthProvider { } // Generate PKCE parameters - const pkceParams = this.generatePKCEParams(); + const pkceParams = generatePKCEParams(); // Determine preferred port from redirectUri if available - const preferredPort = this.getPortFromUrl(config.redirectUri); + const preferredPort = getPortFromUrl(config.redirectUri); // Start callback server first to allocate port // This ensures we only create one server and eliminates race conditions - const callbackServer = this.startCallbackServer( - pkceParams.state, - preferredPort, - ); + const callbackServer = startCallbackServer(pkceParams.state, preferredPort); // Wait for server to start and get the allocated port // We need this port for client registration and auth URL building @@ -892,12 +425,24 @@ export class MCPOAuthProvider { ); } + // Build flow config for shared utilities + const flowConfig: OAuthFlowConfig = { + clientId: config.clientId, + clientSecret: config.clientSecret, + authorizationUrl: config.authorizationUrl, + tokenUrl: config.tokenUrl, + scopes: config.scopes, + audiences: config.audiences, + redirectUri: config.redirectUri, + }; + // Build authorization URL - const authUrl = this.buildAuthorizationUrl( - config, + const resource = this.buildResourceParam(mcpServerUrl); + const authUrl = buildAuthorizationUrl( + flowConfig, pkceParams, redirectPort, - mcpServerUrl, + resource, ); const userConsent = await getConsentForOauth( @@ -933,12 +478,12 @@ ${authUrl} ); // Exchange code for tokens - const tokenResponse = await this.exchangeCodeForToken( - config, + const tokenResponse = await exchangeCodeForToken( + flowConfig, code, pkceParams.codeVerifier, redirectPort, - mcpServerUrl, + resource, ); // Convert to our token format diff --git a/packages/core/src/utils/oauth-flow.test.ts b/packages/core/src/utils/oauth-flow.test.ts new file mode 100644 index 0000000000..dee919c249 --- /dev/null +++ b/packages/core/src/utils/oauth-flow.test.ts @@ -0,0 +1,635 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { + OAuthFlowConfig, + OAuthRefreshConfig, + PKCEParams, +} from './oauth-flow.js'; +import { + generatePKCEParams, + getPortFromUrl, + buildAuthorizationUrl, + startCallbackServer, + exchangeCodeForToken, + refreshAccessToken, + REDIRECT_PATH, +} from './oauth-flow.js'; + +// Save real fetch for startCallbackServer tests (which hit a real local server) +const realFetch = global.fetch; + +// Mock fetch globally for token exchange/refresh tests +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +/** + * Helper to create a mock Response object. + */ +function createMockResponse( + body: string, + options: { status?: number; contentType?: string } = {}, +): Response { + const { status = 200, contentType = 'application/json' } = options; + return { + ok: status >= 200 && status < 300, + status, + text: () => Promise.resolve(body), + headers: new Headers({ 'content-type': contentType }), + } as Response; +} + +const baseConfig: OAuthFlowConfig = { + clientId: 'test-client-id', + authorizationUrl: 'https://auth.example.com/authorize', + tokenUrl: 'https://auth.example.com/token', +}; + +const basePkceParams: PKCEParams = { + codeVerifier: 'test-verifier', + codeChallenge: 'test-challenge', + state: 'test-state', +}; + +describe('oauth-flow', () => { + beforeEach(() => { + vi.stubEnv('OAUTH_CALLBACK_PORT', ''); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + describe('generatePKCEParams', () => { + it('should return codeVerifier, codeChallenge, and state', () => { + const params = generatePKCEParams(); + expect(params).toHaveProperty('codeVerifier'); + expect(params).toHaveProperty('codeChallenge'); + expect(params).toHaveProperty('state'); + }); + + it('should generate a code verifier of at least 43 characters', () => { + const params = generatePKCEParams(); + expect(params.codeVerifier.length).toBeGreaterThanOrEqual(43); + }); + + it('should generate unique values on each call', () => { + const params1 = generatePKCEParams(); + const params2 = generatePKCEParams(); + expect(params1.codeVerifier).not.toBe(params2.codeVerifier); + expect(params1.state).not.toBe(params2.state); + }); + + it('should generate base64url-encoded values', () => { + const params = generatePKCEParams(); + const base64urlRegex = /^[A-Za-z0-9_-]+$/; + expect(params.codeVerifier).toMatch(base64urlRegex); + expect(params.codeChallenge).toMatch(base64urlRegex); + expect(params.state).toMatch(base64urlRegex); + }); + }); + + describe('getPortFromUrl', () => { + it('should return undefined for undefined input', () => { + expect(getPortFromUrl(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(getPortFromUrl('')).toBeUndefined(); + }); + + it('should return undefined for invalid URL', () => { + expect(getPortFromUrl('not-a-url')).toBeUndefined(); + }); + + it('should return the port number from a URL with an explicit port', () => { + expect(getPortFromUrl('http://localhost:8080/callback')).toBe(8080); + }); + + it('should return undefined for a URL without an explicit port', () => { + expect(getPortFromUrl('https://example.com/callback')).toBeUndefined(); + }); + + it('should return port for edge case port 1', () => { + expect(getPortFromUrl('http://localhost:1')).toBe(1); + }); + + it('should return port for edge case port 65535', () => { + expect(getPortFromUrl('http://localhost:65535')).toBe(65535); + }); + }); + + describe('buildAuthorizationUrl', () => { + it('should build a valid authorization URL with required parameters', () => { + const url = buildAuthorizationUrl(baseConfig, basePkceParams, 3000); + const parsed = new URL(url); + + expect(parsed.origin).toBe('https://auth.example.com'); + expect(parsed.pathname).toBe('/authorize'); + expect(parsed.searchParams.get('client_id')).toBe('test-client-id'); + expect(parsed.searchParams.get('response_type')).toBe('code'); + expect(parsed.searchParams.get('state')).toBe('test-state'); + expect(parsed.searchParams.get('code_challenge')).toBe('test-challenge'); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + }); + + it('should use the default redirect URI based on port', () => { + const url = buildAuthorizationUrl(baseConfig, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.get('redirect_uri')).toBe( + `http://localhost:3000${REDIRECT_PATH}`, + ); + }); + + it('should use a custom redirectUri from config when provided', () => { + const config: OAuthFlowConfig = { + ...baseConfig, + redirectUri: 'https://custom.example.com/callback', + }; + const url = buildAuthorizationUrl(config, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.get('redirect_uri')).toBe( + 'https://custom.example.com/callback', + ); + }); + + it('should include scopes when provided', () => { + const config: OAuthFlowConfig = { + ...baseConfig, + scopes: ['read', 'write'], + }; + const url = buildAuthorizationUrl(config, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.get('scope')).toBe('read write'); + }); + + it('should not include scope param when scopes array is empty', () => { + const config: OAuthFlowConfig = { + ...baseConfig, + scopes: [], + }; + const url = buildAuthorizationUrl(config, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.has('scope')).toBe(false); + }); + + it('should include audiences when provided', () => { + const config: OAuthFlowConfig = { + ...baseConfig, + audiences: ['https://api.example.com'], + }; + const url = buildAuthorizationUrl(config, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.get('audience')).toBe( + 'https://api.example.com', + ); + }); + + it('should include resource parameter when provided', () => { + const url = buildAuthorizationUrl( + baseConfig, + basePkceParams, + 3000, + 'https://mcp.example.com', + ); + const parsed = new URL(url); + expect(parsed.searchParams.get('resource')).toBe( + 'https://mcp.example.com', + ); + }); + + it('should not include resource parameter when not provided', () => { + const url = buildAuthorizationUrl(baseConfig, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.has('resource')).toBe(false); + }); + }); + + describe('startCallbackServer', () => { + it('should start a server and resolve port', async () => { + const server = startCallbackServer('test-state'); + const port = await server.port; + expect(port).toBeGreaterThan(0); + + // Make a successful callback request to close the server + const res = await realFetch( + `http://localhost:${port}${REDIRECT_PATH}?code=abc&state=test-state`, + ); + expect(res.status).toBe(200); + await server.response; + }); + + it('should resolve response with code and state on valid callback', async () => { + const server = startCallbackServer('my-state'); + const port = await server.port; + + await realFetch( + `http://localhost:${port}${REDIRECT_PATH}?code=auth-code-123&state=my-state`, + ); + + const response = await server.response; + expect(response.code).toBe('auth-code-123'); + expect(response.state).toBe('my-state'); + }); + + it('should reject on state mismatch', async () => { + const server = startCallbackServer('expected-state'); + const port = await server.port; + + // Attach rejection handler BEFORE triggering the callback to prevent + // unhandled rejection race with Vitest's detection. + const responseResult = server.response.then( + () => new Error('Expected rejection'), + (e: Error) => e, + ); + + await realFetch( + `http://localhost:${port}${REDIRECT_PATH}?code=abc&state=wrong-state`, + ).catch(() => { + // Connection may be reset by server closing — expected + }); + + const error = await responseResult; + expect(error.message).toContain('State mismatch - possible CSRF attack'); + }); + + it('should reject on OAuth error in callback', async () => { + const server = startCallbackServer('test-state'); + const port = await server.port; + + // Attach rejection handler BEFORE triggering the callback + const responseResult = server.response.then( + () => new Error('Expected rejection'), + (e: Error) => e, + ); + + await realFetch( + `http://localhost:${port}${REDIRECT_PATH}?error=access_denied&error_description=User+denied`, + ).catch(() => { + // Connection may be reset by server closing — expected + }); + + const error = await responseResult; + expect(error.message).toContain('OAuth error: access_denied'); + }); + + it('should return 404 for non-callback paths', async () => { + const server = startCallbackServer('test-state'); + const port = await server.port; + + const res = await realFetch(`http://localhost:${port}/other-path`); + expect(res.status).toBe(404); + + // Clean up: send valid callback to close the server + await realFetch( + `http://localhost:${port}${REDIRECT_PATH}?code=abc&state=test-state`, + ); + await server.response; + }); + + it('should reject when OAUTH_CALLBACK_PORT env var is invalid', async () => { + vi.stubEnv('OAUTH_CALLBACK_PORT', 'not-a-number'); + + const server = startCallbackServer('test-state'); + + await expect(server.port).rejects.toThrow( + 'Invalid value for OAUTH_CALLBACK_PORT', + ); + await expect(server.response).rejects.toThrow( + 'Invalid value for OAUTH_CALLBACK_PORT', + ); + }); + }); + + describe('exchangeCodeForToken', () => { + it('should exchange code for token with JSON response', async () => { + const tokenResponse = { + access_token: 'test-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'test-refresh-token', + }; + mockFetch.mockResolvedValueOnce( + createMockResponse(JSON.stringify(tokenResponse)), + ); + + const result = await exchangeCodeForToken( + baseConfig, + 'auth-code', + 'code-verifier', + 3000, + ); + + expect(result.access_token).toBe('test-access-token'); + expect(result.token_type).toBe('Bearer'); + expect(result.expires_in).toBe(3600); + expect(result.refresh_token).toBe('test-refresh-token'); + }); + + it('should send correct parameters in the request body', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await exchangeCodeForToken(baseConfig, 'my-code', 'my-verifier', 4000); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://auth.example.com/token'); + const body = new URLSearchParams(options.body as string); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('my-code'); + expect(body.get('code_verifier')).toBe('my-verifier'); + expect(body.get('client_id')).toBe('test-client-id'); + expect(body.get('redirect_uri')).toBe( + `http://localhost:4000${REDIRECT_PATH}`, + ); + }); + + it('should include client_secret when provided', async () => { + const config: OAuthFlowConfig = { + ...baseConfig, + clientSecret: 'my-secret', + }; + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await exchangeCodeForToken(config, 'code', 'verifier', 3000); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('client_secret')).toBe('my-secret'); + }); + + it('should include resource parameter when provided', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await exchangeCodeForToken( + baseConfig, + 'code', + 'verifier', + 3000, + 'https://mcp.example.com', + ); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('resource')).toBe('https://mcp.example.com'); + }); + + it('should handle form-urlencoded token response', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + 'access_token=form-token&token_type=Bearer&expires_in=7200', + { contentType: 'application/x-www-form-urlencoded' }, + ), + ); + + const result = await exchangeCodeForToken( + baseConfig, + 'code', + 'verifier', + 3000, + ); + + expect(result.access_token).toBe('form-token'); + expect(result.token_type).toBe('Bearer'); + expect(result.expires_in).toBe(7200); + }); + + it('should throw on non-ok response', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse('Bad request', { status: 400 }), + ); + + await expect( + exchangeCodeForToken(baseConfig, 'code', 'verifier', 3000), + ).rejects.toThrow('Token exchange failed'); + }); + + it('should throw on non-ok response with form-urlencoded error', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + 'error=invalid_grant&error_description=Code+expired', + { + status: 400, + contentType: 'application/x-www-form-urlencoded', + }, + ), + ); + + await expect( + exchangeCodeForToken(baseConfig, 'code', 'verifier', 3000), + ).rejects.toThrow('invalid_grant'); + }); + + it('should throw when JSON response has no access_token and form-urlencoded fallback also fails', async () => { + // JSON that parses but has no access_token — falls through to form-urlencoded + // which also has no access_token + mockFetch.mockResolvedValueOnce( + createMockResponse(JSON.stringify({ error: 'server_error' })), + ); + + await expect( + exchangeCodeForToken(baseConfig, 'code', 'verifier', 3000), + ).rejects.toThrow('Token exchange failed'); + }); + + it('should use custom redirectUri from config', async () => { + const config: OAuthFlowConfig = { + ...baseConfig, + redirectUri: 'https://custom.example.com/cb', + }; + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await exchangeCodeForToken(config, 'code', 'verifier', 3000); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('redirect_uri')).toBe('https://custom.example.com/cb'); + }); + + it('should default token_type to Bearer when missing from JSON response', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse(JSON.stringify({ access_token: 'tok' })), + ); + + const result = await exchangeCodeForToken( + baseConfig, + 'code', + 'verifier', + 3000, + ); + expect(result.token_type).toBe('Bearer'); + }); + }); + + describe('refreshAccessToken', () => { + const refreshConfig: OAuthRefreshConfig = { + clientId: 'test-client-id', + }; + + it('should refresh a token with JSON response', async () => { + const tokenResponse = { + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + }; + mockFetch.mockResolvedValueOnce( + createMockResponse(JSON.stringify(tokenResponse)), + ); + + const result = await refreshAccessToken( + refreshConfig, + 'old-refresh-token', + 'https://auth.example.com/token', + ); + + expect(result.access_token).toBe('new-access-token'); + expect(result.expires_in).toBe(3600); + }); + + it('should send correct parameters in the request body', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await refreshAccessToken( + refreshConfig, + 'my-refresh-token', + 'https://auth.example.com/token', + ); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('my-refresh-token'); + expect(body.get('client_id')).toBe('test-client-id'); + }); + + it('should include client_secret when provided', async () => { + const config: OAuthRefreshConfig = { + ...refreshConfig, + clientSecret: 'secret', + }; + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await refreshAccessToken( + config, + 'refresh-token', + 'https://auth.example.com/token', + ); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('client_secret')).toBe('secret'); + }); + + it('should include scopes and audiences when provided', async () => { + const config: OAuthRefreshConfig = { + ...refreshConfig, + scopes: ['read', 'write'], + audiences: ['https://api.example.com'], + }; + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await refreshAccessToken( + config, + 'refresh-token', + 'https://auth.example.com/token', + ); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('scope')).toBe('read write'); + expect(body.get('audience')).toBe('https://api.example.com'); + }); + + it('should include resource parameter when provided', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await refreshAccessToken( + refreshConfig, + 'refresh-token', + 'https://auth.example.com/token', + 'https://mcp.example.com', + ); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('resource')).toBe('https://mcp.example.com'); + }); + + it('should throw on non-ok response', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse('Unauthorized', { status: 401 }), + ); + + await expect( + refreshAccessToken( + refreshConfig, + 'bad-token', + 'https://auth.example.com/token', + ), + ).rejects.toThrow('Token refresh failed'); + }); + + it('should handle form-urlencoded token response', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + 'access_token=refreshed-token&token_type=Bearer&expires_in=1800', + { contentType: 'application/x-www-form-urlencoded' }, + ), + ); + + const result = await refreshAccessToken( + refreshConfig, + 'refresh-token', + 'https://auth.example.com/token', + ); + + expect(result.access_token).toBe('refreshed-token'); + expect(result.expires_in).toBe(1800); + }); + }); +}); diff --git a/packages/core/src/utils/oauth-flow.ts b/packages/core/src/utils/oauth-flow.ts new file mode 100644 index 0000000000..9d5e6b8357 --- /dev/null +++ b/packages/core/src/utils/oauth-flow.ts @@ -0,0 +1,515 @@ +/** + * @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'); +}