Files

302 lines
16 KiB
TypeScript

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<Response> => {
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<string, unknown> = {},
lifetimeSeconds = 1200,
): Promise<string> {
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<Response>;
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('uses the server-selected capability scope list without expanding it', async () => {
const fixture = mockFetch({});
const url = new URL(await sso(fixture).authorizationUrl('state', 'challenge', [
'esi-assets.read_assets.v1',
'esi-wallet.read_character_wallet.v1',
]));
expect(url.searchParams.get('scope')).toBe('esi-assets.read_assets.v1 esi-wallet.read_character_wallet.v1');
});
test('rejects duplicate, empty and non-allowlisted authorization scopes', async () => {
const fixture = mockFetch({});
const client = sso(fixture);
await expect(client.authorizationUrl('state', 'challenge', [])).rejects.toThrow('Invalid EVE SSO requested scopes');
await expect(client.authorizationUrl('state', 'challenge', ['esi-assets.read_assets.v1', 'esi-assets.read_assets.v1'])).rejects.toThrow('Invalid EVE SSO requested scopes');
await expect(client.authorizationUrl('state', 'challenge', ['esi-evil.read_everything.v1'])).rejects.toThrow('Invalid EVE SSO requested scopes');
expect(fixture.calls).toHaveLength(0);
});
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<string, Record<string, unknown>> = {
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();
});
});