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.
Files changed (105) hide show
  1. package/dashboard/dist/components/AuditPane.d.ts +30 -0
  2. package/dashboard/dist/components/AuditPane.js +127 -0
  3. package/dashboard/dist/components/AuditPane.test.d.ts +1 -0
  4. package/dashboard/dist/components/AuditPane.test.js +339 -0
  5. package/dashboard/dist/components/CompliancePane.d.ts +39 -0
  6. package/dashboard/dist/components/CompliancePane.js +96 -0
  7. package/dashboard/dist/components/CompliancePane.test.d.ts +1 -0
  8. package/dashboard/dist/components/CompliancePane.test.js +183 -0
  9. package/dashboard/dist/components/SSOPane.d.ts +36 -0
  10. package/dashboard/dist/components/SSOPane.js +71 -0
  11. package/dashboard/dist/components/SSOPane.test.d.ts +1 -0
  12. package/dashboard/dist/components/SSOPane.test.js +155 -0
  13. package/dashboard/dist/components/WorkspaceDocsPane.js +0 -16
  14. package/dashboard/dist/components/WorkspacePane.d.ts +1 -1
  15. package/dashboard/dist/components/ZeroRetentionPane.d.ts +44 -0
  16. package/dashboard/dist/components/ZeroRetentionPane.js +83 -0
  17. package/dashboard/dist/components/ZeroRetentionPane.test.d.ts +1 -0
  18. package/dashboard/dist/components/ZeroRetentionPane.test.js +160 -0
  19. package/package.json +1 -1
  20. package/server/lib/access-control-doc.js +541 -0
  21. package/server/lib/access-control-doc.test.js +672 -0
  22. package/server/lib/adr-generator.js +423 -0
  23. package/server/lib/adr-generator.test.js +586 -0
  24. package/server/lib/agent-progress-monitor.js +223 -0
  25. package/server/lib/agent-progress-monitor.test.js +202 -0
  26. package/server/lib/audit-attribution.js +191 -0
  27. package/server/lib/audit-attribution.test.js +359 -0
  28. package/server/lib/audit-classifier.js +202 -0
  29. package/server/lib/audit-classifier.test.js +209 -0
  30. package/server/lib/audit-command.js +275 -0
  31. package/server/lib/audit-command.test.js +325 -0
  32. package/server/lib/audit-exporter.js +380 -0
  33. package/server/lib/audit-exporter.test.js +464 -0
  34. package/server/lib/audit-logger.js +236 -0
  35. package/server/lib/audit-logger.test.js +364 -0
  36. package/server/lib/audit-query.js +257 -0
  37. package/server/lib/audit-query.test.js +352 -0
  38. package/server/lib/audit-storage.js +269 -0
  39. package/server/lib/audit-storage.test.js +272 -0
  40. package/server/lib/bulk-repo-init.js +342 -0
  41. package/server/lib/bulk-repo-init.test.js +388 -0
  42. package/server/lib/compliance-checklist.js +866 -0
  43. package/server/lib/compliance-checklist.test.js +476 -0
  44. package/server/lib/compliance-command.js +616 -0
  45. package/server/lib/compliance-command.test.js +551 -0
  46. package/server/lib/compliance-reporter.js +692 -0
  47. package/server/lib/compliance-reporter.test.js +707 -0
  48. package/server/lib/data-flow-doc.js +665 -0
  49. package/server/lib/data-flow-doc.test.js +659 -0
  50. package/server/lib/ephemeral-storage.js +249 -0
  51. package/server/lib/ephemeral-storage.test.js +254 -0
  52. package/server/lib/evidence-collector.js +627 -0
  53. package/server/lib/evidence-collector.test.js +901 -0
  54. package/server/lib/flow-diagram-generator.js +474 -0
  55. package/server/lib/flow-diagram-generator.test.js +446 -0
  56. package/server/lib/idp-manager.js +626 -0
  57. package/server/lib/idp-manager.test.js +587 -0
  58. package/server/lib/memory-exclusion.js +326 -0
  59. package/server/lib/memory-exclusion.test.js +241 -0
  60. package/server/lib/mfa-handler.js +452 -0
  61. package/server/lib/mfa-handler.test.js +490 -0
  62. package/server/lib/oauth-flow.js +375 -0
  63. package/server/lib/oauth-flow.test.js +487 -0
  64. package/server/lib/oauth-registry.js +190 -0
  65. package/server/lib/oauth-registry.test.js +306 -0
  66. package/server/lib/readme-generator.js +490 -0
  67. package/server/lib/readme-generator.test.js +493 -0
  68. package/server/lib/repo-dependency-tracker.js +261 -0
  69. package/server/lib/repo-dependency-tracker.test.js +350 -0
  70. package/server/lib/retention-policy.js +281 -0
  71. package/server/lib/retention-policy.test.js +486 -0
  72. package/server/lib/role-mapper.js +236 -0
  73. package/server/lib/role-mapper.test.js +395 -0
  74. package/server/lib/saml-provider.js +765 -0
  75. package/server/lib/saml-provider.test.js +643 -0
  76. package/server/lib/security-policy-generator.js +682 -0
  77. package/server/lib/security-policy-generator.test.js +544 -0
  78. package/server/lib/sensitive-detector.js +112 -0
  79. package/server/lib/sensitive-detector.test.js +209 -0
  80. package/server/lib/service-interaction-diagram.js +700 -0
  81. package/server/lib/service-interaction-diagram.test.js +638 -0
  82. package/server/lib/service-summary.js +553 -0
  83. package/server/lib/service-summary.test.js +619 -0
  84. package/server/lib/session-purge.js +460 -0
  85. package/server/lib/session-purge.test.js +312 -0
  86. package/server/lib/sso-command.js +544 -0
  87. package/server/lib/sso-command.test.js +552 -0
  88. package/server/lib/sso-session.js +492 -0
  89. package/server/lib/sso-session.test.js +670 -0
  90. package/server/lib/workspace-command.js +249 -0
  91. package/server/lib/workspace-command.test.js +264 -0
  92. package/server/lib/workspace-config.js +270 -0
  93. package/server/lib/workspace-config.test.js +312 -0
  94. package/server/lib/workspace-docs-command.js +547 -0
  95. package/server/lib/workspace-docs-command.test.js +692 -0
  96. package/server/lib/workspace-memory.js +451 -0
  97. package/server/lib/workspace-memory.test.js +403 -0
  98. package/server/lib/workspace-scanner.js +452 -0
  99. package/server/lib/workspace-scanner.test.js +677 -0
  100. package/server/lib/workspace-test-runner.js +315 -0
  101. package/server/lib/workspace-test-runner.test.js +294 -0
  102. package/server/lib/zero-retention-command.js +439 -0
  103. package/server/lib/zero-retention-command.test.js +448 -0
  104. package/server/lib/zero-retention.js +322 -0
  105. 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
+ };