mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-26 13:04:49 -07:00
refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider (#20895)
This commit is contained in:
@@ -4,9 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as http from 'node:http';
|
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
import type * as net from 'node:net';
|
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
|
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
|
||||||
import type { OAuthToken } from './token-storage/types.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 { coreEvents } from '../utils/events.js';
|
||||||
import { debugLogger } from '../utils/debugLogger.js';
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
import { getConsentForOauth } from '../utils/authConsent.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.
|
* OAuth configuration for an MCP server.
|
||||||
@@ -34,25 +49,6 @@ export interface MCPOAuthConfig {
|
|||||||
registrationUrl?: string;
|
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).
|
* Dynamic client registration request (RFC 7591).
|
||||||
*/
|
*/
|
||||||
@@ -80,18 +76,6 @@ export interface OAuthClientRegistrationResponse {
|
|||||||
scope?: string;
|
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.
|
* Provider for handling OAuth authentication for MCP servers.
|
||||||
*/
|
*/
|
||||||
@@ -239,375 +223,18 @@ export class MCPOAuthProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate PKCE parameters for OAuth flow.
|
* Build the OAuth resource parameter from an MCP server URL, if available.
|
||||||
*
|
* Returns undefined if the URL is not provided or cannot be processed.
|
||||||
* @returns PKCE parameters including code verifier, challenge, and state
|
|
||||||
*/
|
*/
|
||||||
private generatePKCEParams(): PKCEParams {
|
private buildResourceParam(mcpServerUrl?: string): string | undefined {
|
||||||
// Generate code verifier (43-128 characters)
|
if (!mcpServerUrl) return undefined;
|
||||||
// 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<number>;
|
|
||||||
response: Promise<OAuthAuthorizationResponse>;
|
|
||||||
} {
|
|
||||||
let portResolve: (port: number) => void;
|
|
||||||
let portReject: (error: Error) => void;
|
|
||||||
const portPromise = new Promise<number>((resolve, reject) => {
|
|
||||||
portResolve = resolve;
|
|
||||||
portReject = reject;
|
|
||||||
});
|
|
||||||
|
|
||||||
const responsePromise = new Promise<OAuthAuthorizationResponse>(
|
|
||||||
(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(`
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<h1>Authentication Failed</h1>
|
|
||||||
<p>Error: ${error.replace(/</g, '<').replace(/>/g, '>')}</p>
|
|
||||||
<p>${(url.searchParams.get('error_description') || '').replace(/</g, '<').replace(/>/g, '>')}</p>
|
|
||||||
<p>You can close this window.</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
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(`
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<h1>Authentication Successful!</h1>
|
|
||||||
<p>You can close this window and return to Gemini CLI.</p>
|
|
||||||
<script>window.close();</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(urlString);
|
return OAuthUtils.buildResourceParameter(mcpServerUrl);
|
||||||
if (url.port) {
|
} catch (error) {
|
||||||
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<OAuthTokenResponse> {
|
|
||||||
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')
|
|
||||||
) {
|
|
||||||
debugLogger.warn(
|
debugLogger.warn(
|
||||||
`Token endpoint returned unexpected content-type: ${contentType}. ` +
|
`Could not add resource parameter: ${getErrorMessage(error)}`,
|
||||||
`Expected application/json or application/x-www-form-urlencoded. ` +
|
|
||||||
`Will attempt to parse response.`,
|
|
||||||
);
|
);
|
||||||
}
|
return undefined;
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,112 +253,21 @@ export class MCPOAuthProvider {
|
|||||||
tokenUrl: string,
|
tokenUrl: string,
|
||||||
mcpServerUrl?: string,
|
mcpServerUrl?: string,
|
||||||
): Promise<OAuthTokenResponse> {
|
): Promise<OAuthTokenResponse> {
|
||||||
const params = new URLSearchParams({
|
if (!config.clientId) {
|
||||||
grant_type: 'refresh_token',
|
throw new Error('Missing required clientId for token refresh');
|
||||||
refresh_token: refreshToken,
|
|
||||||
client_id: config.clientId!,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config.clientSecret) {
|
|
||||||
params.append('client_secret', config.clientSecret);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.scopes && config.scopes.length > 0) {
|
return refreshAccessTokenShared(
|
||||||
params.append('scope', config.scopes.join(' '));
|
{
|
||||||
}
|
clientId: config.clientId,
|
||||||
|
clientSecret: config.clientSecret,
|
||||||
if (config.audiences && config.audiences.length > 0) {
|
scopes: config.scopes,
|
||||||
params.append('audience', config.audiences.join(' '));
|
audiences: config.audiences,
|
||||||
}
|
|
||||||
|
|
||||||
// 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',
|
|
||||||
},
|
},
|
||||||
body: params.toString(),
|
refreshToken,
|
||||||
});
|
tokenUrl,
|
||||||
|
this.buildResourceParam(mcpServerUrl),
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -830,17 +366,14 @@ export class MCPOAuthProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate PKCE parameters
|
// Generate PKCE parameters
|
||||||
const pkceParams = this.generatePKCEParams();
|
const pkceParams = generatePKCEParams();
|
||||||
|
|
||||||
// Determine preferred port from redirectUri if available
|
// 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
|
// Start callback server first to allocate port
|
||||||
// This ensures we only create one server and eliminates race conditions
|
// This ensures we only create one server and eliminates race conditions
|
||||||
const callbackServer = this.startCallbackServer(
|
const callbackServer = startCallbackServer(pkceParams.state, preferredPort);
|
||||||
pkceParams.state,
|
|
||||||
preferredPort,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for server to start and get the allocated port
|
// Wait for server to start and get the allocated port
|
||||||
// We need this port for client registration and auth URL building
|
// 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
|
// Build authorization URL
|
||||||
const authUrl = this.buildAuthorizationUrl(
|
const resource = this.buildResourceParam(mcpServerUrl);
|
||||||
config,
|
const authUrl = buildAuthorizationUrl(
|
||||||
|
flowConfig,
|
||||||
pkceParams,
|
pkceParams,
|
||||||
redirectPort,
|
redirectPort,
|
||||||
mcpServerUrl,
|
resource,
|
||||||
);
|
);
|
||||||
|
|
||||||
const userConsent = await getConsentForOauth(
|
const userConsent = await getConsentForOauth(
|
||||||
@@ -933,12 +478,12 @@ ${authUrl}
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Exchange code for tokens
|
// Exchange code for tokens
|
||||||
const tokenResponse = await this.exchangeCodeForToken(
|
const tokenResponse = await exchangeCodeForToken(
|
||||||
config,
|
flowConfig,
|
||||||
code,
|
code,
|
||||||
pkceParams.codeVerifier,
|
pkceParams.codeVerifier,
|
||||||
redirectPort,
|
redirectPort,
|
||||||
mcpServerUrl,
|
resource,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to our token format
|
// Convert to our token format
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<number>;
|
||||||
|
response: Promise<OAuthAuthorizationResponse>;
|
||||||
|
} {
|
||||||
|
let portResolve: (port: number) => void;
|
||||||
|
let portReject: (error: Error) => void;
|
||||||
|
const portPromise = new Promise<number>((resolve, reject) => {
|
||||||
|
portResolve = resolve;
|
||||||
|
portReject = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const responsePromise = new Promise<OAuthAuthorizationResponse>(
|
||||||
|
(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(`
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Authentication Failed</h1>
|
||||||
|
<p>Error: ${error.replace(/</g, '<').replace(/>/g, '>')}</p>
|
||||||
|
<p>${(url.searchParams.get('error_description') || '').replace(/</g, '<').replace(/>/g, '>')}</p>
|
||||||
|
<p>You can close this window.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
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(`
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Authentication Successful!</h1>
|
||||||
|
<p>You can close this window and return to Gemini CLI.</p>
|
||||||
|
<script>window.close();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
|
||||||
|
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<OAuthTokenResponse> {
|
||||||
|
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<string, unknown>)['access_token'] === 'string'
|
||||||
|
) {
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
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<OAuthTokenResponse> {
|
||||||
|
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<OAuthTokenResponse> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user