-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauth.test.ts
More file actions
189 lines (166 loc) · 7.64 KB
/
Copy pathauth.test.ts
File metadata and controls
189 lines (166 loc) · 7.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
beginSignIn,
buildAuthorizeUrl,
buildLogoutUrl,
CallbackError,
completeSignIn,
exchangeCodeForTokens,
generateCodeChallenge,
generateCodeVerifier,
generateState,
TokenExchangeError,
type AuthConfig,
} from '../src/lib/auth';
const CONFIG: AuthConfig = {
domain: 'openjobradar-dev.auth.us-east-1.amazoncognito.com',
clientId: 'client-abc123',
redirectUri: 'http://localhost:4321/callback',
logoutUri: 'http://localhost:4321/',
};
beforeEach(() => {
sessionStorage.clear();
});
describe('generateCodeVerifier', () => {
it('produces a string within RFC 7636 length bounds using only the allowed charset', () => {
const verifier = generateCodeVerifier();
expect(verifier.length).toBe(64);
expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/);
});
it('rejects lengths outside 43-128', () => {
expect(() => generateCodeVerifier(42)).toThrow(RangeError);
expect(() => generateCodeVerifier(129)).toThrow(RangeError);
});
it('is different on every call (not a fixed/predictable value)', () => {
const a = generateCodeVerifier();
const b = generateCodeVerifier();
expect(a).not.toBe(b);
});
});
describe('generateCodeChallenge', () => {
// RFC 7636 Appendix B's own worked example — the strongest possible test for this function.
it('matches the RFC 7636 Appendix B test vector exactly', async () => {
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
const challenge = await generateCodeChallenge(verifier);
expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
});
it('never contains standard-base64 characters (+, /, or padding)', async () => {
const challenge = await generateCodeChallenge(generateCodeVerifier());
expect(challenge).not.toMatch(/[+/=]/);
});
});
describe('generateState', () => {
it('is different on every call', () => {
expect(generateState()).not.toBe(generateState());
});
});
describe('buildAuthorizeUrl', () => {
it('includes every required PKCE and OAuth parameter', () => {
const url = new URL(buildAuthorizeUrl(CONFIG, 'the-challenge', 'the-state'));
expect(url.origin + url.pathname).toBe('https://openjobradar-dev.auth.us-east-1.amazoncognito.com/oauth2/authorize');
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('client_id')).toBe('client-abc123');
expect(url.searchParams.get('redirect_uri')).toBe(CONFIG.redirectUri);
expect(url.searchParams.get('code_challenge')).toBe('the-challenge');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('state')).toBe('the-state');
expect(url.searchParams.get('scope')).toBe('openid email profile');
});
it('honors custom scopes when provided', () => {
const url = new URL(buildAuthorizeUrl({ ...CONFIG, scopes: ['openid'] }, 'c', 's'));
expect(url.searchParams.get('scope')).toBe('openid');
});
});
describe('buildLogoutUrl', () => {
it('points at the Hosted UI logout endpoint with client_id and logout_uri', () => {
const url = new URL(buildLogoutUrl(CONFIG));
expect(url.origin + url.pathname).toBe('https://openjobradar-dev.auth.us-east-1.amazoncognito.com/logout');
expect(url.searchParams.get('client_id')).toBe('client-abc123');
expect(url.searchParams.get('logout_uri')).toBe(CONFIG.logoutUri);
});
});
describe('exchangeCodeForTokens', () => {
it('POSTs the correct grant_type/body shape and returns parsed tokens', async () => {
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = new URLSearchParams(init.body as string);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('client_id')).toBe('client-abc123');
expect(body.get('code')).toBe('auth-code-xyz');
expect(body.get('code_verifier')).toBe('verifier-123');
expect(init.headers).toMatchObject({ 'Content-Type': 'application/x-www-form-urlencoded' });
return new Response(JSON.stringify({ access_token: 'a', id_token: 'i', token_type: 'Bearer', expires_in: 3600 }), {
status: 200,
});
});
const tokens = await exchangeCodeForTokens(CONFIG, 'auth-code-xyz', 'verifier-123', fetchMock as unknown as typeof fetch);
expect(tokens.access_token).toBe('a');
expect(tokens.id_token).toBe('i');
expect(fetchMock).toHaveBeenCalledWith(
'https://openjobradar-dev.auth.us-east-1.amazoncognito.com/oauth2/token',
expect.anything(),
);
});
it('fails closed on a non-2xx response', async () => {
const fetchMock = vi.fn(async () => new Response('invalid_grant', { status: 400 }));
await expect(
exchangeCodeForTokens(CONFIG, 'code', 'verifier', fetchMock as unknown as typeof fetch),
).rejects.toThrow(TokenExchangeError);
});
it('fails closed when the response is missing tokens', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
await expect(
exchangeCodeForTokens(CONFIG, 'code', 'verifier', fetchMock as unknown as typeof fetch),
).rejects.toThrow(TokenExchangeError);
});
});
describe('beginSignIn + completeSignIn (full round trip)', () => {
it('completes successfully when the callback carries the matching code and state', async () => {
const authorizeUrl = await beginSignIn(CONFIG);
const state = new URL(authorizeUrl).searchParams.get('state')!;
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ access_token: 'a', id_token: 'i', token_type: 'Bearer', expires_in: 3600 }), {
status: 200,
}),
);
const callbackUrl = `http://localhost:4321/callback?code=xyz&state=${state}`;
const tokens = await completeSignIn(CONFIG, callbackUrl, fetchMock as unknown as typeof fetch);
expect(tokens.access_token).toBe('a');
});
it('clears PKCE state after completion so it cannot be replayed', async () => {
const authorizeUrl = await beginSignIn(CONFIG);
const state = new URL(authorizeUrl).searchParams.get('state')!;
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ access_token: 'a', id_token: 'i', token_type: 'Bearer', expires_in: 3600 }), {
status: 200,
}),
);
await completeSignIn(CONFIG, `http://localhost:4321/callback?code=xyz&state=${state}`, fetchMock as unknown as typeof fetch);
await expect(
completeSignIn(CONFIG, `http://localhost:4321/callback?code=xyz&state=${state}`, fetchMock as unknown as typeof fetch),
).rejects.toThrow(CallbackError);
});
it('rejects a callback whose state does not match what was stored', async () => {
await beginSignIn(CONFIG);
await expect(
completeSignIn(CONFIG, 'http://localhost:4321/callback?code=xyz&state=forged-state'),
).rejects.toThrow(/state mismatch/);
});
it('rejects a callback with no prior beginSignIn call in this session', async () => {
await expect(
completeSignIn(CONFIG, 'http://localhost:4321/callback?code=xyz&state=whatever'),
).rejects.toThrow(CallbackError);
});
it('surfaces an authorization error from Cognito instead of a generic failure', async () => {
await beginSignIn(CONFIG);
await expect(
completeSignIn(CONFIG, 'http://localhost:4321/callback?error=access_denied&error_description=User+cancelled'),
).rejects.toThrow(/access_denied/);
});
it('rejects a callback with no code at all', async () => {
const authorizeUrl = await beginSignIn(CONFIG);
const state = new URL(authorizeUrl).searchParams.get('state')!;
await expect(
completeSignIn(CONFIG, `http://localhost:4321/callback?state=${state}`),
).rejects.toThrow(/authorization code/);
});
});