@mediaviz/sdk 0.1.0

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 (52) hide show
  1. package/MediaViz.js +126 -0
  2. package/_oauth.js +3 -0
  3. package/admin.js +93 -0
  4. package/ai_model_credits.js +22 -0
  5. package/company.js +54 -0
  6. package/curated_albums.js +85 -0
  7. package/custom_albums.js +78 -0
  8. package/dist/sdk.cjs +1976 -0
  9. package/dist/sdk.esm.js +1947 -0
  10. package/dist/sdk.umd.js +1982 -0
  11. package/email_tokens.js +64 -0
  12. package/errors.js +81 -0
  13. package/health.js +20 -0
  14. package/index.js +21 -0
  15. package/keywords.js +123 -0
  16. package/oauth/.prettierrc +6 -0
  17. package/oauth/README.md +76 -0
  18. package/oauth/browser-smoke-test.html +45 -0
  19. package/oauth/implementation_plan.json +106 -0
  20. package/oauth/package-lock.json +5236 -0
  21. package/oauth/package.json +28 -0
  22. package/oauth/rollup.config.js +21 -0
  23. package/oauth/smoke-test.js +27 -0
  24. package/oauth/spec.md +187 -0
  25. package/oauth/src/__tests__/browser-smoke-test.test.js +38 -0
  26. package/oauth/src/__tests__/client.test.js +556 -0
  27. package/oauth/src/__tests__/errors.test.js +73 -0
  28. package/oauth/src/__tests__/http.test.js +102 -0
  29. package/oauth/src/__tests__/index.test.js +53 -0
  30. package/oauth/src/__tests__/package-fields.test.js +29 -0
  31. package/oauth/src/__tests__/pkce.test.js +55 -0
  32. package/oauth/src/__tests__/rollup-build.test.js +58 -0
  33. package/oauth/src/__tests__/smoke-test.test.js +26 -0
  34. package/oauth/src/__tests__/types.test.js +29 -0
  35. package/oauth/src/client.js +180 -0
  36. package/oauth/src/errors.js +32 -0
  37. package/oauth/src/http.js +52 -0
  38. package/oauth/src/index.js +7 -0
  39. package/oauth/src/pkce.js +50 -0
  40. package/oauth/src/types.js +67 -0
  41. package/oauth_authorization.js +53 -0
  42. package/oauth_clients.js +18 -0
  43. package/oauth_login.js +24 -0
  44. package/oauth_token.js +30 -0
  45. package/package.json +27 -0
  46. package/person.js +54 -0
  47. package/photos.js +106 -0
  48. package/photoupload.js +55 -0
  49. package/projects.js +191 -0
  50. package/rollup.config.js +12 -0
  51. package/search.js +99 -0
  52. package/users.js +137 -0
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const { postForm, getJson } = require('../http');
4
+ const { OAuthError } = require('../errors');
5
+
6
+ describe('postForm', () => {
7
+ afterEach(() => jest.restoreAllMocks());
8
+
9
+ it('sends urlencoded POST and returns parsed JSON on 2xx', async () => {
10
+ const mockResponse = { access_token: 'tok', token_type: 'bearer', expires_in: 3600, refresh_token: 'ref' };
11
+ jest.spyOn(global, 'fetch').mockResolvedValue({
12
+ ok: true,
13
+ status: 200,
14
+ json: async () => mockResponse,
15
+ });
16
+
17
+ const result = await postForm('https://example.com/token', { grant_type: 'authorization_code', code: 'abc' });
18
+
19
+ expect(result).toEqual(mockResponse);
20
+ const [url, init] = global.fetch.mock.calls[0];
21
+ expect(url).toBe('https://example.com/token');
22
+ expect(init.method).toBe('POST');
23
+ expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
24
+ expect(init.body).toContain('grant_type=authorization_code');
25
+ expect(init.body).toContain('code=abc');
26
+ });
27
+
28
+ it('throws OAuthError on non-2xx with RFC 6749 body', async () => {
29
+ jest.spyOn(global, 'fetch').mockResolvedValue({
30
+ ok: false,
31
+ status: 400,
32
+ json: async () => ({ error: 'invalid_grant', error_description: 'Code expired' }),
33
+ });
34
+
35
+ await expect(postForm('https://example.com/token', {})).rejects.toThrow(OAuthError);
36
+
37
+ try {
38
+ await postForm('https://example.com/token', {});
39
+ } catch (err) {
40
+ expect(err.code).toBe('invalid_grant');
41
+ expect(err.description).toBe('Code expired');
42
+ expect(err.httpStatus).toBe(400);
43
+ }
44
+ });
45
+
46
+ it('throws OAuthError with server_error fallback for non-RFC body', async () => {
47
+ jest.spyOn(global, 'fetch').mockResolvedValue({
48
+ ok: false,
49
+ status: 500,
50
+ json: async () => ({ message: 'Something broke' }),
51
+ });
52
+
53
+ await expect(postForm('https://example.com/token', {})).rejects.toMatchObject({
54
+ code: 'server_error',
55
+ httpStatus: 500,
56
+ });
57
+ });
58
+
59
+ it('merges extra headers', async () => {
60
+ jest.spyOn(global, 'fetch').mockResolvedValue({
61
+ ok: true,
62
+ status: 200,
63
+ json: async () => ({}),
64
+ });
65
+
66
+ await postForm('https://example.com/token', {}, { 'X-Custom': 'value' });
67
+ const [, init] = global.fetch.mock.calls[0];
68
+ expect(init.headers['X-Custom']).toBe('value');
69
+ });
70
+ });
71
+
72
+ describe('getJson', () => {
73
+ afterEach(() => jest.restoreAllMocks());
74
+
75
+ it('sends GET and returns parsed JSON on 2xx', async () => {
76
+ const mockData = { user_id: 'u1' };
77
+ jest.spyOn(global, 'fetch').mockResolvedValue({
78
+ ok: true,
79
+ status: 200,
80
+ json: async () => mockData,
81
+ });
82
+
83
+ const result = await getJson('https://example.com/me', { Authorization: 'Bearer tok' });
84
+ expect(result).toEqual(mockData);
85
+ const [url, init] = global.fetch.mock.calls[0];
86
+ expect(url).toBe('https://example.com/me');
87
+ expect(init.headers['Authorization']).toBe('Bearer tok');
88
+ });
89
+
90
+ it('throws OAuthError on non-2xx', async () => {
91
+ jest.spyOn(global, 'fetch').mockResolvedValue({
92
+ ok: false,
93
+ status: 401,
94
+ json: async () => ({ error: 'invalid_token', error_description: 'Expired' }),
95
+ });
96
+
97
+ await expect(getJson('https://example.com/me')).rejects.toMatchObject({
98
+ code: 'invalid_token',
99
+ httpStatus: 401,
100
+ });
101
+ });
102
+ });
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const sdk = require('../index');
4
+
5
+ describe('barrel export (index.js)', () => {
6
+ test('exports OAuthClient', () => {
7
+ expect(typeof sdk.OAuthClient).toBe('function');
8
+ });
9
+
10
+ test('exports OAuthError', () => {
11
+ expect(typeof sdk.OAuthError).toBe('function');
12
+ });
13
+
14
+ test('exports OAuthErrorCode', () => {
15
+ expect(typeof sdk.OAuthErrorCode).toBe('object');
16
+ expect(sdk.OAuthErrorCode).not.toBeNull();
17
+ });
18
+
19
+ test('OAuthErrorCode is frozen', () => {
20
+ expect(Object.isFrozen(sdk.OAuthErrorCode)).toBe(true);
21
+ });
22
+
23
+ test('OAuthErrorCode contains required codes', () => {
24
+ expect(sdk.OAuthErrorCode.INVALID_REQUEST).toBe('invalid_request');
25
+ expect(sdk.OAuthErrorCode.INVALID_CLIENT).toBe('invalid_client');
26
+ expect(sdk.OAuthErrorCode.INVALID_GRANT).toBe('invalid_grant');
27
+ expect(sdk.OAuthErrorCode.UNAUTHORIZED_CLIENT).toBe('unauthorized_client');
28
+ expect(sdk.OAuthErrorCode.UNSUPPORTED_GRANT_TYPE).toBe('unsupported_grant_type');
29
+ expect(sdk.OAuthErrorCode.ACCESS_DENIED).toBe('access_denied');
30
+ expect(sdk.OAuthErrorCode.SERVER_ERROR).toBe('server_error');
31
+ });
32
+
33
+ test('OAuthClient is instantiable with config', () => {
34
+ const client = new sdk.OAuthClient({
35
+ baseUrl: 'https://auth.example.com',
36
+ clientId: 'cid',
37
+ clientSecret: 'csecret',
38
+ redirectUri: 'https://myapp.com/callback',
39
+ });
40
+ expect(client).toBeInstanceOf(sdk.OAuthClient);
41
+ });
42
+
43
+ test('OAuthError is a subclass of Error', () => {
44
+ const err = new sdk.OAuthError('server_error', 'boom', 500);
45
+ expect(err).toBeInstanceOf(Error);
46
+ expect(err).toBeInstanceOf(sdk.OAuthError);
47
+ });
48
+
49
+ test('no unexpected exports', () => {
50
+ const keys = Object.keys(sdk);
51
+ expect(keys.sort()).toEqual(['OAuthClient', 'OAuthError', 'OAuthErrorCode'].sort());
52
+ });
53
+ });
@@ -0,0 +1,29 @@
1
+ const path = require('path');
2
+ const pkg = require('../../package.json');
3
+
4
+ describe('package.json build fields (task 5)', () => {
5
+ test('main points to CJS entry', () => {
6
+ expect(pkg.main).toBe('src/index.js');
7
+ });
8
+
9
+ test('module field points to ESM dist', () => {
10
+ expect(pkg.module).toBe('dist/oauth-sdk.esm.js');
11
+ });
12
+
13
+ test('browser field points to UMD dist', () => {
14
+ expect(pkg.browser).toBe('dist/oauth-sdk.umd.js');
15
+ });
16
+
17
+ test('files includes src/ and dist/', () => {
18
+ expect(pkg.files).toContain('src/');
19
+ expect(pkg.files).toContain('dist/');
20
+ });
21
+
22
+ test('build script runs rollup', () => {
23
+ expect(pkg.scripts.build).toBe('rollup -c');
24
+ });
25
+
26
+ test('prepublishOnly runs build', () => {
27
+ expect(pkg.scripts.prepublishOnly).toBe('npm run build');
28
+ });
29
+ });
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const { generateCodeVerifier, generateCodeChallenge, generateState } = require('../pkce');
4
+
5
+ const VERIFIER_ALPHABET = /^[A-Za-z0-9\-._~]+$/;
6
+
7
+ describe('generateCodeVerifier', () => {
8
+ test('returns a 64-character string', () => {
9
+ expect(generateCodeVerifier()).toHaveLength(64);
10
+ });
11
+
12
+ test('only contains valid PKCE alphabet characters', () => {
13
+ expect(generateCodeVerifier()).toMatch(VERIFIER_ALPHABET);
14
+ });
15
+
16
+ test('generates unique values', () => {
17
+ expect(generateCodeVerifier()).not.toBe(generateCodeVerifier());
18
+ });
19
+ });
20
+
21
+ describe('generateCodeChallenge', () => {
22
+ // RFC 7636 Appendix B test vector
23
+ const KNOWN_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
24
+ const KNOWN_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
25
+
26
+ test('matches known PKCE S256 test vector', async () => {
27
+ expect(await generateCodeChallenge(KNOWN_VERIFIER)).toBe(KNOWN_CHALLENGE);
28
+ });
29
+
30
+ test('output has no base64 padding', async () => {
31
+ const challenge = await generateCodeChallenge(generateCodeVerifier());
32
+ expect(challenge).not.toContain('=');
33
+ });
34
+
35
+ test('output uses URL-safe base64 (no + or /)', async () => {
36
+ for (let i = 0; i < 20; i++) {
37
+ const challenge = await generateCodeChallenge(generateCodeVerifier());
38
+ expect(challenge).not.toMatch(/[+/]/);
39
+ }
40
+ });
41
+ });
42
+
43
+ describe('generateState', () => {
44
+ test('returns a 32-character string', () => {
45
+ expect(generateState()).toHaveLength(32);
46
+ });
47
+
48
+ test('is lowercase hex', () => {
49
+ expect(generateState()).toMatch(/^[0-9a-f]{32}$/);
50
+ });
51
+
52
+ test('generates unique values', () => {
53
+ expect(generateState()).not.toBe(generateState());
54
+ });
55
+ });
@@ -0,0 +1,58 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+
4
+ const distDir = path.resolve(__dirname, '../../dist');
5
+ const umdPath = path.join(distDir, 'oauth-sdk.umd.js');
6
+ const esmPath = path.join(distDir, 'oauth-sdk.esm.js');
7
+
8
+ describe('Rollup build output', () => {
9
+ test('dist/oauth-sdk.umd.js exists', () => {
10
+ expect(fs.existsSync(umdPath)).toBe(true);
11
+ });
12
+
13
+ test('dist/oauth-sdk.esm.js exists', () => {
14
+ expect(fs.existsSync(esmPath)).toBe(true);
15
+ });
16
+
17
+ test('UMD bundle is non-empty and minified (single line)', () => {
18
+ const content = fs.readFileSync(umdPath, 'utf8').trim();
19
+ expect(content.length).toBeGreaterThan(100);
20
+ // terser produces a single line
21
+ expect(content.split('\n').length).toBe(1);
22
+ });
23
+
24
+ test('UMD bundle registers OAuthSDK global', () => {
25
+ const content = fs.readFileSync(umdPath, 'utf8');
26
+ expect(content).toContain('OAuthSDK');
27
+ });
28
+
29
+ test('UMD bundle exports OAuthClient, OAuthError, OAuthErrorCode', () => {
30
+ const content = fs.readFileSync(umdPath, 'utf8');
31
+ expect(content).toContain('OAuthClient');
32
+ expect(content).toContain('OAuthError');
33
+ expect(content).toContain('OAuthErrorCode');
34
+ });
35
+
36
+ test('ESM bundle uses export syntax', () => {
37
+ const content = fs.readFileSync(esmPath, 'utf8');
38
+ expect(content).toMatch(/export\s*\{/);
39
+ });
40
+
41
+ test('UMD bundle uses Web Crypto (no require crypto)', () => {
42
+ const content = fs.readFileSync(umdPath, 'utf8');
43
+ expect(content).not.toContain("require('crypto')");
44
+ expect(content).toContain('globalThis.crypto');
45
+ });
46
+
47
+ test('rollup.config.js exists', () => {
48
+ const configPath = path.resolve(__dirname, '../../rollup.config.js');
49
+ expect(fs.existsSync(configPath)).toBe(true);
50
+ });
51
+
52
+ test('package.json includes rollup dev dependencies', () => {
53
+ const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8'));
54
+ expect(pkg.devDependencies).toHaveProperty('rollup');
55
+ expect(pkg.devDependencies).toHaveProperty('@rollup/plugin-node-resolve');
56
+ expect(pkg.devDependencies).toHaveProperty('@rollup/plugin-terser');
57
+ });
58
+ });
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ const SDK_ROOT = path.resolve(__dirname, '..', '..', '..', '..', '..');
8
+ const JS_ROOT = path.resolve(__dirname, '..', '..');
9
+
10
+ describe('task 6: smoke-test and gitignore', () => {
11
+ test('smoke-test.js exits 0', () => {
12
+ const result = execSync(`node ${path.join(JS_ROOT, 'smoke-test.js')}`, { encoding: 'utf8' });
13
+ expect(result.trim()).toBe('OK');
14
+ });
15
+
16
+ test('.gitignore includes dist/', () => {
17
+ const gitignore = fs.readFileSync(path.join(JS_ROOT, '.gitignore'), 'utf8');
18
+ const lines = gitignore.split('\n').map(l => l.trim());
19
+ expect(lines).toContain('dist/');
20
+ });
21
+
22
+ test('smoke-test.js awaits generateAuthorizationUrl', () => {
23
+ const src = fs.readFileSync(path.join(JS_ROOT, 'smoke-test.js'), 'utf8');
24
+ expect(src).toMatch(/await\s+client\.generateAuthorizationUrl\(\)/);
25
+ });
26
+ });
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const { OAuthErrorCode } = require('../types');
4
+
5
+ describe('OAuthErrorCode', () => {
6
+ test('has all required error codes with correct values', () => {
7
+ expect(OAuthErrorCode.INVALID_REQUEST).toBe('invalid_request');
8
+ expect(OAuthErrorCode.INVALID_CLIENT).toBe('invalid_client');
9
+ expect(OAuthErrorCode.INVALID_GRANT).toBe('invalid_grant');
10
+ expect(OAuthErrorCode.UNAUTHORIZED_CLIENT).toBe('unauthorized_client');
11
+ expect(OAuthErrorCode.UNSUPPORTED_GRANT_TYPE).toBe('unsupported_grant_type');
12
+ expect(OAuthErrorCode.ACCESS_DENIED).toBe('access_denied');
13
+ expect(OAuthErrorCode.SERVER_ERROR).toBe('server_error');
14
+ });
15
+
16
+ test('is frozen (immutable)', () => {
17
+ expect(Object.isFrozen(OAuthErrorCode)).toBe(true);
18
+ });
19
+
20
+ test('mutation attempt does not change values', () => {
21
+ const original = OAuthErrorCode.INVALID_REQUEST;
22
+ try { OAuthErrorCode.INVALID_REQUEST = 'mutated'; } catch (_) {}
23
+ expect(OAuthErrorCode.INVALID_REQUEST).toBe(original);
24
+ });
25
+
26
+ test('has exactly 7 entries', () => {
27
+ expect(Object.keys(OAuthErrorCode).length).toBe(7);
28
+ });
29
+ });
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ const { generateCodeVerifier, generateCodeChallenge, generateState } = require('./pkce');
4
+ const { postForm, postJson } = require('./http');
5
+ const { OAuthError } = require('./errors');
6
+
7
+ class OAuthClient {
8
+ /**
9
+ * @param {import('./types').OAuthClientConfig} config
10
+ */
11
+ constructor(config) {
12
+ this._config = config;
13
+ this._inFlightRefreshes = new Map();
14
+ }
15
+
16
+ /**
17
+ * @param {import('./types').ClientRegistrationRequest} params
18
+ * @returns {Promise<import('./types').ClientRegistrationResponse>}
19
+ */
20
+ static async registerClient(params) {
21
+ const baseUrl = params.baseUrl.replace(/\/+$/, '');
22
+ return postJson(`${baseUrl}/oauth/clients`, {
23
+ client_name: params.clientName,
24
+ client_type: params.clientType,
25
+ redirect_uris: params.redirectUris,
26
+ is_first_party: params.isFirstParty,
27
+ });
28
+ }
29
+
30
+ /**
31
+ * @param {string} [state]
32
+ * @returns {import('./types').AuthorizationUrlResult}
33
+ */
34
+ async generateAuthorizationUrl(state) {
35
+ const codeVerifier = generateCodeVerifier();
36
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
37
+ const resolvedState = state ?? generateState();
38
+
39
+ const params = new URLSearchParams({
40
+ response_type: 'code',
41
+ client_id: this._config.clientId,
42
+ redirect_uri: this._config.redirectUri,
43
+ state: resolvedState,
44
+ code_challenge: codeChallenge,
45
+ code_challenge_method: 'S256',
46
+ });
47
+
48
+ return {
49
+ url: `${this._config.baseUrl}/oauth/authorize?${params}`,
50
+ state: resolvedState,
51
+ code_verifier: codeVerifier,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * @param {string} code
57
+ * @param {string} codeVerifier
58
+ * @param {string} [redirectUri]
59
+ * @returns {Promise<import('./types').TokenResponse>}
60
+ */
61
+ async exchangeCode(code, codeVerifier, redirectUri) {
62
+ return postForm(`${this._config.baseUrl}/oauth/token`, {
63
+ grant_type: 'authorization_code',
64
+ code,
65
+ code_verifier: codeVerifier,
66
+ redirect_uri: redirectUri ?? this._config.redirectUri,
67
+ client_id: this._config.clientId,
68
+ client_secret: this._config.clientSecret,
69
+ });
70
+ }
71
+
72
+ /**
73
+ * RFC 6749 §4.4 — machine-to-machine token exchange. No user login.
74
+ * Returned TokenResponse has no refresh_token.
75
+ * @returns {Promise<import('./types').TokenResponse>}
76
+ */
77
+ async getClientCredentialsToken() {
78
+ return postForm(`${this._config.baseUrl}/oauth/token`, {
79
+ grant_type: 'client_credentials',
80
+ client_id: this._config.clientId,
81
+ client_secret: this._config.clientSecret,
82
+ });
83
+ }
84
+
85
+ /**
86
+ * @param {string} refreshToken
87
+ * @returns {Promise<import('./types').TokenResponse>}
88
+ */
89
+ async refreshAccessToken(refreshToken) {
90
+ return postForm(`${this._config.baseUrl}/oauth/token`, {
91
+ grant_type: 'refresh_token',
92
+ refresh_token: refreshToken,
93
+ client_id: this._config.clientId,
94
+ client_secret: this._config.clientSecret,
95
+ });
96
+ }
97
+
98
+ /**
99
+ * @param {string} token
100
+ * @param {string} [tokenTypeHint]
101
+ * @returns {Promise<void>}
102
+ */
103
+ async revokeToken(token, tokenTypeHint) {
104
+ const params = { token, client_id: this._config.clientId, client_secret: this._config.clientSecret };
105
+ if (tokenTypeHint) params.token_type_hint = tokenTypeHint;
106
+ await postForm(`${this._config.baseUrl}/oauth/revoke`, params);
107
+ }
108
+
109
+ /**
110
+ * Makes an authenticated request with 401-intercept-and-retry.
111
+ *
112
+ * `onRefreshSuccess` fires synchronously the moment the rotated tokens are received,
113
+ * BEFORE the retry. The server has already deleted the old refresh token by then;
114
+ * if the retry throws, the new tokens would otherwise be lost in this call frame.
115
+ * Long-lived callers MUST persist via this callback — not from the resolved value —
116
+ * to stay in sync with single-use refresh-token rotation (RFC 6749 §6).
117
+ *
118
+ * @param {string} url
119
+ * @param {string} method
120
+ * @param {string} accessToken
121
+ * @param {string} refreshToken
122
+ * @param {object} [body]
123
+ * @param {(newTokens: import('./types').TokenResponse) => void} [onRefreshSuccess]
124
+ * @returns {Promise<import('./types').AuthenticatedResponse>}
125
+ */
126
+ async request(url, method, accessToken, refreshToken, body, onRefreshSuccess) {
127
+ const buildOptions = (token) => {
128
+ const headers = { Authorization: `Bearer ${token}` };
129
+ const options = { method, headers };
130
+ if (body != null) {
131
+ headers['Content-Type'] = 'application/json';
132
+ options.body = JSON.stringify(body);
133
+ }
134
+ return options;
135
+ };
136
+
137
+ const firstResponse = await fetch(`${this._config.baseUrl}${url}`, buildOptions(accessToken));
138
+
139
+ if (firstResponse.status !== 401) {
140
+ const json = await firstResponse.json();
141
+ if (!firstResponse.ok) throw OAuthError.fromResponse(firstResponse.status, json);
142
+ return { data: json, updatedTokens: null };
143
+ }
144
+
145
+ // 401: attempt refresh — propagate OAuthError if refresh fails.
146
+ // Concurrent request() callers with the same refresh_token share one in-flight
147
+ // call so the server only sees one rotation (the second would get invalid_grant).
148
+ let pending = this._inFlightRefreshes.get(refreshToken);
149
+ if (!pending) {
150
+ pending = this.refreshAccessToken(refreshToken).finally(() => {
151
+ this._inFlightRefreshes.delete(refreshToken);
152
+ });
153
+ this._inFlightRefreshes.set(refreshToken, pending);
154
+ }
155
+ const newTokens = await pending;
156
+
157
+ if (onRefreshSuccess) onRefreshSuccess(newTokens);
158
+
159
+ const retryResponse = await fetch(`${this._config.baseUrl}${url}`, buildOptions(newTokens.access_token));
160
+ const retryJson = await retryResponse.json();
161
+ if (!retryResponse.ok) throw OAuthError.fromResponse(retryResponse.status, retryJson);
162
+ return { data: retryJson, updatedTokens: newTokens };
163
+ }
164
+
165
+ /**
166
+ * Decodes a JWT access token payload without verifying the signature.
167
+ * @param {string} accessToken
168
+ * @returns {import('./types').TokenPayload}
169
+ */
170
+ decodeAccessToken(accessToken) {
171
+ const segment = accessToken.split('.')[1];
172
+ if (!segment) throw new OAuthError('invalid_token', 'Malformed JWT', 400);
173
+ const b64 = segment.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(segment.length / 4) * 4, '=');
174
+ const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
175
+ const payload = JSON.parse(new TextDecoder().decode(bytes));
176
+ return payload;
177
+ }
178
+ }
179
+
180
+ module.exports = { OAuthClient };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ class OAuthError extends Error {
4
+ /**
5
+ * @param {string} code - RFC 6749 error code
6
+ * @param {string} description - Human-readable description
7
+ * @param {number} httpStatus - HTTP status code
8
+ * @param {unknown} [body] - Raw response body (parsed JSON when available)
9
+ */
10
+ constructor(code, description, httpStatus, body = null) {
11
+ super(description);
12
+ this.name = 'OAuthError';
13
+ this.code = code;
14
+ this.description = description;
15
+ this.httpStatus = httpStatus;
16
+ this.body = body;
17
+ }
18
+
19
+ /**
20
+ * @param {number} status
21
+ * @param {unknown} body
22
+ * @returns {OAuthError}
23
+ */
24
+ static fromResponse(status, body) {
25
+ if (body && typeof body === 'object' && typeof body.error === 'string') {
26
+ return new OAuthError(body.error, body.error_description ?? '', status, body);
27
+ }
28
+ return new OAuthError('server_error', 'Unexpected server response', status, body);
29
+ }
30
+ }
31
+
32
+ module.exports = { OAuthError };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const { OAuthError } = require('./errors');
4
+
5
+ /**
6
+ * @param {string} url
7
+ * @param {Record<string, string>} params
8
+ * @param {Record<string, string>} [headers]
9
+ * @returns {Promise<unknown>}
10
+ */
11
+ async function postForm(url, params, headers = {}) {
12
+ const body = new URLSearchParams(params).toString();
13
+ const response = await fetch(url, {
14
+ method: 'POST',
15
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
16
+ body,
17
+ });
18
+ const json = await response.json();
19
+ if (!response.ok) throw OAuthError.fromResponse(response.status, json);
20
+ return json;
21
+ }
22
+
23
+ /**
24
+ * @param {string} url
25
+ * @param {Record<string, string>} [headers]
26
+ * @returns {Promise<unknown>}
27
+ */
28
+ async function getJson(url, headers = {}) {
29
+ const response = await fetch(url, { headers });
30
+ const json = await response.json();
31
+ if (!response.ok) throw OAuthError.fromResponse(response.status, json);
32
+ return json;
33
+ }
34
+
35
+ /**
36
+ * @param {string} url
37
+ * @param {object} body
38
+ * @param {Record<string, string>} [headers]
39
+ * @returns {Promise<unknown>}
40
+ */
41
+ async function postJson(url, body, headers = {}) {
42
+ const response = await fetch(url, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json', ...headers },
45
+ body: JSON.stringify(body),
46
+ });
47
+ const json = await response.json();
48
+ if (!response.ok) throw OAuthError.fromResponse(response.status, json);
49
+ return json;
50
+ }
51
+
52
+ module.exports = { postForm, getJson, postJson };
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ const { OAuthClient } = require('./client');
4
+ const { OAuthError } = require('./errors');
5
+ const { OAuthErrorCode } = require('./types');
6
+
7
+ module.exports = { OAuthClient, OAuthError, OAuthErrorCode };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ // base64url-encode a Uint8Array without padding
4
+ function base64urlEncode(bytes) {
5
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
6
+ let result = '';
7
+ for (let i = 0; i < bytes.length; i += 3) {
8
+ const b0 = bytes[i];
9
+ const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
10
+ const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
11
+ result += chars[b0 >> 2];
12
+ result += chars[((b0 & 3) << 4) | (b1 >> 4)];
13
+ if (i + 1 < bytes.length) result += chars[((b1 & 0xf) << 2) | (b2 >> 6)];
14
+ if (i + 2 < bytes.length) result += chars[b2 & 0x3f];
15
+ }
16
+ return result;
17
+ }
18
+
19
+ /**
20
+ * Generates a 64-character PKCE code verifier from [A-Za-z0-9-._~].
21
+ * @returns {string}
22
+ */
23
+ function generateCodeVerifier() {
24
+ const bytes = new Uint8Array(48);
25
+ globalThis.crypto.getRandomValues(bytes);
26
+ return base64urlEncode(bytes).slice(0, 64);
27
+ }
28
+
29
+ /**
30
+ * Computes Base64URL(SHA256(verifier)) with no padding.
31
+ * @param {string} verifier
32
+ * @returns {Promise<string>}
33
+ */
34
+ async function generateCodeChallenge(verifier) {
35
+ const encoded = new TextEncoder().encode(verifier);
36
+ const hashBuf = await globalThis.crypto.subtle.digest('SHA-256', encoded);
37
+ return base64urlEncode(new Uint8Array(hashBuf));
38
+ }
39
+
40
+ /**
41
+ * Generates a cryptographically random 32-char hex state value.
42
+ * @returns {string}
43
+ */
44
+ function generateState() {
45
+ const bytes = new Uint8Array(16);
46
+ globalThis.crypto.getRandomValues(bytes);
47
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
48
+ }
49
+
50
+ module.exports = { generateCodeVerifier, generateCodeChallenge, generateState };