@mondaydotcomorg/atp-providers 0.17.14

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 (58) hide show
  1. package/README.md +430 -0
  2. package/__tests__/oauth-integration.test.ts +457 -0
  3. package/__tests__/scope-checkers.test.ts +560 -0
  4. package/__tests__/token-expiration.test.ts +308 -0
  5. package/dist/audit/jsonl.d.ts +29 -0
  6. package/dist/audit/jsonl.d.ts.map +1 -0
  7. package/dist/audit/jsonl.js +163 -0
  8. package/dist/audit/jsonl.js.map +1 -0
  9. package/dist/audit/opentelemetry.d.ts +23 -0
  10. package/dist/audit/opentelemetry.d.ts.map +1 -0
  11. package/dist/audit/opentelemetry.js +240 -0
  12. package/dist/audit/opentelemetry.js.map +1 -0
  13. package/dist/audit/otel-metrics.d.ts +111 -0
  14. package/dist/audit/otel-metrics.d.ts.map +1 -0
  15. package/dist/audit/otel-metrics.js +115 -0
  16. package/dist/audit/otel-metrics.js.map +1 -0
  17. package/dist/auth/env.d.ts +21 -0
  18. package/dist/auth/env.d.ts.map +1 -0
  19. package/dist/auth/env.js +48 -0
  20. package/dist/auth/env.js.map +1 -0
  21. package/dist/cache/file.d.ts +47 -0
  22. package/dist/cache/file.d.ts.map +1 -0
  23. package/dist/cache/file.js +262 -0
  24. package/dist/cache/file.js.map +1 -0
  25. package/dist/cache/memory.d.ts +30 -0
  26. package/dist/cache/memory.d.ts.map +1 -0
  27. package/dist/cache/memory.js +81 -0
  28. package/dist/cache/memory.js.map +1 -0
  29. package/dist/cache/redis.d.ts +28 -0
  30. package/dist/cache/redis.d.ts.map +1 -0
  31. package/dist/cache/redis.js +124 -0
  32. package/dist/cache/redis.js.map +1 -0
  33. package/dist/index.d.ts +10 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +9 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/oauth/index.d.ts +2 -0
  38. package/dist/oauth/index.d.ts.map +1 -0
  39. package/dist/oauth/index.js +2 -0
  40. package/dist/oauth/index.js.map +1 -0
  41. package/dist/oauth/scope-checkers.d.ts +61 -0
  42. package/dist/oauth/scope-checkers.d.ts.map +1 -0
  43. package/dist/oauth/scope-checkers.js +166 -0
  44. package/dist/oauth/scope-checkers.js.map +1 -0
  45. package/package.json +26 -0
  46. package/project.json +31 -0
  47. package/src/audit/jsonl.ts +189 -0
  48. package/src/audit/opentelemetry.ts +286 -0
  49. package/src/audit/otel-metrics.ts +122 -0
  50. package/src/auth/env.ts +65 -0
  51. package/src/cache/file.ts +330 -0
  52. package/src/cache/memory.ts +105 -0
  53. package/src/cache/redis.ts +160 -0
  54. package/src/index.ts +32 -0
  55. package/src/oauth/index.ts +1 -0
  56. package/src/oauth/scope-checkers.ts +196 -0
  57. package/tsconfig.json +14 -0
  58. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,560 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { ScopeCheckerRegistry } from '../src/oauth/scope-checkers.js';
