mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 17:41:24 -07:00
636 lines
20 KiB
TypeScript
636 lines
20 KiB
TypeScript
/**
|
|
* @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);
|
|
});
|
|
});
|
|
});
|