tlc-claude-code 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/dist/components/AuditPane.d.ts +30 -0
- package/dashboard/dist/components/AuditPane.js +127 -0
- package/dashboard/dist/components/AuditPane.test.d.ts +1 -0
- package/dashboard/dist/components/AuditPane.test.js +339 -0
- package/dashboard/dist/components/CompliancePane.d.ts +39 -0
- package/dashboard/dist/components/CompliancePane.js +96 -0
- package/dashboard/dist/components/CompliancePane.test.d.ts +1 -0
- package/dashboard/dist/components/CompliancePane.test.js +183 -0
- package/dashboard/dist/components/SSOPane.d.ts +36 -0
- package/dashboard/dist/components/SSOPane.js +71 -0
- package/dashboard/dist/components/SSOPane.test.d.ts +1 -0
- package/dashboard/dist/components/SSOPane.test.js +155 -0
- package/dashboard/dist/components/WorkspaceDocsPane.js +0 -16
- package/dashboard/dist/components/WorkspacePane.d.ts +1 -1
- package/dashboard/dist/components/ZeroRetentionPane.d.ts +44 -0
- package/dashboard/dist/components/ZeroRetentionPane.js +83 -0
- package/dashboard/dist/components/ZeroRetentionPane.test.d.ts +1 -0
- package/dashboard/dist/components/ZeroRetentionPane.test.js +160 -0
- package/package.json +1 -1
- package/server/lib/access-control-doc.js +541 -0
- package/server/lib/access-control-doc.test.js +672 -0
- package/server/lib/adr-generator.js +423 -0
- package/server/lib/adr-generator.test.js +586 -0
- package/server/lib/agent-progress-monitor.js +223 -0
- package/server/lib/agent-progress-monitor.test.js +202 -0
- package/server/lib/audit-attribution.js +191 -0
- package/server/lib/audit-attribution.test.js +359 -0
- package/server/lib/audit-classifier.js +202 -0
- package/server/lib/audit-classifier.test.js +209 -0
- package/server/lib/audit-command.js +275 -0
- package/server/lib/audit-command.test.js +325 -0
- package/server/lib/audit-exporter.js +380 -0
- package/server/lib/audit-exporter.test.js +464 -0
- package/server/lib/audit-logger.js +236 -0
- package/server/lib/audit-logger.test.js +364 -0
- package/server/lib/audit-query.js +257 -0
- package/server/lib/audit-query.test.js +352 -0
- package/server/lib/audit-storage.js +269 -0
- package/server/lib/audit-storage.test.js +272 -0
- package/server/lib/bulk-repo-init.js +342 -0
- package/server/lib/bulk-repo-init.test.js +388 -0
- package/server/lib/compliance-checklist.js +866 -0
- package/server/lib/compliance-checklist.test.js +476 -0
- package/server/lib/compliance-command.js +616 -0
- package/server/lib/compliance-command.test.js +551 -0
- package/server/lib/compliance-reporter.js +692 -0
- package/server/lib/compliance-reporter.test.js +707 -0
- package/server/lib/data-flow-doc.js +665 -0
- package/server/lib/data-flow-doc.test.js +659 -0
- package/server/lib/ephemeral-storage.js +249 -0
- package/server/lib/ephemeral-storage.test.js +254 -0
- package/server/lib/evidence-collector.js +627 -0
- package/server/lib/evidence-collector.test.js +901 -0
- package/server/lib/flow-diagram-generator.js +474 -0
- package/server/lib/flow-diagram-generator.test.js +446 -0
- package/server/lib/idp-manager.js +626 -0
- package/server/lib/idp-manager.test.js +587 -0
- package/server/lib/memory-exclusion.js +326 -0
- package/server/lib/memory-exclusion.test.js +241 -0
- package/server/lib/mfa-handler.js +452 -0
- package/server/lib/mfa-handler.test.js +490 -0
- package/server/lib/oauth-flow.js +375 -0
- package/server/lib/oauth-flow.test.js +487 -0
- package/server/lib/oauth-registry.js +190 -0
- package/server/lib/oauth-registry.test.js +306 -0
- package/server/lib/readme-generator.js +490 -0
- package/server/lib/readme-generator.test.js +493 -0
- package/server/lib/repo-dependency-tracker.js +261 -0
- package/server/lib/repo-dependency-tracker.test.js +350 -0
- package/server/lib/retention-policy.js +281 -0
- package/server/lib/retention-policy.test.js +486 -0
- package/server/lib/role-mapper.js +236 -0
- package/server/lib/role-mapper.test.js +395 -0
- package/server/lib/saml-provider.js +765 -0
- package/server/lib/saml-provider.test.js +643 -0
- package/server/lib/security-policy-generator.js +682 -0
- package/server/lib/security-policy-generator.test.js +544 -0
- package/server/lib/sensitive-detector.js +112 -0
- package/server/lib/sensitive-detector.test.js +209 -0
- package/server/lib/service-interaction-diagram.js +700 -0
- package/server/lib/service-interaction-diagram.test.js +638 -0
- package/server/lib/service-summary.js +553 -0
- package/server/lib/service-summary.test.js +619 -0
- package/server/lib/session-purge.js +460 -0
- package/server/lib/session-purge.test.js +312 -0
- package/server/lib/sso-command.js +544 -0
- package/server/lib/sso-command.test.js +552 -0
- package/server/lib/sso-session.js +492 -0
- package/server/lib/sso-session.test.js +670 -0
- package/server/lib/workspace-command.js +249 -0
- package/server/lib/workspace-command.test.js +264 -0
- package/server/lib/workspace-config.js +270 -0
- package/server/lib/workspace-config.test.js +312 -0
- package/server/lib/workspace-docs-command.js +547 -0
- package/server/lib/workspace-docs-command.test.js +692 -0
- package/server/lib/workspace-memory.js +451 -0
- package/server/lib/workspace-memory.test.js +403 -0
- package/server/lib/workspace-scanner.js +452 -0
- package/server/lib/workspace-scanner.test.js +677 -0
- package/server/lib/workspace-test-runner.js +315 -0
- package/server/lib/workspace-test-runner.test.js +294 -0
- package/server/lib/zero-retention-command.js +439 -0
- package/server/lib/zero-retention-command.test.js +448 -0
- package/server/lib/zero-retention.js +322 -0
- package/server/lib/zero-retention.test.js +258 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createOAuthFlow,
|
|
4
|
+
generateState,
|
|
5
|
+
validateState,
|
|
6
|
+
generatePKCE,
|
|
7
|
+
} from './oauth-flow.js';
|
|
8
|
+
import { createOAuthRegistry } from './oauth-registry.js';
|
|
9
|
+
|
|
10
|
+
describe('oauth-flow', () => {
|
|
11
|
+
let registry;
|
|
12
|
+
let oauthFlow;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.useFakeTimers();
|
|
16
|
+
vi.setSystemTime(new Date('2026-02-02T12:00:00Z'));
|
|
17
|
+
|
|
18
|
+
registry = createOAuthRegistry();
|
|
19
|
+
registry.registerProvider('github', {
|
|
20
|
+
clientId: 'test-github-client',
|
|
21
|
+
clientSecret: 'test-github-secret',
|
|
22
|
+
});
|
|
23
|
+
registry.registerProvider('google', {
|
|
24
|
+
clientId: 'test-google-client',
|
|
25
|
+
clientSecret: 'test-google-secret',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
oauthFlow = createOAuthFlow(registry);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.useRealTimers();
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('generateState', () => {
|
|
37
|
+
it('creates state with nonce and timestamp', () => {
|
|
38
|
+
const state = generateState('github');
|
|
39
|
+
const decoded = JSON.parse(Buffer.from(state, 'base64').toString('utf-8'));
|
|
40
|
+
|
|
41
|
+
expect(decoded.nonce).toBeDefined();
|
|
42
|
+
expect(decoded.nonce).toHaveLength(32); // 16 bytes hex = 32 chars
|
|
43
|
+
expect(decoded.provider).toBe('github');
|
|
44
|
+
expect(decoded.timestamp).toBe(Date.now());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('includes code verifier for PKCE', () => {
|
|
48
|
+
const state = generateState('github', { usePKCE: true });
|
|
49
|
+
const decoded = JSON.parse(Buffer.from(state, 'base64').toString('utf-8'));
|
|
50
|
+
|
|
51
|
+
expect(decoded.codeVerifier).toBeDefined();
|
|
52
|
+
expect(decoded.codeVerifier.length).toBeGreaterThanOrEqual(43);
|
|
53
|
+
expect(decoded.codeVerifier.length).toBeLessThanOrEqual(128);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('generates unique states each time', () => {
|
|
57
|
+
const state1 = generateState('github');
|
|
58
|
+
const state2 = generateState('github');
|
|
59
|
+
|
|
60
|
+
expect(state1).not.toBe(state2);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('validateState', () => {
|
|
65
|
+
it('returns valid for fresh state', () => {
|
|
66
|
+
const state = generateState('github');
|
|
67
|
+
const result = validateState(state, 'github');
|
|
68
|
+
|
|
69
|
+
expect(result.valid).toBe(true);
|
|
70
|
+
expect(result.provider).toBe('github');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('detects state mismatch (CSRF protection)', () => {
|
|
74
|
+
const state = generateState('github');
|
|
75
|
+
const result = validateState(state, 'google'); // Different provider
|
|
76
|
+
|
|
77
|
+
expect(result.valid).toBe(false);
|
|
78
|
+
expect(result.error).toMatch(/provider mismatch/i);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('detects expired state', () => {
|
|
82
|
+
const state = generateState('github');
|
|
83
|
+
|
|
84
|
+
// Advance time by 11 minutes (default expiry is 10 minutes)
|
|
85
|
+
vi.advanceTimersByTime(11 * 60 * 1000);
|
|
86
|
+
|
|
87
|
+
const result = validateState(state, 'github');
|
|
88
|
+
|
|
89
|
+
expect(result.valid).toBe(false);
|
|
90
|
+
expect(result.error).toMatch(/expired/i);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('allows custom expiry time', () => {
|
|
94
|
+
const state = generateState('github');
|
|
95
|
+
|
|
96
|
+
// Advance time by 5 minutes
|
|
97
|
+
vi.advanceTimersByTime(5 * 60 * 1000);
|
|
98
|
+
|
|
99
|
+
// With 3 minute expiry, should be expired
|
|
100
|
+
const result = validateState(state, 'github', { maxAgeMs: 3 * 60 * 1000 });
|
|
101
|
+
|
|
102
|
+
expect(result.valid).toBe(false);
|
|
103
|
+
expect(result.error).toMatch(/expired/i);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('rejects malformed state', () => {
|
|
107
|
+
const result = validateState('not-valid-base64!!!', 'github');
|
|
108
|
+
|
|
109
|
+
expect(result.valid).toBe(false);
|
|
110
|
+
expect(result.error).toMatch(/invalid state/i);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns code verifier when present', () => {
|
|
114
|
+
const state = generateState('github', { usePKCE: true });
|
|
115
|
+
const result = validateState(state, 'github');
|
|
116
|
+
|
|
117
|
+
expect(result.valid).toBe(true);
|
|
118
|
+
expect(result.codeVerifier).toBeDefined();
|
|
119
|
+
expect(result.codeVerifier.length).toBeGreaterThanOrEqual(43);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('generatePKCE', () => {
|
|
124
|
+
it('generates code verifier of valid length', () => {
|
|
125
|
+
const pkce = generatePKCE();
|
|
126
|
+
|
|
127
|
+
expect(pkce.codeVerifier).toBeDefined();
|
|
128
|
+
expect(pkce.codeVerifier.length).toBeGreaterThanOrEqual(43);
|
|
129
|
+
expect(pkce.codeVerifier.length).toBeLessThanOrEqual(128);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('generates code challenge using S256', () => {
|
|
133
|
+
const pkce = generatePKCE();
|
|
134
|
+
|
|
135
|
+
expect(pkce.codeChallenge).toBeDefined();
|
|
136
|
+
expect(pkce.codeChallengeMethod).toBe('S256');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('generates base64url encoded challenge', () => {
|
|
140
|
+
const pkce = generatePKCE();
|
|
141
|
+
|
|
142
|
+
// base64url should not contain + / =
|
|
143
|
+
expect(pkce.codeChallenge).not.toMatch(/[+/=]/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('generates unique PKCE values each time', () => {
|
|
147
|
+
const pkce1 = generatePKCE();
|
|
148
|
+
const pkce2 = generatePKCE();
|
|
149
|
+
|
|
150
|
+
expect(pkce1.codeVerifier).not.toBe(pkce2.codeVerifier);
|
|
151
|
+
expect(pkce1.codeChallenge).not.toBe(pkce2.codeChallenge);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('getAuthorizationUrl', () => {
|
|
156
|
+
it('generates valid URL with state', () => {
|
|
157
|
+
const { url, state } = oauthFlow.getAuthorizationUrl('github', {
|
|
158
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(url).toContain('https://github.com/login/oauth/authorize');
|
|
162
|
+
expect(url).toContain('client_id=test-github-client');
|
|
163
|
+
expect(url).toContain('redirect_uri=');
|
|
164
|
+
expect(url).toContain('state=');
|
|
165
|
+
expect(state).toBeDefined();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('includes scopes in URL', () => {
|
|
169
|
+
const { url } = oauthFlow.getAuthorizationUrl('github', {
|
|
170
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(url).toContain('scope=');
|
|
174
|
+
expect(url).toContain('read%3Auser'); // URL encoded read:user
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('allows custom scopes', () => {
|
|
178
|
+
const { url } = oauthFlow.getAuthorizationUrl('github', {
|
|
179
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
180
|
+
scopes: ['repo', 'user'],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(url).toContain('scope=repo+user');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('supports PKCE code challenge', () => {
|
|
187
|
+
const { url, state, codeVerifier } = oauthFlow.getAuthorizationUrl('github', {
|
|
188
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
189
|
+
usePKCE: true,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(url).toContain('code_challenge=');
|
|
193
|
+
expect(url).toContain('code_challenge_method=S256');
|
|
194
|
+
expect(codeVerifier).toBeDefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('throws for unknown provider', () => {
|
|
198
|
+
expect(() => oauthFlow.getAuthorizationUrl('unknown', {
|
|
199
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
200
|
+
})).toThrow(/provider not found/i);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('throws when redirectUri is missing', () => {
|
|
204
|
+
expect(() => oauthFlow.getAuthorizationUrl('github', {})).toThrow(/redirectUri/i);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('exchangeCode', () => {
|
|
209
|
+
beforeEach(() => {
|
|
210
|
+
global.fetch = vi.fn();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
afterEach(() => {
|
|
214
|
+
delete global.fetch;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('sends correct request to token endpoint', async () => {
|
|
218
|
+
global.fetch.mockResolvedValue({
|
|
219
|
+
ok: true,
|
|
220
|
+
json: () => Promise.resolve({
|
|
221
|
+
access_token: 'test-access-token',
|
|
222
|
+
token_type: 'Bearer',
|
|
223
|
+
scope: 'read:user user:email',
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await oauthFlow.exchangeCode('github', {
|
|
228
|
+
code: 'auth-code-123',
|
|
229
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
233
|
+
'https://github.com/login/oauth/access_token',
|
|
234
|
+
expect.objectContaining({
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: expect.objectContaining({
|
|
237
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
238
|
+
'Accept': 'application/json',
|
|
239
|
+
}),
|
|
240
|
+
})
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Check body contains required params
|
|
244
|
+
const callArgs = global.fetch.mock.calls[0];
|
|
245
|
+
const body = callArgs[1].body;
|
|
246
|
+
expect(body).toContain('grant_type=authorization_code');
|
|
247
|
+
expect(body).toContain('code=auth-code-123');
|
|
248
|
+
expect(body).toContain('client_id=test-github-client');
|
|
249
|
+
expect(body).toContain('client_secret=test-github-secret');
|
|
250
|
+
expect(body).toContain('redirect_uri=');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('returns access and refresh tokens', async () => {
|
|
254
|
+
global.fetch.mockResolvedValue({
|
|
255
|
+
ok: true,
|
|
256
|
+
json: () => Promise.resolve({
|
|
257
|
+
access_token: 'test-access-token',
|
|
258
|
+
refresh_token: 'test-refresh-token',
|
|
259
|
+
token_type: 'Bearer',
|
|
260
|
+
expires_in: 3600,
|
|
261
|
+
scope: 'read:user user:email',
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const result = await oauthFlow.exchangeCode('github', {
|
|
266
|
+
code: 'auth-code-123',
|
|
267
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(result.accessToken).toBe('test-access-token');
|
|
271
|
+
expect(result.refreshToken).toBe('test-refresh-token');
|
|
272
|
+
expect(result.tokenType).toBe('Bearer');
|
|
273
|
+
expect(result.expiresIn).toBe(3600);
|
|
274
|
+
expect(result.scope).toBe('read:user user:email');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('includes code verifier for PKCE', async () => {
|
|
278
|
+
global.fetch.mockResolvedValue({
|
|
279
|
+
ok: true,
|
|
280
|
+
json: () => Promise.resolve({
|
|
281
|
+
access_token: 'test-access-token',
|
|
282
|
+
token_type: 'Bearer',
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await oauthFlow.exchangeCode('github', {
|
|
287
|
+
code: 'auth-code-123',
|
|
288
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
289
|
+
codeVerifier: 'test-code-verifier-43-chars-minimum-length',
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const callArgs = global.fetch.mock.calls[0];
|
|
293
|
+
const body = callArgs[1].body;
|
|
294
|
+
expect(body).toContain('code_verifier=test-code-verifier-43-chars-minimum-length');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('handles error response', async () => {
|
|
298
|
+
global.fetch.mockResolvedValue({
|
|
299
|
+
ok: false,
|
|
300
|
+
status: 400,
|
|
301
|
+
json: () => Promise.resolve({
|
|
302
|
+
error: 'invalid_grant',
|
|
303
|
+
error_description: 'The authorization code has expired',
|
|
304
|
+
}),
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await expect(oauthFlow.exchangeCode('github', {
|
|
308
|
+
code: 'expired-code',
|
|
309
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
310
|
+
})).rejects.toThrow(/invalid_grant/);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('handles network errors', async () => {
|
|
314
|
+
global.fetch.mockRejectedValue(new Error('Network error'));
|
|
315
|
+
|
|
316
|
+
await expect(oauthFlow.exchangeCode('github', {
|
|
317
|
+
code: 'auth-code-123',
|
|
318
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
319
|
+
})).rejects.toThrow(/network error/i);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('refreshToken', () => {
|
|
324
|
+
beforeEach(() => {
|
|
325
|
+
global.fetch = vi.fn();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
afterEach(() => {
|
|
329
|
+
delete global.fetch;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('exchanges refresh token for new access token', async () => {
|
|
333
|
+
global.fetch.mockResolvedValue({
|
|
334
|
+
ok: true,
|
|
335
|
+
json: () => Promise.resolve({
|
|
336
|
+
access_token: 'new-access-token',
|
|
337
|
+
refresh_token: 'new-refresh-token',
|
|
338
|
+
token_type: 'Bearer',
|
|
339
|
+
expires_in: 3600,
|
|
340
|
+
}),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const result = await oauthFlow.refreshToken('github', {
|
|
344
|
+
refreshToken: 'old-refresh-token',
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(result.accessToken).toBe('new-access-token');
|
|
348
|
+
expect(result.refreshToken).toBe('new-refresh-token');
|
|
349
|
+
|
|
350
|
+
// Verify correct request
|
|
351
|
+
const callArgs = global.fetch.mock.calls[0];
|
|
352
|
+
const body = callArgs[1].body;
|
|
353
|
+
expect(body).toContain('grant_type=refresh_token');
|
|
354
|
+
expect(body).toContain('refresh_token=old-refresh-token');
|
|
355
|
+
expect(body).toContain('client_id=test-github-client');
|
|
356
|
+
expect(body).toContain('client_secret=test-github-secret');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('handles expired refresh token', async () => {
|
|
360
|
+
global.fetch.mockResolvedValue({
|
|
361
|
+
ok: false,
|
|
362
|
+
status: 400,
|
|
363
|
+
json: () => Promise.resolve({
|
|
364
|
+
error: 'invalid_grant',
|
|
365
|
+
error_description: 'Refresh token has expired',
|
|
366
|
+
}),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await expect(oauthFlow.refreshToken('github', {
|
|
370
|
+
refreshToken: 'expired-refresh-token',
|
|
371
|
+
})).rejects.toThrow(/invalid_grant/);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe('handleCallback', () => {
|
|
376
|
+
beforeEach(() => {
|
|
377
|
+
global.fetch = vi.fn();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
afterEach(() => {
|
|
381
|
+
delete global.fetch;
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('processes successful OAuth callback', async () => {
|
|
385
|
+
global.fetch.mockResolvedValue({
|
|
386
|
+
ok: true,
|
|
387
|
+
json: () => Promise.resolve({
|
|
388
|
+
access_token: 'callback-access-token',
|
|
389
|
+
refresh_token: 'callback-refresh-token',
|
|
390
|
+
token_type: 'Bearer',
|
|
391
|
+
expires_in: 3600,
|
|
392
|
+
}),
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const state = generateState('github');
|
|
396
|
+
const callbackParams = {
|
|
397
|
+
code: 'callback-auth-code',
|
|
398
|
+
state: state,
|
|
399
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const result = await oauthFlow.handleCallback('github', callbackParams);
|
|
403
|
+
|
|
404
|
+
expect(result.success).toBe(true);
|
|
405
|
+
expect(result.tokens.accessToken).toBe('callback-access-token');
|
|
406
|
+
expect(result.tokens.refreshToken).toBe('callback-refresh-token');
|
|
407
|
+
expect(result.provider).toBe('github');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('processes successful PKCE callback', async () => {
|
|
411
|
+
global.fetch.mockResolvedValue({
|
|
412
|
+
ok: true,
|
|
413
|
+
json: () => Promise.resolve({
|
|
414
|
+
access_token: 'pkce-access-token',
|
|
415
|
+
token_type: 'Bearer',
|
|
416
|
+
}),
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const state = generateState('github', { usePKCE: true });
|
|
420
|
+
const decoded = JSON.parse(Buffer.from(state, 'base64').toString('utf-8'));
|
|
421
|
+
|
|
422
|
+
const result = await oauthFlow.handleCallback('github', {
|
|
423
|
+
code: 'pkce-auth-code',
|
|
424
|
+
state: state,
|
|
425
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(result.success).toBe(true);
|
|
429
|
+
|
|
430
|
+
// Verify code_verifier was included in token exchange
|
|
431
|
+
const callArgs = global.fetch.mock.calls[0];
|
|
432
|
+
const body = callArgs[1].body;
|
|
433
|
+
expect(body).toContain('code_verifier=');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('rejects invalid state (CSRF protection)', async () => {
|
|
437
|
+
const validState = generateState('github');
|
|
438
|
+
const tamperedState = Buffer.from(JSON.stringify({
|
|
439
|
+
nonce: 'tampered',
|
|
440
|
+
provider: 'google', // Different provider
|
|
441
|
+
timestamp: Date.now(),
|
|
442
|
+
})).toString('base64');
|
|
443
|
+
|
|
444
|
+
const result = await oauthFlow.handleCallback('github', {
|
|
445
|
+
code: 'auth-code',
|
|
446
|
+
state: tamperedState,
|
|
447
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
expect(result.success).toBe(false);
|
|
451
|
+
expect(result.error).toMatch(/state/i);
|
|
452
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('rejects expired state', async () => {
|
|
456
|
+
const state = generateState('github');
|
|
457
|
+
|
|
458
|
+
// Advance time past expiry
|
|
459
|
+
vi.advanceTimersByTime(15 * 60 * 1000);
|
|
460
|
+
|
|
461
|
+
const result = await oauthFlow.handleCallback('github', {
|
|
462
|
+
code: 'auth-code',
|
|
463
|
+
state: state,
|
|
464
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
expect(result.success).toBe(false);
|
|
468
|
+
expect(result.error).toMatch(/expired/i);
|
|
469
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('handles OAuth error in callback', async () => {
|
|
473
|
+
const state = generateState('github');
|
|
474
|
+
|
|
475
|
+
const result = await oauthFlow.handleCallback('github', {
|
|
476
|
+
error: 'access_denied',
|
|
477
|
+
error_description: 'The user denied the request',
|
|
478
|
+
state: state,
|
|
479
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
expect(result.success).toBe(false);
|
|
483
|
+
expect(result.error).toMatch(/access_denied/);
|
|
484
|
+
expect(result.errorDescription).toMatch(/user denied/i);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Provider Registry
|
|
3
|
+
*
|
|
4
|
+
* Configures and manages OAuth 2.0 providers for authentication.
|
|
5
|
+
* Supports GitHub, Google, Azure AD with sensible defaults.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default configurations for common OAuth providers.
|
|
10
|
+
* Users only need to provide clientId and clientSecret.
|
|
11
|
+
*/
|
|
12
|
+
const PROVIDER_DEFAULTS = {
|
|
13
|
+
github: {
|
|
14
|
+
authUrl: 'https://github.com/login/oauth/authorize',
|
|
15
|
+
tokenUrl: 'https://github.com/login/oauth/access_token',
|
|
16
|
+
userInfoUrl: 'https://api.github.com/user',
|
|
17
|
+
scopes: ['read:user', 'user:email'],
|
|
18
|
+
},
|
|
19
|
+
google: {
|
|
20
|
+
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
21
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
22
|
+
userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo',
|
|
23
|
+
scopes: ['openid', 'email', 'profile'],
|
|
24
|
+
},
|
|
25
|
+
azuread: {
|
|
26
|
+
authUrl: 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize',
|
|
27
|
+
tokenUrl: 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token',
|
|
28
|
+
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
29
|
+
scopes: ['openid', 'email', 'profile'],
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validates provider configuration.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} config - Provider configuration
|
|
37
|
+
* @param {string} [providerName] - Optional provider name for defaults lookup
|
|
38
|
+
* @returns {string[]} Array of validation error messages (empty if valid)
|
|
39
|
+
*/
|
|
40
|
+
function validateProviderConfig(config, providerName) {
|
|
41
|
+
const errors = [];
|
|
42
|
+
|
|
43
|
+
if (!config) {
|
|
44
|
+
return ['config is required'];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!config.clientId) {
|
|
48
|
+
errors.push('clientId is required');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!config.clientSecret) {
|
|
52
|
+
errors.push('clientSecret is required');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check for authUrl and tokenUrl only if no defaults available
|
|
56
|
+
const defaults = providerName ? PROVIDER_DEFAULTS[providerName.toLowerCase()] : null;
|
|
57
|
+
|
|
58
|
+
if (!config.authUrl && !defaults?.authUrl) {
|
|
59
|
+
errors.push('authUrl is required');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!config.tokenUrl && !defaults?.tokenUrl) {
|
|
63
|
+
errors.push('tokenUrl is required');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return errors;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Creates a new OAuth provider registry instance.
|
|
71
|
+
*
|
|
72
|
+
* @returns {object} Registry instance with provider management methods
|
|
73
|
+
*/
|
|
74
|
+
function createOAuthRegistry() {
|
|
75
|
+
const providers = new Map();
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Registers an OAuth provider.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} name - Provider name (e.g., 'github', 'google', 'azuread')
|
|
81
|
+
* @param {object} config - Provider configuration
|
|
82
|
+
* @param {string} config.clientId - OAuth client ID
|
|
83
|
+
* @param {string} config.clientSecret - OAuth client secret
|
|
84
|
+
* @param {string} [config.authUrl] - Authorization URL (optional for known providers)
|
|
85
|
+
* @param {string} [config.tokenUrl] - Token URL (optional for known providers)
|
|
86
|
+
* @param {string} [config.userInfoUrl] - User info URL
|
|
87
|
+
* @param {string[]} [config.scopes] - OAuth scopes
|
|
88
|
+
* @throws {Error} If required fields are missing
|
|
89
|
+
*/
|
|
90
|
+
function registerProvider(name, config) {
|
|
91
|
+
const normalizedName = name.toLowerCase();
|
|
92
|
+
const defaults = PROVIDER_DEFAULTS[normalizedName] || {};
|
|
93
|
+
|
|
94
|
+
// Validate config
|
|
95
|
+
const errors = validateProviderConfig(config, normalizedName);
|
|
96
|
+
if (errors.length > 0) {
|
|
97
|
+
throw new Error(`Invalid provider config: ${errors.join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Merge config with defaults (config takes precedence)
|
|
101
|
+
const mergedConfig = {
|
|
102
|
+
...defaults,
|
|
103
|
+
...config,
|
|
104
|
+
name: normalizedName,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
providers.set(normalizedName, mergedConfig);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Gets a registered provider by name.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} name - Provider name
|
|
114
|
+
* @returns {object|null} Provider configuration or null if not found
|
|
115
|
+
*/
|
|
116
|
+
function getProvider(name) {
|
|
117
|
+
if (!name) return null;
|
|
118
|
+
return providers.get(name.toLowerCase()) || null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Lists all registered providers.
|
|
123
|
+
* Secrets are not included in the response.
|
|
124
|
+
*
|
|
125
|
+
* @returns {object[]} Array of provider info (without secrets)
|
|
126
|
+
*/
|
|
127
|
+
function listProviders() {
|
|
128
|
+
const result = [];
|
|
129
|
+
for (const [name, config] of providers) {
|
|
130
|
+
// Return provider info without secrets
|
|
131
|
+
const { clientSecret, ...safeConfig } = config;
|
|
132
|
+
result.push(safeConfig);
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Loads providers from a .tlc.json config object.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} config - The .tlc.json configuration
|
|
141
|
+
* @param {object} [config.oauth] - OAuth configuration section
|
|
142
|
+
* @param {object} [config.oauth.providers] - Providers object
|
|
143
|
+
*/
|
|
144
|
+
function loadFromConfig(config) {
|
|
145
|
+
if (!config || !config.oauth || !config.oauth.providers) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const configProviders = config.oauth.providers;
|
|
150
|
+
for (const [name, providerConfig] of Object.entries(configProviders)) {
|
|
151
|
+
registerProvider(name, providerConfig);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Removes a registered provider.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} name - Provider name to remove
|
|
159
|
+
*/
|
|
160
|
+
function removeProvider(name) {
|
|
161
|
+
if (!name) return;
|
|
162
|
+
providers.delete(name.toLowerCase());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Checks if a provider is registered.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} name - Provider name
|
|
169
|
+
* @returns {boolean} True if provider exists
|
|
170
|
+
*/
|
|
171
|
+
function hasProvider(name) {
|
|
172
|
+
if (!name) return false;
|
|
173
|
+
return providers.has(name.toLowerCase());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
registerProvider,
|
|
178
|
+
getProvider,
|
|
179
|
+
listProviders,
|
|
180
|
+
loadFromConfig,
|
|
181
|
+
removeProvider,
|
|
182
|
+
hasProvider,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
createOAuthRegistry,
|
|
188
|
+
PROVIDER_DEFAULTS,
|
|
189
|
+
validateProviderConfig,
|
|
190
|
+
};
|