import { describe, expect, test } from 'bun:test'; import { createHash } from 'node:crypto'; import { exportJWK, generateKeyPair, SignJWT, type KeyInput, } from 'jose'; import { EveSso } from '../src/lib/server/sso'; import { oauthFailureDiagnostic, SsoDiagnosticError } from '../src/lib/server/oauth-diagnostics'; const NOW = 1_800_000_000_000; const NOW_SECONDS = NOW / 1000; const DISCOVERY = 'https://login.eveonline.com/.well-known/oauth-authorization-server'; const TOKEN_ENDPOINT = 'https://login.eveonline.com/v2/oauth/token'; const JWKS_ENDPOINT = 'https://login.eveonline.com/oauth/jwks'; const metadata = { issuer: 'https://login.eveonline.com', authorization_endpoint: 'https://login.eveonline.com/v2/oauth/authorize', token_endpoint: TOKEN_ENDPOINT, jwks_uri: JWKS_ENDPOINT, code_challenge_methods_supported: ['S256'], }; type Call = { url: string; init?: RequestInit }; function json(value: unknown, status = 200): Response { return new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' }, }); } function mockFetch(tokenResponse: unknown, jwks: unknown = { keys: [] }) { const calls: Call[] = []; const fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const url = String(input); calls.push({ url, init }); if (url === DISCOVERY) return json(metadata); if (url === JWKS_ENDPOINT) return json(jwks); if (url === TOKEN_ENDPOINT) return json(tokenResponse); return json({ error: 'not found' }, 404); }; return { fetch, calls }; } async function signedToken( privateKey: KeyInput, overrides: Record = {}, lifetimeSeconds = 1200, ): Promise { return new SignJWT({ name: 'Capsuleer Example', owner: 'owner-stable-value', scp: ['esi-assets.read_assets.v1'], aud: ['client-123', 'EVE Online'], ...overrides, }) .setProtectedHeader({ alg: 'RS256', kid: 'fixture-key' }) .setIssuer('https://login.eveonline.com') .setSubject('CHARACTER:EVE:90000001') .setIssuedAt(NOW_SECONDS) .setExpirationTime(NOW_SECONDS + lifetimeSeconds) .sign(privateKey); } function sso(input: { fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; verificationKey?: KeyInput; clientSecret?: string; now?: () => number; }) { return new EveSso({ clientId: 'client-123', clientSecret: input.clientSecret, redirectUri: 'https://app.example/auth/eve/callback', userAgent: 'eve-intelligence-test/1.0', fetch: input.fetch as typeof fetch, now: input.now ?? (() => NOW), verificationKey: input.verificationKey, }); } describe('EVE SSO adapter', () => { test('builds a discovered authorization URL with only the assets S256 grant', async () => { const fixture = mockFetch({}); const url = new URL(await sso(fixture).authorizationUrl('csrf value', 'pkce-challenge')); expect(url.origin).toBe('https://login.eveonline.com'); expect(url.pathname).toBe('/v2/oauth/authorize'); expect(Object.fromEntries(url.searchParams)).toEqual({ response_type: 'code', client_id: 'client-123', redirect_uri: 'https://app.example/auth/eve/callback', scope: 'esi-assets.read_assets.v1', state: 'csrf value', code_challenge: 'pkce-challenge', code_challenge_method: 'S256', }); expect(fixture.calls).toHaveLength(1); expect(fixture.calls[0]!.init?.redirect).toBe('error'); expect(fixture.calls[0]!.init?.signal).toBeInstanceOf(AbortSignal); }); test('refreshes cached discovery metadata after its bounded TTL', async () => { let clock = NOW; const fixture = mockFetch({}); const client = sso({ fetch: fixture.fetch, now: () => clock }); await client.authorizationUrl('state-1', 'challenge-1'); clock += 10 * 60_000; await client.authorizationUrl('state-2', 'challenge-2'); expect(fixture.calls.filter((call) => call.url === DISCOVERY)).toHaveLength(2); }); test('exchanges a confidential code using Basic and registered redirect URI', async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); const accessToken = await signedToken(privateKey); const fixture = mockFetch({ token_type: 'Bearer', access_token: accessToken, refresh_token: 'refresh-1', expires_in: 1200 }); const grant = await sso({ fetch: fixture.fetch, verificationKey: publicKey, clientSecret: 'secret-value' }).exchange('one-time-code', 'verifier'); const request = fixture.calls.find((call) => call.url === TOKEN_ENDPOINT)!; const body = new URLSearchParams(String(request.init?.body)); expect(request.init?.method).toBe('POST'); expect(request.init?.redirect).toBe('error'); expect(request.init?.signal).toBeInstanceOf(AbortSignal); expect(request.init?.headers).toMatchObject({ authorization: `Basic ${Buffer.from('client-123:secret-value').toString('base64')}`, 'content-type': 'application/x-www-form-urlencoded', }); expect(Object.fromEntries(body)).toEqual({ grant_type: 'authorization_code', code: 'one-time-code', redirect_uri: 'https://app.example/auth/eve/callback', code_verifier: 'verifier', }); expect(grant).toEqual({ characterId: 90000001, name: 'Capsuleer Example', ownerHash: createHash('sha256').update('owner-stable-value').digest('hex'), scopes: ['esi-assets.read_assets.v1'], accessToken, refreshToken: 'refresh-1', expiresAt: NOW + 1_200_000, }); }); test('caps local freshness at the verified JWT expiration', async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); const accessToken = await signedToken(privateKey, {}, 30); const fixture = mockFetch({ token_type: 'Bearer', access_token: accessToken, refresh_token: 'refresh-short', expires_in: 1200 }); const grant = await sso({ fetch: fixture.fetch, verificationKey: publicKey }).exchange('code', 'verifier'); expect(grant.expiresAt).toBe(NOW + 30_000); }); test('exchanges a public code with client_id and verifies a discovered JWKS', async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); const jwk = await exportJWK(publicKey); const accessToken = await signedToken(privateKey); const fixture = mockFetch( { token_type: 'Bearer', access_token: accessToken, refresh_token: 'refresh-public', expires_in: 900 }, { keys: [{ ...jwk, kid: 'fixture-key', alg: 'RS256', use: 'sig' }] }, ); const grant = await sso({ fetch: fixture.fetch }).exchange('code', 'verifier'); const request = fixture.calls.find((call) => call.url === TOKEN_ENDPOINT)!; const body = new URLSearchParams(String(request.init?.body)); expect(request.init?.redirect).toBe('error'); expect(request.init?.signal).toBeInstanceOf(AbortSignal); expect(request.init?.headers).not.toHaveProperty('authorization'); expect(body.get('client_id')).toBe('client-123'); const jwksCall = fixture.calls.find((call) => call.url === JWKS_ENDPOINT)!; expect(jwksCall.init?.redirect).toBe('error'); expect(jwksCall.init?.signal).toBeInstanceOf(AbortSignal); expect(fixture.calls.filter((call) => call.url === JWKS_ENDPOINT)).toHaveLength(1); expect(grant.refreshToken).toBe('refresh-public'); }); test('refreshes with a rotated refresh token and verifies the replacement grant', async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); const accessToken = await signedToken(privateKey, { name: 'Refreshed Capsuleer' }); const fixture = mockFetch({ token_type: 'Bearer', access_token: accessToken, refresh_token: 'refresh-rotated', expires_in: 300 }); const grant = await sso({ fetch: fixture.fetch, verificationKey: publicKey, clientSecret: 'secret-value' }).refresh('refresh-old'); const request = fixture.calls.find((call) => call.url === TOKEN_ENDPOINT)!; expect(new URLSearchParams(String(request.init?.body))).toEqual(new URLSearchParams({ grant_type: 'refresh_token', refresh_token: 'refresh-old', })); expect(grant.refreshToken).toBe('refresh-rotated'); expect(grant.name).toBe('Refreshed Capsuleer'); }); test('normalizes a singleton scope claim for both login and refresh without expanding permissions', async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); const scope = 'esi-assets.read_assets.v1'; const accessToken = await signedToken(privateKey, { scp: scope }); const fixture = mockFetch({ token_type: 'Bearer', access_token: accessToken, refresh_token: 'refresh', expires_in: 1200 }); const client = sso({ fetch: fixture.fetch, verificationKey: publicKey }); expect((await client.exchange('code', 'verifier')).scopes).toEqual([scope]); expect((await client.refresh('refresh')).scopes).toEqual([scope]); }); test('rejects missing, mixed, empty, and ambiguous scope claims', async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); for (const scp of [undefined, null, 123, {}, [123], '', [''], 'scope-one scope-two', ['scope\ninvalid']]) { const accessToken = await signedToken(privateKey, { scp }); const fixture = mockFetch({ token_type: 'Bearer', access_token: accessToken, refresh_token: 'refresh', expires_in: 1200 }); await expect(sso({ fetch: fixture.fetch, verificationKey: publicKey }).exchange('code', 'verifier')).rejects.toThrow('Invalid EVE SSO granted scopes'); } }); test('reports token rejection categories without retaining provider descriptions or retrying codes', async () => { const secret = 'private-provider-response-canary'; for (const providerError of ['invalid_client', secret]) { let calls = 0; const fetch = async (input: RequestInfo | URL) => { if (String(input) === DISCOVERY) return json(metadata); calls++; return json({ error: providerError, error_description: secret, access_token: secret }, 401); }; const failure = await sso({ fetch }).exchange('private-code', 'private-verifier').catch((error: unknown) => error); expect(failure).toBeInstanceOf(SsoDiagnosticError); expect(calls).toBe(1); expect(String(failure)).toBe('Error: EVE SSO exchange failed'); expect(JSON.stringify(failure)).not.toContain(secret); const diagnostic = oauthFailureDiagnostic('exchange', failure); expect(diagnostic).toMatchObject({ reason: 'token_request', httpStatus: 401 }); if (providerError === 'invalid_client') expect(diagnostic.oauthError).toBe('invalid_client'); else expect(diagnostic).not.toHaveProperty('oauthError'); } }); test('reports failed JWT claim names without retaining tokens or payloads', async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); const accessToken = await signedToken(privateKey, { aud: ['wrong-client', 'EVE Online'] }); const fixture = mockFetch({ token_type: 'Bearer', access_token: accessToken, refresh_token: 'private-refresh-canary', expires_in: 1200 }); const failure = await sso({ fetch: fixture.fetch, verificationKey: publicKey }).exchange('code', 'verifier').catch((error: unknown) => error); expect(oauthFailureDiagnostic('exchange', failure)).toMatchObject({ reason: 'jwt_validation', joseCode: 'ERR_JWT_CLAIM_VALIDATION_FAILED', claim: 'aud' }); expect(JSON.stringify(failure)).not.toContain(accessToken); expect(JSON.stringify(failure)).not.toContain('private-refresh-canary'); expect(JSON.stringify(failure)).not.toContain('Capsuleer Example'); }); test('rejects token responses that are not Bearer grants', async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); const accessToken = await signedToken(privateKey); const fixture = mockFetch({ token_type: 'Basic', access_token: accessToken, refresh_token: 'refresh', expires_in: 1200 }); await expect(sso({ fetch: fixture.fetch, verificationKey: publicKey }).exchange('code', 'verifier')).rejects.toThrow(); }); test('rejects invalid JWT claims without exposing token material', async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); const cases: Record> = { audience: { aud: ['other-client', 'EVE Online'] }, issuer: { iss: 'https://evil.example' }, subject: { sub: 'CHARACTER:EVE:9007199254740993' }, azp: { azp: 'other-client' }, owner: { owner: undefined }, }; for (const [name, claims] of Object.entries(cases)) { const accessToken = name === 'issuer' ? await new SignJWT({ name: 'Capsuleer Example', owner: 'owner-stable-value', scp: [], aud: ['client-123', 'EVE Online'] }) .setProtectedHeader({ alg: 'RS256', kid: 'fixture-key' }).setIssuer('https://evil.example') .setSubject('CHARACTER:EVE:90000001').setIssuedAt(NOW_SECONDS).setExpirationTime(NOW_SECONDS + 1200).sign(privateKey) : name === 'subject' ? await new SignJWT({ name: 'Capsuleer Example', owner: 'owner-stable-value', scp: [], aud: ['client-123', 'EVE Online'] }) .setProtectedHeader({ alg: 'RS256', kid: 'fixture-key' }).setIssuer('https://login.eveonline.com') .setSubject(String(claims.sub)).setIssuedAt(NOW_SECONDS).setExpirationTime(NOW_SECONDS + 1200).sign(privateKey) : await signedToken(privateKey, claims); const fixture = mockFetch({ token_type: 'Bearer', access_token: accessToken, refresh_token: 'refresh', expires_in: 1200 }); await expect(sso({ fetch: fixture.fetch, verificationKey: publicKey }).exchange('code', 'verifier')).rejects.toThrow(); } }); test('rejects expired and incorrectly signed tokens, and does not retry the token call', async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); const wrong = await generateKeyPair('RS256'); const expired = await new SignJWT({ name: 'Capsuleer Example', owner: 'owner-stable-value', scp: [], aud: ['client-123', 'EVE Online'] }) .setProtectedHeader({ alg: 'RS256', kid: 'fixture-key' }).setIssuer('https://login.eveonline.com') .setSubject('CHARACTER:EVE:90000001').setIssuedAt(NOW_SECONDS - 2000).setExpirationTime(NOW_SECONDS - 1).sign(privateKey); const fixture = mockFetch({ token_type: 'Bearer', access_token: expired, refresh_token: 'refresh', expires_in: 1200 }); await expect(sso({ fetch: fixture.fetch, verificationKey: publicKey }).exchange('code', 'verifier')).rejects.toThrow(); expect(fixture.calls.filter((call) => call.url === TOKEN_ENDPOINT)).toHaveLength(1); const badSignature = await signedToken(wrong.privateKey); const badFixture = mockFetch({ token_type: 'Bearer', access_token: badSignature, refresh_token: 'refresh', expires_in: 1200 }); await expect(sso({ fetch: badFixture.fetch, verificationKey: publicKey }).exchange('code', 'verifier')).rejects.toThrow(); }); });