import {describe, it, expect, beforeEach, vi, Mock} from 'vitest';
import {createHash, randomBytes} from 'crypto';
import {Response} from 'express';
import {GmailOAuthServerProvider} from './oauth-provider.js';
import type {OAuthCredentials, OAuthTokens} from '../oauth.js';
import {OAuthHandler} from './oauth-handler.js';
import {decryptToken} from '../token-encryption.js';
import {auditAccessDenied} from '../../audit.js';
vi.mock('../../audit.js', () => ({
auditOauthStarted: vi.fn(),
auditOauthSuccess: vi.fn(),
auditOauthFailure: vi.fn(),
auditTokenExchangeSuccess: vi.fn(),
auditTokenExchangeFailure: vi.fn(),
auditTokenRevoked: vi.fn(),
auditAccessDenied: vi.fn(),
maskEmail: (email: string) => email.replace(/(.)[^@]*(@.*)/, '$1***$2'),
maskToken: (token: string) => token.length > 8 ? `${token.slice(0, 4)}...${token.slice(-4)}` : '***',
}));
const TEST_CODE_VERIFIER = 'test-code-verifier-with-sufficient-length-for-pkce';
const TEST_CODE_CHALLENGE = createHash('sha256').update(TEST_CODE_VERIFIER).digest('base64url');
const mockCredentials: OAuthCredentials = {
client_id: 'google-client-id',
client_secret: 'google-client-secret',
redirect_uri: 'https://example.com/callback',
};
const mockGoogleTokens: OAuthTokens = {
access_token: 'google-access-token',
refresh_token: 'google-refresh-token',
scope: 'https://www.googleapis.com/auth/gmail.readonly',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000,
};
const encryptionSecret = randomBytes(32);
async function registerClient(provider: GmailOAuthServerProvider, redirectUri: string, clientName?: string) {
const metadata: Record<string, unknown> = {
redirect_uris: [new URL(redirectUri)],
};
if (clientName) {
metadata.client_name = clientName;
}
return provider.clientsStore.registerClient!(metadata as never);
}
describe('GmailOAuthServerProvider', () => {
let provider: GmailOAuthServerProvider;
let oauthHandler: OAuthHandler;
let mockCreateOAuth2Client: Mock;
beforeEach(() => {
vi.clearAllMocks();
mockCreateOAuth2Client = vi.fn().mockReturnValue({
setCredentials: vi.fn(),
refreshAccessToken: vi.fn().mockResolvedValue({
credentials: {
access_token: 'refreshed-access-token',
expiry_date: Date.now() + 3600000,
},
}),
});
oauthHandler = {
generateAuthUrl: vi.fn().mockReturnValue('https://accounts.google.com/oauth'),
exchangeCodeForTokens: vi.fn().mockResolvedValue(mockGoogleTokens),
getUserInfo: vi.fn().mockResolvedValue({email: 'user@example.com'}),
createOAuth2Client: mockCreateOAuth2Client,
} as unknown as OAuthHandler;
provider = new GmailOAuthServerProvider(
mockCredentials,
oauthHandler,
encryptionSecret
);
});
describe('clientsStore', () => {
it('registers a new client', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
expect(client.client_id).toBeDefined();
expect(client.client_id_issued_at).toBeDefined();
});
it('retrieves registered client', async () => {
const registered = await registerClient(provider, 'https://client.example.com/callback', 'Test Client');
const retrieved = provider.clientsStore.getClient(registered.client_id);
expect(retrieved).toEqual(registered);
});
it('returns undefined for unknown client', () => {
expect(provider.clientsStore.getClient('unknown-id')).toBeUndefined();
});
});
describe('authorize', () => {
it('redirects to Google with requestId as state', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {
redirect: vi.fn(),
} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
state: 'test-state',
},
mockRes
);
expect(mockRes.redirect).toHaveBeenCalledWith('https://accounts.google.com/oauth');
expect(oauthHandler.generateAuthUrl).toHaveBeenCalled();
const stateArg = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
expect(stateArg).toMatch(/^[a-f0-9-]{36}$/);
});
it('throws on invalid redirect URI', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await expect(
provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://malicious.example.com/callback',
},
mockRes
)
).rejects.toThrow('Invalid redirect URI');
});
});
describe('handleGoogleCallback', () => {
it('exchanges code and returns redirect URL with auth code', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
state: 'original-state',
},
mockRes
);
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
const {redirectUrl} = await provider.handleGoogleCallback(requestId, 'google-auth-code');
expect(oauthHandler.exchangeCodeForTokens).toHaveBeenCalledWith('google-auth-code');
expect(oauthHandler.getUserInfo).toHaveBeenCalledWith(mockGoogleTokens);
const url = new URL(redirectUrl);
expect(url.origin + url.pathname).toBe('https://client.example.com/callback');
expect(url.searchParams.get('code')).toBeDefined();
expect(url.searchParams.get('state')).toBe('original-state');
});
it('throws on invalid request ID', async () => {
await expect(
provider.handleGoogleCallback('invalid-id', 'code')
).rejects.toThrow('Invalid or expired authorization request');
});
});
describe('challengeForAuthorizationCode', () => {
it('returns stored code challenge', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
},
mockRes
);
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
const {redirectUrl} = await provider.handleGoogleCallback(requestId, 'google-code');
const authCode = new URL(redirectUrl).searchParams.get('code')!;
const challenge = await provider.challengeForAuthorizationCode(client, authCode);
expect(challenge).toBe(TEST_CODE_CHALLENGE);
});
it('throws on invalid authorization code', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
await expect(
provider.challengeForAuthorizationCode(client, 'invalid-code')
).rejects.toThrow('Invalid authorization code');
});
});
describe('exchangeAuthorizationCode', () => {
it('returns JWE encrypted access token', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
},
mockRes
);
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
const {redirectUrl} = await provider.handleGoogleCallback(requestId, 'google-code');
const authCode = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, authCode, TEST_CODE_VERIFIER);
expect(tokens.access_token).toBeDefined();
expect(tokens.access_token.split('.')).toHaveLength(5);
expect(tokens.token_type).toBe('Bearer');
const payload = await decryptToken(tokens.access_token, encryptionSecret);
expect(payload.access_token).toBe('google-access-token');
expect(payload.refresh_token).toBe('google-refresh-token');
expect(payload.email).toBe('user@example.com');
});
it('throws on client ID mismatch', async () => {
const client1 = await registerClient(provider, 'https://client1.example.com/callback');
const client2 = await registerClient(provider, 'https://client2.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client1,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client1.example.com/callback',
},
mockRes
);
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
const {redirectUrl} = await provider.handleGoogleCallback(requestId, 'google-code');
const authCode = new URL(redirectUrl).searchParams.get('code')!;
await expect(
provider.exchangeAuthorizationCode(client2, authCode, TEST_CODE_VERIFIER)
).rejects.toThrow('Client ID mismatch');
});
it('invalidates code after use', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
},
mockRes
);
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
const {redirectUrl} = await provider.handleGoogleCallback(requestId, 'google-code');
const authCode = new URL(redirectUrl).searchParams.get('code')!;
await provider.exchangeAuthorizationCode(client, authCode, TEST_CODE_VERIFIER);
await expect(
provider.exchangeAuthorizationCode(client, authCode, TEST_CODE_VERIFIER)
).rejects.toThrow('Invalid authorization code');
});
});
describe('exchangeRefreshToken', () => {
it('throws not supported error', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
await expect(
provider.exchangeRefreshToken(client, 'refresh-token')
).rejects.toThrow('Refresh tokens not supported');
});
});
describe('verifyAccessToken', () => {
it('returns auth info for valid JWE token', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
},
mockRes
);
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
const {redirectUrl} = await provider.handleGoogleCallback(requestId, 'google-code');
const authCode = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, authCode, TEST_CODE_VERIFIER);
const authInfo = await provider.verifyAccessToken(tokens.access_token);
expect(authInfo.token).toBe(tokens.access_token);
expect(authInfo.scopes).toEqual(['gmail.readonly']);
expect(authInfo.extra?.googleAccessToken).toBe('google-access-token');
expect(authInfo.extra?.email).toBe('user@example.com');
});
it('throws on invalid token and logs audit event', async () => {
await expect(provider.verifyAccessToken('invalid-token')).rejects.toThrow(
'Invalid access token'
);
expect(auditAccessDenied).toHaveBeenCalledWith({
reason: 'invalid_jwe_token',
});
});
it('refreshes expired tokens', async () => {
const expiredTokens = {
...mockGoogleTokens,
expiry_date: Date.now() - 1000,
};
(oauthHandler.exchangeCodeForTokens as Mock).mockResolvedValueOnce(expiredTokens);
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
},
mockRes
);
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
const {redirectUrl} = await provider.handleGoogleCallback(requestId, 'google-code');
const authCode = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, authCode, TEST_CODE_VERIFIER);
const authInfo = await provider.verifyAccessToken(tokens.access_token);
expect(mockCreateOAuth2Client).toHaveBeenCalled();
expect(authInfo.extra?.googleAccessToken).toBe('refreshed-access-token');
});
it('throws when refresh fails and logs audit event with error', async () => {
const expiredTokens = {
...mockGoogleTokens,
expiry_date: Date.now() - 1000,
};
(oauthHandler.exchangeCodeForTokens as Mock).mockResolvedValueOnce(expiredTokens);
mockCreateOAuth2Client.mockReturnValueOnce({
setCredentials: vi.fn(),
refreshAccessToken: vi.fn().mockRejectedValue(new Error('Refresh failed')),
});
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
},
mockRes
);
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
const {redirectUrl} = await provider.handleGoogleCallback(requestId, 'google-code');
const authCode = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, authCode, TEST_CODE_VERIFIER);
await expect(provider.verifyAccessToken(tokens.access_token)).rejects.toThrow(
'Failed to refresh token'
);
expect(auditAccessDenied).toHaveBeenCalledWith({
reason: 'token_refresh_failed',
userId: 'u***@example.com',
error: 'Refresh failed',
});
});
});
describe('revokeToken', () => {
it('does not throw when revoking any token', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
await expect(
provider.revokeToken(client, {token: 'any-token'})
).resolves.toBeUndefined();
});
});
describe('cleanup', () => {
it('removes expired pending requests', async () => {
vi.useFakeTimers();
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
},
mockRes
);
vi.advanceTimersByTime(6 * 60 * 1000);
provider.cleanup();
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
await expect(
provider.handleGoogleCallback(requestId, 'google-code')
).rejects.toThrow('Invalid or expired authorization request');
vi.useRealTimers();
});
it('removes expired auth codes', async () => {
vi.useFakeTimers();
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
},
mockRes
);
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
const {redirectUrl} = await provider.handleGoogleCallback(requestId, 'code');
const authCode = new URL(redirectUrl).searchParams.get('code')!;
vi.advanceTimersByTime(11 * 60 * 1000);
provider.cleanup();
await expect(
provider.challengeForAuthorizationCode(client, authCode)
).rejects.toThrow('Invalid authorization code');
vi.useRealTimers();
});
});
describe('handleGoogleCallback error handling', () => {
it('propagates error when exchangeCodeForTokens fails', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
},
mockRes
);
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
(oauthHandler.exchangeCodeForTokens as Mock).mockRejectedValueOnce(new Error('Token exchange failed'));
await expect(
provider.handleGoogleCallback(requestId, 'bad-code')
).rejects.toThrow('Token exchange failed');
});
it('propagates error when getUserInfo fails', async () => {
const client = await registerClient(provider, 'https://client.example.com/callback');
const mockRes = {redirect: vi.fn()} as unknown as Response;
await provider.authorize(
client,
{
codeChallenge: TEST_CODE_CHALLENGE,
redirectUri: 'https://client.example.com/callback',
},
mockRes
);
const requestId = (oauthHandler.generateAuthUrl as Mock).mock.calls[0][0];
(oauthHandler.getUserInfo as Mock).mockRejectedValueOnce(new Error('User info failed'));
await expect(
provider.handleGoogleCallback(requestId, 'code')
).rejects.toThrow('User info failed');
});
});
});