Initial Commit
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user