3
+ import type { ScopeChecker } from '@mondaydotcomorg/atp-protocol';
4
+
5
+ // Mock implementations for testing
6
+ class MockGitHubScopeChecker implements ScopeChecker {
7
+ provider = 'github';
8
+
9
+ async check(token: string): Promise<string[]> {
10
+ const response = await fetch('https://api.github.com/user', {
11
+ headers: {
12
+ Authorization: `Bearer ${token}`,
13
+ Accept: 'application/vnd.github+json',
14
+ 'User-Agent': 'agent-tool-protocol',
15
+ },
16
+ });
17
+
18
+ if (!response.ok) {
19
+ if (response.status === 401) {
20
+ throw new Error('Invalid or expired GitHub token');
21
+ }
22
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
23
+ }
24
+
25
+ const scopesHeader = response.headers.get('X-OAuth-Scopes');
26
+ if (!scopesHeader) {
27
+ return [];
28
+ }
29
+
30
+ return scopesHeader
31
+ .split(',')
32
+ .map((s) => s.trim())
33
+ .filter((s) => s.length > 0);
34
+ }
35
+
36
+ async validate(token: string): Promise<boolean> {
37
+ try {
38
+ const response = await fetch('https://api.github.com/user', {
39
+ headers: {
40
+ Authorization: `Bearer ${token}`,
41
+ Accept: 'application/vnd.github+json',
42
+ 'User-Agent': 'agent-tool-protocol',
43
+ },
44
+ });
45
+ return response.ok;
46
+ } catch (error) {
47
+ return false;
48
+ }
49
+ }
50
+ }
51
+
52
+ class MockGoogleScopeChecker implements ScopeChecker {
53
+ provider = 'google';
54
+
55
+ async check(token: string): Promise<string[]> {
56
+ const response = await fetch(
57
+ `https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(token)}`
58
+ );
59
+
60
+ if (!response.ok) {
61
+ if (response.status === 400) {
62
+ throw new Error('Invalid or expired Google token');
63
+ }
64
+ throw new Error(`Google tokeninfo error: ${response.status} ${response.statusText}`);
65
+ }
66
+
67
+ const data = (await response.json()) as { scope?: string; exp?: number };
68
+
69
+ if (data.exp && data.exp < Math.floor(Date.now() / 1000)) {
70
+ throw new Error('Google token has expired');
71
+ }
72
+
73
+ if (!data.scope) {
74
+ return [];
75
+ }
76
+
77
+ return data.scope.split(' ').filter((s) => s.length > 0);
78
+ }
79
+
80
+ async validate(token: string): Promise<boolean> {
81
+ try {
82
+ const response = await fetch(
83
+ `https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(token)}`
84
+ );
85
+
86
+ if (!response.ok) {
87
+ return false;
88
+ }
89
+
90
+ const data = (await response.json()) as { exp?: number };
91
+ if (data.exp) {
92
+ const now = Math.floor(Date.now() / 1000);
93
+ return data.exp > now;
94
+ }
95
+
96
+ return true;
97
+ } catch (error) {
98
+ return false;
99
+ }
100
+ }
101
+ }
102
+
103
+ // Mock fetch globally
104
+ global.fetch = vi.fn();
105
+
106
+ describe('MockGitHubScopeChecker', () => {
107
+ let checker: MockGitHubScopeChecker;
108
+
109
+ beforeEach(() => {
110
+ checker = new MockGitHubScopeChecker();
111
+ vi.clearAllMocks();
112
+ });
113
+
114
+ describe('check()', () => {
115
+ it('should return scopes from X-OAuth-Scopes header', async () => {
116
+ (global.fetch as any).mockResolvedValueOnce({
117
+ ok: true,
118
+ headers: {
119
+ get: (name: string) => {
120
+ if (name === 'X-OAuth-Scopes') {
121
+ return 'repo, read:user, write:org';
122
+ }
123
+ return null;
124
+ },
125
+ },
126
+ });
127
+
128
+ const scopes = await checker.check('gho_test_token');
129
+
130
+ expect(scopes).toEqual(['repo', 'read:user', 'write:org']);
131
+ expect(global.fetch).toHaveBeenCalledWith(
132
+ 'https://api.github.com/user',
133
+ expect.objectContaining({
134
+ headers: expect.objectContaining({
135
+ Authorization: 'Bearer gho_test_token',
136
+ }),
137
+ })
138
+ );
139
+ });
140
+
141
+ it('should return empty array when no scopes header', async () => {
142
+ (global.fetch as any).mockResolvedValueOnce({
143
+ ok: true,
144
+ headers: {
145
+ get: () => null,
146
+ },
147
+ });
148
+
149
+ const scopes = await checker.check('gho_test_token');
150
+
151
+ expect(scopes).toEqual([]);
152
+ });
153
+
154
+ it('should throw error on 401 unauthorized', async () => {
155
+ (global.fetch as any).mockResolvedValueOnce({
156
+ ok: false,
157
+ status: 401,
158
+ statusText: 'Unauthorized',
159
+ });
160
+
161
+ await expect(checker.check('invalid_token')).rejects.toThrow(
162
+ 'Invalid or expired GitHub token'
163
+ );
164
+ });
165
+
166
+ it('should throw error on other HTTP errors', async () => {
167
+ (global.fetch as any).mockResolvedValueOnce({
168
+ ok: false,
169
+ status: 500,
170
+ statusText: 'Internal Server Error',
171
+ });
172
+
173
+ await expect(checker.check('gho_test_token')).rejects.toThrow(
174
+ 'GitHub API error: 500 Internal Server Error'
175
+ );
176
+ });
177
+ });
178
+
179
+ describe('validate()', () => {
180
+ it('should return true for valid token', async () => {
181
+ (global.fetch as any).mockResolvedValueOnce({
182
+ ok: true,
183
+ });
184
+
185
+ const isValid = await checker.validate('gho_test_token');
186
+
187
+ expect(isValid).toBe(true);
188
+ });
189
+
190
+ it('should return false for invalid token', async () => {
191
+ (global.fetch as any).mockResolvedValueOnce({
192
+ ok: false,
193
+ });
194
+
195
+ const isValid = await checker.validate('invalid_token');
196
+
197
+ expect(isValid).toBe(false);
198
+ });
199
+
200
+ it('should return false on network error', async () => {
201
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
202
+
203
+ const isValid = await checker.validate('gho_test_token');
204
+
205
+ expect(isValid).toBe(false);
206
+ });
207
+ });
208
+ });
209
+
210
+ describe('MockGoogleScopeChecker', () => {
211
+ let checker: MockGoogleScopeChecker;
212
+
213
+ beforeEach(() => {
214
+ checker = new MockGoogleScopeChecker();
215
+ vi.clearAllMocks();
216
+ });
217
+
218
+ describe('check()', () => {
219
+ it('should return scopes from tokeninfo response', async () => {
220
+ const now = Math.floor(Date.now() / 1000);
221
+ (global.fetch as any).mockResolvedValueOnce({
222
+ ok: true,
223
+ json: async () => ({
224
+ scope:
225
+ 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/userinfo.email',
226
+ exp: now + 3600,
227
+ }),
228
+ });
229
+
230
+ const scopes = await checker.check('ya29.test_token');
231
+
232
+ expect(scopes).toEqual([
233
+ 'https://www.googleapis.com/auth/calendar',
234
+ 'https://www.googleapis.com/auth/userinfo.email',
235
+ ]);
236
+ });
237
+
238
+ it('should throw error when token is expired', async () => {
239
+ const expiredTime = Math.floor(Date.now() / 1000) - 3600;
240
+ (global.fetch as any).mockResolvedValueOnce({
241
+ ok: true,
242
+ json: async () => ({
243
+ scope: 'email',
244
+ exp: expiredTime,
245
+ }),
246
+ });
247
+
248
+ await expect(checker.check('ya29.test_token')).rejects.toThrow('Google token has expired');
249
+ });
250
+
251
+ it('should throw error on 400 bad request', async () => {
252
+ (global.fetch as any).mockResolvedValueOnce({
253
+ ok: false,
254
+ status: 400,
255
+ statusText: 'Bad Request',
256
+ });
257
+
258
+ await expect(checker.check('invalid_token')).rejects.toThrow(
259
+ 'Invalid or expired Google token'
260
+ );
261
+ });
262
+
263
+ it('should return empty array when no scope field', async () => {
264
+ (global.fetch as any).mockResolvedValueOnce({
265
+ ok: true,
266
+ json: async () => ({}),
267
+ });
268
+
269
+ const scopes = await checker.check('ya29.test_token');
270
+
271
+ expect(scopes).toEqual([]);
272
+ });
273
+ });
274
+
275
+ describe('validate()', () => {
276
+ it('should return true for non-expired token', async () => {
277
+ const now = Math.floor(Date.now() / 1000);
278
+ (global.fetch as any).mockResolvedValueOnce({
279
+ ok: true,
280
+ json: async () => ({
281
+ exp: now + 3600,
282
+ }),
283
+ });
284
+
285
+ const isValid = await checker.validate('ya29.test_token');
286
+
287
+ expect(isValid).toBe(true);
288
+ });
289
+
290
+ it('should return false for expired token', async () => {
291
+ const expiredTime = Math.floor(Date.now() / 1000) - 3600;
292
+ (global.fetch as any).mockResolvedValueOnce({
293
+ ok: true,
294
+ json: async () => ({
295
+ exp: expiredTime,
296
+ }),
297
+ });
298
+
299
+ const isValid = await checker.validate('ya29.test_token');
300
+
301
+ expect(isValid).toBe(false);
302
+ });
303
+
304
+ it('should return false on network error', async () => {
305
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
306
+
307
+ const isValid = await checker.validate('ya29.test_token');
308
+
309
+ expect(isValid).toBe(false);
310
+ });
311
+ });
312
+ });
313
+
314
+ describe('ScopeChecker (Microsoft example)', () => {
315
+ // Tests removed - vendor implementations moved to examples
316
+ it('should be tested in examples', () => {
317
+ expect(true).toBe(true);
318
+ });
319
+ });
320
+
321
+ describe('ScopeCheckerRegistry', () => {
322
+ let registry: ScopeCheckerRegistry;
323
+
324
+ beforeEach(() => {
325
+ registry = new ScopeCheckerRegistry();
326
+ vi.clearAllMocks();
327
+ });
328
+
329
+ afterEach(() => {
330
+ registry.stopCleanup();
331
+ });
332
+
333
+ describe('register()', () => {
334
+ it('should register custom scope checker', () => {
335
+ const customChecker = {
336
+ provider: 'custom',
337
+ check: vi.fn(),
338
+ validate: vi.fn(),
339
+ };
340
+
341
+ registry.register(customChecker);
342
+
343
+ expect(registry.hasChecker('custom')).toBe(true);
344
+ expect(registry.getChecker('custom')).toBe(customChecker);
345
+ });
346
+ });
347
+
348
+ describe('hasChecker()', () => {
349
+ it('should return false for checkers not registered', () => {
350
+ expect(registry.hasChecker('github')).toBe(false);
351
+ expect(registry.hasChecker('google')).toBe(false);
352
+ expect(registry.hasChecker('unknown')).toBe(false);
353
+ });
354
+
355
+ it('should return true for registered checkers', () => {
356
+ registry.register(new MockGitHubScopeChecker());
357
+ expect(registry.hasChecker('github')).toBe(true);
358
+ });
359
+ });
360
+
361
+ describe('checkScopes()', () => {
362
+ it('should check scopes and cache result', async () => {
363
+ // Register checker first
364
+ registry.register(new MockGitHubScopeChecker());
365
+
366
+ (global.fetch as any).mockResolvedValueOnce({
367
+ ok: true,
368
+ headers: {
369
+ get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
370
+ },
371
+ });
372
+
373
+ const scopes1 = await registry.checkScopes('github', 'gho_test_token');
374
+ expect(scopes1).toEqual(['repo']);
375
+ expect(global.fetch).toHaveBeenCalledTimes(1);
376
+
377
+ // Second call should use cache
378
+ const scopes2 = await registry.checkScopes('github', 'gho_test_token');
379
+ expect(scopes2).toEqual(['repo']);
380
+ expect(global.fetch).toHaveBeenCalledTimes(1); // Still 1, not 2
381
+ });
382
+
383
+ it('should deduplicate concurrent requests', async () => {
384
+ registry.register(new MockGitHubScopeChecker());
385
+
386
+ (global.fetch as any).mockImplementation(
387
+ () =>
388
+ new Promise((resolve) =>
389
+ setTimeout(
390
+ () =>
391
+ resolve({
392
+ ok: true,
393
+ headers: {
394
+ get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
395
+ },
396
+ }),
397
+ 100
398
+ )
399
+ )
400
+ );
401
+
402
+ // Make 3 concurrent requests
403
+ const promises = [
404
+ registry.checkScopes('github', 'gho_test_token'),
405
+ registry.checkScopes('github', 'gho_test_token'),
406
+ registry.checkScopes('github', 'gho_test_token'),
407
+ ];
408
+
409
+ const results = await Promise.all(promises);
410
+
411
+ // All should return the same result
412
+ expect(results).toEqual([['repo'], ['repo'], ['repo']]);
413
+
414
+ // But only one API call should have been made
415
+ expect(global.fetch).toHaveBeenCalledTimes(1);
416
+ });
417
+
418
+ it('should throw error for unsupported provider', async () => {
419
+ await expect(registry.checkScopes('unsupported', 'token')).rejects.toThrow(
420
+ 'No scope checker registered for provider: unsupported'
421
+ );
422
+ });
423
+ });
424
+
425
+ describe('validateToken()', () => {
426
+ it('should validate token', async () => {
427
+ registry.register(new MockGitHubScopeChecker());
428
+
429
+ (global.fetch as any).mockResolvedValueOnce({
430
+ ok: true,
431
+ });
432
+
433
+ const isValid = await registry.validateToken('github', 'gho_test_token');
434
+
435
+ expect(isValid).toBe(true);
436
+ });
437
+
438
+ it('should return true for checker without validate method', async () => {
439
+ const checkerWithoutValidate = {
440
+ provider: 'custom',
441
+ check: vi.fn(),
442
+ };
443
+
444
+ registry.register(checkerWithoutValidate);
445
+
446
+ const isValid = await registry.validateToken('custom', 'token');
447
+
448
+ expect(isValid).toBe(true);
449
+ });
450
+ });
451
+
452
+ describe('getTokenInfo()', () => {
453
+ it('should return token info with validity and scopes', async () => {
454
+ registry.register(new MockGitHubScopeChecker());
455
+
456
+ (global.fetch as any)
457
+ .mockResolvedValueOnce({
458
+ ok: true,
459
+ headers: {
460
+ get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
461
+ },
462
+ })
463
+ .mockResolvedValueOnce({
464
+ ok: true,
465
+ });
466
+
467
+ const tokenInfo = await registry.getTokenInfo('github', 'gho_test_token');
468
+
469
+ expect(tokenInfo).toEqual({
470
+ valid: true,
471
+ scopes: ['repo'],
472
+ });
473
+ });
474
+ });
475
+
476
+ describe('clearCache()', () => {
477
+ it('should clear all cache when no provider specified', async () => {
478
+ registry.register(new MockGitHubScopeChecker());
479
+
480
+ (global.fetch as any).mockResolvedValue({
481
+ ok: true,
482
+ headers: {
483
+ get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
484
+ },
485
+ });
486
+
487
+ // Cache some scopes
488
+ await registry.checkScopes('github', 'token1');
489
+ await registry.checkScopes('github', 'token2');
490
+
491
+ expect(global.fetch).toHaveBeenCalledTimes(2);
492
+
493
+ // Clear cache
494
+ registry.clearCache();
495
+
496
+ // Next call should hit API again
497
+ await registry.checkScopes('github', 'token1');
498
+ expect(global.fetch).toHaveBeenCalledTimes(3);
499
+ });
500
+
501
+ it('should clear cache for specific provider only', async () => {
502
+ registry.register(new MockGitHubScopeChecker());
503
+
504
+ (global.fetch as any).mockResolvedValue({
505
+ ok: true,
506
+ headers: {
507
+ get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
508
+ },
509
+ });
510
+
511
+ // Cache scopes for github
512
+ await registry.checkScopes('github', 'token1');
513
+ const callCount = (global.fetch as any).mock.calls.length;
514
+
515
+ // Clear github cache
516
+ registry.clearCache('github');
517
+
518
+ // GitHub should hit API again
519
+ await registry.checkScopes('github', 'token1');
520
+ expect((global.fetch as any).mock.calls.length).toBe(callCount + 1);
521
+ });
522
+ });
523
+
524
+ describe('cache cleanup', () => {
525
+ it('should periodically clean expired entries', async () => {
526
+ registry.register(new MockGitHubScopeChecker());
527
+
528
+ vi.useFakeTimers();
529
+
530
+ (global.fetch as any).mockResolvedValue({
531
+ ok: true,
532
+ headers: {
533
+ get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
534
+ },
535
+ });
536
+
537
+ // Cache with short TTL
538
+ await registry.checkScopes('github', 'token', 1); // 1 second TTL
539
+
540
+ // Fast-forward 5 minutes (cleanup interval)
541
+ vi.advanceTimersByTime(5 * 60 * 1000);
542
+
543
+ // Cache should be cleaned
544
+ await registry.checkScopes('github', 'token');
545
+
546
+ // Should have made 2 API calls (one before, one after cleanup)
547
+ expect((global.fetch as any).mock.calls.length).toBeGreaterThanOrEqual(2);
548
+
549
+ vi.useRealTimers();
550
+ });
551
+
552
+ it('should stop cleanup on stopCleanup()', () => {
553
+ const spy = vi.spyOn(global, 'clearInterval');
554
+
555
+ registry.stopCleanup();
556
+
557
+ expect(spy).toHaveBeenCalled();
558
+ });
559
+ });
560
+ });