import { describe, it, expect, vi, afterEach } from 'vitest' import crypto from 'node:crypto' import { base64url, generatePKCE, exchangeCodeForTokens } from '../../src/auth/oauth-flow.js' import type { OAuthConfig } from '../../src/auth/oauth-flow.js' describe('base64url', () => { it('produces output without +, /, or = characters', () => { // Test with many random buffers to catch all variants for (let i = 0; i < 50; i++) { const buf = crypto.randomBytes(32) const result = base64url(buf) expect(result).not.toContain('+') expect(result).not.toContain('/') expect(result).not.toContain('=') } }) it('produces a non-empty string for non-empty input', () => { const result = base64url(Buffer.from('hello')) expect(result.length).toBeGreaterThan(0) }) it('is URL-safe: replaces + with -, / with _', () => { // Craft a buffer that produces + and / in standard base64 // Standard base64 of [0xfb, 0xff] = '+/8=' const result = base64url(Buffer.from([0xfb, 0xff])) expect(result).toBe('-_8'.replace('8', '8')) // just verify no + or / expect(result).not.toContain('+') expect(result).not.toContain('/') }) }) describe('generatePKCE', () => { it('returns verifier and challenge strings', () => { const { verifier, challenge } = generatePKCE() expect(typeof verifier).toBe('string') expect(typeof challenge).toBe('string') expect(verifier.length).toBeGreaterThan(0) expect(challenge.length).toBeGreaterThan(0) }) it('verifier and challenge are different', () => { const { verifier, challenge } = generatePKCE() expect(verifier).not.toBe(challenge) }) it('challenge is the base64url(SHA256(verifier))', () => { const { verifier, challenge } = generatePKCE() const expected = base64url(crypto.createHash('sha256').update(verifier).digest()) expect(challenge).toBe(expected) }) it('each call produces a unique verifier (non-deterministic)', () => { const a = generatePKCE() const b = generatePKCE() // Extremely unlikely to collide with 32 random bytes expect(a.verifier).not.toBe(b.verifier) }) it('verifier has no +, /, = (is URL-safe)', () => { for (let i = 0; i < 20; i++) { const { verifier } = generatePKCE() expect(verifier).not.toContain('+') expect(verifier).not.toContain('/') expect(verifier).not.toContain('=') } }) }) const minimalCfg: OAuthConfig = { providerLabel: 'Test Provider', clientId: 'test-client-id', authorizeUrl: 'https://example.com/auth', tokenUrl: 'https://example.com/token', scope: 'openid', } describe('exchangeCodeForTokens', () => { afterEach(() => { vi.restoreAllMocks() }) it('returns accessToken from successful response', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, text: async () => JSON.stringify({ access_token: 'tk-abc', expires_in: 3600 }), }) vi.stubGlobal('fetch', mockFetch) const result = await exchangeCodeForTokens(minimalCfg, 'code123', 'state456', 'verifier789', 'https://redirect') expect(result.accessToken).toBe('tk-abc') expect(result.expiresIn).toBe(3600) }) it('populates raw with full response', async () => { const raw = { access_token: 'tk', refresh_token: 'rt', id_token: 'it', expires_in: 1800, extra: 'val' } vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => JSON.stringify(raw), })) const result = await exchangeCodeForTokens(minimalCfg, 'c', 's', 'v', 'https://r') expect(result.refreshToken).toBe('rt') expect(result.idToken).toBe('it') expect(result.raw).toMatchObject(raw) }) it('throws when response is not ok', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 400, text: async () => 'Bad Request', })) await expect( exchangeCodeForTokens(minimalCfg, 'c', 's', 'v', 'https://r') ).rejects.toThrow('Token exchange 400') }) it('throws when response body is not JSON', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => 'not json at all', })) await expect( exchangeCodeForTokens(minimalCfg, 'c', 's', 'v', 'https://r') ).rejects.toThrow(/no-JSON|JSON/) }) it('throws when access_token is missing', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => JSON.stringify({ refresh_token: 'rt' }), })) await expect( exchangeCodeForTokens(minimalCfg, 'c', 's', 'v', 'https://r') ).rejects.toThrow(/access_token/) }) it('sends body as form-urlencoded by default', async () => { let capturedBody: string | null = null let capturedHeaders: Record = {} vi.stubGlobal('fetch', vi.fn().mockImplementation((_url: string, opts: RequestInit) => { capturedBody = opts.body as string capturedHeaders = opts.headers as Record return Promise.resolve({ ok: true, text: async () => JSON.stringify({ access_token: 'tk' }) }) })) await exchangeCodeForTokens(minimalCfg, 'c', 's', 'v', 'https://r') expect(capturedHeaders['Content-Type']).toBe('application/x-www-form-urlencoded') expect(capturedBody).toContain('grant_type=authorization_code') }) it('sends body as JSON when tokenRequestFormat is "json"', async () => { let capturedBody: string | null = null let capturedHeaders: Record = {} vi.stubGlobal('fetch', vi.fn().mockImplementation((_url: string, opts: RequestInit) => { capturedBody = opts.body as string capturedHeaders = opts.headers as Record return Promise.resolve({ ok: true, text: async () => JSON.stringify({ access_token: 'tk' }) }) })) const cfg = { ...minimalCfg, tokenRequestFormat: 'json' as const } await exchangeCodeForTokens(cfg, 'c', 's', 'v', 'https://r') expect(capturedHeaders['Content-Type']).toBe('application/json') const parsed = JSON.parse(capturedBody!) expect(parsed.grant_type).toBe('authorization_code') }) it('includes state in token request when includeStateInTokenRequest is true', async () => { let capturedBody: string | null = null vi.stubGlobal('fetch', vi.fn().mockImplementation((_url: string, opts: RequestInit) => { capturedBody = opts.body as string return Promise.resolve({ ok: true, text: async () => JSON.stringify({ access_token: 'tk' }) }) })) const cfg = { ...minimalCfg, includeStateInTokenRequest: true, tokenRequestFormat: 'json' as const } await exchangeCodeForTokens(cfg, 'mycode', 'mystate', 'v', 'https://r') const parsed = JSON.parse(capturedBody!) expect(parsed.state).toBe('mystate') }) it('does NOT include state when includeStateInTokenRequest is false', async () => { let capturedBody: string | null = null vi.stubGlobal('fetch', vi.fn().mockImplementation((_url: string, opts: RequestInit) => { capturedBody = opts.body as string return Promise.resolve({ ok: true, text: async () => JSON.stringify({ access_token: 'tk' }) }) })) const cfg = { ...minimalCfg, tokenRequestFormat: 'json' as const } await exchangeCodeForTokens(cfg, 'c', 'mystate', 'v', 'https://r') const parsed = JSON.parse(capturedBody!) expect(parsed.state).toBeUndefined() }) })