284 lines
15 KiB
TypeScript
284 lines
15 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('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();
|
|
});
|
|
}); |