@tigeropenapi/tigeropen 0.5.5 → 0.5.7

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 (61) hide show
  1. package/README.md +27 -0
  2. package/dist/cjs/auth/authentication.js +74 -0
  3. package/dist/cjs/auth/index.js +24 -0
  4. package/dist/cjs/auth/oauth2/client-registration.js +44 -0
  5. package/dist/cjs/auth/oauth2/device-authorization.js +40 -0
  6. package/dist/cjs/auth/oauth2/errors.js +64 -0
  7. package/dist/cjs/auth/oauth2/http-utils.js +112 -0
  8. package/dist/cjs/auth/oauth2/index.js +42 -0
  9. package/dist/cjs/auth/oauth2/loopback-receiver.js +215 -0
  10. package/dist/cjs/auth/oauth2/metadata.js +34 -0
  11. package/dist/cjs/auth/oauth2/oauth2-authentication.js +74 -0
  12. package/dist/cjs/auth/oauth2/session-manager.js +762 -0
  13. package/dist/cjs/auth/oauth2/token-store.js +252 -0
  14. package/dist/cjs/auth/oauth2/token.js +76 -0
  15. package/dist/cjs/push/pb/Request.js +12 -1
  16. package/dist/cjs/push/proto-message.js +5 -1
  17. package/dist/cjs/push/push-client.js +29 -13
  18. package/dist/cjs/trade/trade-client.js +12 -3
  19. package/dist/cjs/version.js +1 -1
  20. package/dist/esm/auth/authentication.js +68 -0
  21. package/dist/esm/auth/index.js +3 -0
  22. package/dist/esm/auth/oauth2/client-registration.js +40 -0
  23. package/dist/esm/auth/oauth2/device-authorization.js +36 -0
  24. package/dist/esm/auth/oauth2/errors.js +59 -0
  25. package/dist/esm/auth/oauth2/http-utils.js +105 -0
  26. package/dist/esm/auth/oauth2/index.js +11 -0
  27. package/dist/esm/auth/oauth2/loopback-receiver.js +178 -0
  28. package/dist/esm/auth/oauth2/metadata.js +30 -0
  29. package/dist/esm/auth/oauth2/oauth2-authentication.js +70 -0
  30. package/dist/esm/auth/oauth2/session-manager.js +725 -0
  31. package/dist/esm/auth/oauth2/token-store.js +214 -0
  32. package/dist/esm/auth/oauth2/token.js +71 -0
  33. package/dist/esm/package.json +3 -0
  34. package/dist/esm/push/pb/Request.js +12 -1
  35. package/dist/esm/push/proto-message.js +5 -1
  36. package/dist/esm/push/push-client.js +29 -13
  37. package/dist/esm/trade/trade-client.js +12 -3
  38. package/dist/esm/version.js +1 -1
  39. package/dist/types/auth/authentication.d.ts +98 -0
  40. package/dist/types/auth/index.d.ts +4 -0
  41. package/dist/types/auth/oauth2/client-registration.d.ts +32 -0
  42. package/dist/types/auth/oauth2/device-authorization.d.ts +40 -0
  43. package/dist/types/auth/oauth2/errors.d.ts +46 -0
  44. package/dist/types/auth/oauth2/http-utils.d.ts +21 -0
  45. package/dist/types/auth/oauth2/index.d.ts +16 -0
  46. package/dist/types/auth/oauth2/loopback-receiver.d.ts +29 -0
  47. package/dist/types/auth/oauth2/metadata.d.ts +32 -0
  48. package/dist/types/auth/oauth2/oauth2-authentication.d.ts +31 -0
  49. package/dist/types/auth/oauth2/session-manager.d.ts +241 -0
  50. package/dist/types/auth/oauth2/token-store.d.ts +49 -0
  51. package/dist/types/auth/oauth2/token.d.ts +56 -0
  52. package/dist/types/index.d.ts +1 -1
  53. package/dist/types/model/quote.d.ts +1 -0
  54. package/dist/types/model/trade-requests.d.ts +5 -0
  55. package/dist/types/push/index.d.ts +1 -1
  56. package/dist/types/push/pb/Request.d.ts +1 -0
  57. package/dist/types/push/proto-message.d.ts +4 -1
  58. package/dist/types/push/push-client.d.ts +33 -0
  59. package/dist/types/trade/trade-client.d.ts +12 -3
  60. package/dist/types/version.d.ts +1 -1
  61. package/package.json +6 -4
@@ -0,0 +1,725 @@
1
+ /**
2
+ * OAuth 2.0 authorization session management.
3
+ *
4
+ * The manager performs metadata discovery, dynamic registration, loopback or device
5
+ * authorization, token refresh, and persistence. Requested scopes are derived from
6
+ * `scopes_supported` metadata. Construction performs no network requests.
7
+ */
8
+ import { createHash, randomBytes } from 'crypto';
9
+ import { OAuth2ClientRegistration } from './client-registration.js';
10
+ import { OAuth2DeviceAuthorization } from './device-authorization.js';
11
+ import { OAuth2Category, OAuth2Error, RejectedGrantError } from './errors.js';
12
+ import * as httpUtils from './http-utils.js';
13
+ import { LoopbackReceiver } from './loopback-receiver.js';
14
+ import { OAuth2Metadata } from './metadata.js';
15
+ import { currentMillis, OAuth2Token } from './token.js';
16
+ import { DEFAULT_SURFACE, OAuth2TokenStore } from './token-store.js';
17
+ export const ENV_ISSUER = 'TIGEROPEN_OAUTH2_ISSUER';
18
+ /**
19
+ * Production authorization server. The environment variable or constructor argument
20
+ * overrides this value.
21
+ */
22
+ export const BUILTIN_ISSUER = 'https://openapi-oauth2.tigerfintech.com';
23
+ /** How early to refresh an access token. */
24
+ export const DEFAULT_REFRESH_AHEAD_MILLIS = 300 * 1000;
25
+ /** Remaining lifetime below which a token is no longer handed out. */
26
+ export const MIN_USABLE_REMAINING_MILLIS = 30 * 1000;
27
+ /** How long to wait for the browser callback. */
28
+ export const DEFAULT_AUTHORIZE_TIMEOUT_SECONDS = 5 * 60;
29
+ /** Added to the polling interval on each `slow_down`. */
30
+ const DEVICE_SLOW_DOWN_INCREMENT_SECONDS = 5;
31
+ /** Fallback when the device authorization response omits `expires_in`. */
32
+ const DEFAULT_DEVICE_EXPIRES_IN_SECONDS = 600;
33
+ /**
34
+ * Fixed callback port for a manually configured clientId. Chosen to stay clear of the
35
+ * default Linux ephemeral range (from 32768) and of common dev ports.
36
+ */
37
+ export const DEFAULT_MANUAL_CALLBACK_PORT = 18888;
38
+ const GRANT_TYPE_AUTHORIZATION_CODE = 'authorization_code';
39
+ const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token';
40
+ const GRANT_TYPE_DEVICE_CODE = 'urn:ietf:params:oauth:grant-type:device_code';
41
+ /**
42
+ * Application name registered by the SDK itself. Shown on the user's authorization
43
+ * page, so an entry point that registers its own client passes its own name.
44
+ */
45
+ export const CLIENT_NAME = 'Tiger TypeScript SDK';
46
+ /** OAuth 2.0 authorization session manager. */
47
+ export class OAuth2SessionManager {
48
+ constructor(options = {}) {
49
+ /** In-flight refresh, so concurrent callers share one token request (single-flight). */
50
+ this.refreshInFlight = null;
51
+ /** In-flight metadata discovery, so concurrent callers share one round trip. */
52
+ this.metadataInFlight = null;
53
+ this._issuer = trimTrailingSlash(options.issuer || OAuth2SessionManager.defaultIssuer());
54
+ this._callbackPort = options.callbackPort || 0;
55
+ this.explicitClientId = options.clientId || undefined;
56
+ this.authorizeTimeoutSeconds = options.authorizeTimeoutSeconds ?? DEFAULT_AUTHORIZE_TIMEOUT_SECONDS;
57
+ this.clientName = options.clientName || CLIENT_NAME;
58
+ this.store = options.store ?? new OAuth2TokenStore(options.home, options.surface ?? DEFAULT_SURFACE);
59
+ }
60
+ static defaultIssuer() {
61
+ return trimTrailingSlash(process.env[ENV_ISSUER] || BUILTIN_ISSUER);
62
+ }
63
+ get issuer() {
64
+ return this._issuer;
65
+ }
66
+ /** Currently known clientId; undefined before registration in dynamic mode. */
67
+ get clientId() {
68
+ return this.knownClientId();
69
+ }
70
+ get hasExplicitClientId() {
71
+ return this.explicitClientId !== undefined;
72
+ }
73
+ get callbackPort() {
74
+ return this._callbackPort;
75
+ }
76
+ /** Scopes requested by this session, space-separated. Fetches metadata. */
77
+ async scopes() {
78
+ return this.resolveScopes();
79
+ }
80
+ /**
81
+ * Create a default session and perform browser authorization when required.
82
+ *
83
+ * The callback receives the authorization URL; the SDK does not open a browser.
84
+ */
85
+ static async loginWithDefaults(onAuthorizationUrl) {
86
+ const sessions = new OAuth2SessionManager();
87
+ await sessions.loginIfNeeded(onAuthorizationUrl);
88
+ return sessions;
89
+ }
90
+ /** Create a default session and perform device authorization when required. */
91
+ static async loginWithDeviceCodeDefaults(onDeviceAuthorization) {
92
+ const sessions = new OAuth2SessionManager();
93
+ await sessions.loginWithDeviceCodeIfNeeded(onDeviceAuthorization);
94
+ return sessions;
95
+ }
96
+ /**
97
+ * Perform browser authorization with a loopback redirect and PKCE.
98
+ *
99
+ * @param onAuthorizationUrl callback receiving the authorization URL
100
+ */
101
+ async login(onAuthorizationUrl) {
102
+ const metadata = await this.metadataOf();
103
+ const clientId = await this.resolveClientId();
104
+ const scopes = await this.resolveScopes();
105
+ // The receiver must be listening before the authorization request is issued.
106
+ const receiver = await LoopbackReceiver.start(this.effectiveCallbackPort());
107
+ const verifier = randomUrlSafe(64);
108
+ const state = randomUrlSafe(24);
109
+ let params;
110
+ try {
111
+ const url = `${metadata.authorizationEndpoint}?` + query({
112
+ response_type: 'code',
113
+ client_id: clientId,
114
+ redirect_uri: receiver.redirectUri,
115
+ scope: scopes,
116
+ state,
117
+ code_challenge: s256(verifier),
118
+ code_challenge_method: 'S256',
119
+ });
120
+ await onAuthorizationUrl(url);
121
+ params = await receiver.waitForCallback(this.authorizeTimeoutSeconds);
122
+ }
123
+ finally {
124
+ receiver.close();
125
+ }
126
+ const error = params['error'];
127
+ if (error) {
128
+ throw new OAuth2Error(OAuth2Category.CallbackFailed, `authorization denied: ${error} ${params['error_description'] ?? ''}`);
129
+ }
130
+ if (params['state'] !== state) {
131
+ // A mismatch indicates that the callback does not belong to the active flow.
132
+ throw new OAuth2Error(OAuth2Category.CallbackFailed, 'state mismatch in authorization callback');
133
+ }
134
+ const code = params['code'];
135
+ if (!code) {
136
+ throw new OAuth2Error(OAuth2Category.CallbackFailed, 'authorization callback has no code');
137
+ }
138
+ let token;
139
+ try {
140
+ token = await this.exchange({
141
+ grant_type: GRANT_TYPE_AUTHORIZATION_CODE,
142
+ code,
143
+ redirect_uri: receiver.redirectUri,
144
+ client_id: clientId,
145
+ code_verifier: verifier,
146
+ }, scopes, clientId);
147
+ }
148
+ catch (e) {
149
+ // A refused authorization code says nothing about the persisted token, so that is
150
+ // left alone. A refused client registration is removed so it is not reused.
151
+ if (e instanceof RejectedGrantError) {
152
+ this.forgetRejectedRegistration(e);
153
+ }
154
+ throw e;
155
+ }
156
+ this.persist(token);
157
+ return token;
158
+ }
159
+ /**
160
+ * Use the existing local authorization if usable, otherwise run a full loopback +
161
+ * PKCE authorization.
162
+ */
163
+ async loginIfNeeded(onAuthorizationUrl) {
164
+ const reusable = await this.reusableToken();
165
+ return reusable ?? this.login(onAuthorizationUrl);
166
+ }
167
+ /**
168
+ * Reuse local authorization or perform device authorization (RFC 8628).
169
+ *
170
+ * Device authorization supports environments without a local browser. Unlike loopback
171
+ * authorization with PKCE, it has no redirect binding or proof that the initiating and
172
+ * redeeming clients are the same party, increasing phishing risk. Loopback
173
+ * authorization is preferred when a local browser is available.
174
+ */
175
+ async loginWithDeviceCodeIfNeeded(onDeviceAuthorization) {
176
+ const reusable = await this.reusableToken();
177
+ return reusable ?? this.loginWithDeviceCode(onDeviceAuthorization);
178
+ }
179
+ /**
180
+ * Perform device authorization as defined by RFC 8628.
181
+ *
182
+ * This flow is intended for environments without a local browser and has the security
183
+ * limitations documented by {@link loginWithDeviceCodeIfNeeded}.
184
+ */
185
+ async loginWithDeviceCode(onDeviceAuthorization) {
186
+ const metadata = await this.metadataOf();
187
+ if (!metadata.deviceAuthorizationEndpoint) {
188
+ throw new OAuth2Error(OAuth2Category.MetadataUnavailable, 'authorization server does not advertise a device_authorization_endpoint');
189
+ }
190
+ const clientId = await this.resolveClientId();
191
+ const scopes = await this.resolveScopes();
192
+ const device = await this.requestDeviceAuthorization(metadata, clientId, scopes);
193
+ await onDeviceAuthorization(device);
194
+ const token = await this.pollForDeviceToken(metadata, device, clientId, scopes);
195
+ this.persist(token);
196
+ return token;
197
+ }
198
+ /**
199
+ * Revoke authorization under RFC 7009 and delete the local token.
200
+ *
201
+ * Dynamic client registration is retained across logout. The local token is deleted
202
+ * even if server-side revocation fails.
203
+ *
204
+ * @returns true when server-side revocation succeeds; false when only local state is
205
+ * cleared and the server credential may remain valid
206
+ */
207
+ async logout() {
208
+ const clientId = this.knownClientId();
209
+ const revoked = await this.revoke(this.currentToken(), clientId);
210
+ if (clientId) {
211
+ this.store.deleteToken(clientId);
212
+ }
213
+ this.token = undefined;
214
+ return revoked;
215
+ }
216
+ /**
217
+ * Return a usable access token, refreshing near expiry.
218
+ *
219
+ * Throws `ReauthorizationRequired` when no authorization is available. Browser
220
+ * authorization is never started implicitly.
221
+ *
222
+ * A refresh being due does not make the caller wait for one. While the current token
223
+ * still has {@link MIN_USABLE_REMAINING_MILLIS} of life, a refresh already running
224
+ * elsewhere is left to finish and the current token is returned.
225
+ */
226
+ async getAccessToken() {
227
+ const token = this.currentToken();
228
+ if (!token || !token.accessToken) {
229
+ throw new OAuth2Error(OAuth2Category.ReauthorizationRequired, 'not authorized yet, call login() first');
230
+ }
231
+ if (!token.needsRefresh(DEFAULT_REFRESH_AHEAD_MILLIS)) {
232
+ return token.accessToken;
233
+ }
234
+ if (token.remainingMillis() > MIN_USABLE_REMAINING_MILLIS && this.refreshInFlight !== null) {
235
+ // Refresh due, token still good, and somebody else already has the exchange.
236
+ return token.accessToken;
237
+ }
238
+ const refreshed = await this.refresh(token.accessToken);
239
+ return refreshed.accessToken;
240
+ }
241
+ /** The current token, or undefined. Sends no requests and triggers no authorization. */
242
+ status() {
243
+ return this.currentToken();
244
+ }
245
+ /**
246
+ * Whether asking for a token now would trigger a refresh. Reads memory only.
247
+ *
248
+ * Exists so no other component needs a refresh-ahead window of its own.
249
+ */
250
+ shouldRefreshSoon() {
251
+ const token = this.currentToken();
252
+ return !!token && token.needsRefresh(DEFAULT_REFRESH_AHEAD_MILLIS);
253
+ }
254
+ isAuthorized() {
255
+ return this.currentToken() !== undefined;
256
+ }
257
+ /** Refresh because the token is close to expiry. */
258
+ refresh(failedAccessToken) {
259
+ return this.doRefresh(failedAccessToken, false);
260
+ }
261
+ /**
262
+ * Refresh after a 401 even when the token is not near expiry.
263
+ *
264
+ * @param failedAccessToken token used by the failed request; comparison with the
265
+ * current token detects rotation completed elsewhere and preserves single-flight
266
+ * refresh behavior
267
+ */
268
+ refreshAfterUnauthorized(failedAccessToken) {
269
+ return this.doRefresh(failedAccessToken, true);
270
+ }
271
+ /**
272
+ * Single-flight refresh: the first caller performs rotation while others await the
273
+ * same promise.
274
+ */
275
+ async doRefresh(failedAccessToken, force) {
276
+ while (this.refreshInFlight) {
277
+ // Re-run the guards once the running exchange settles: it may already have
278
+ // produced exactly the token this caller needs. Its failure belongs to whoever
279
+ // started it; this caller re-evaluates from the state it left behind.
280
+ try {
281
+ await this.refreshInFlight;
282
+ }
283
+ catch {
284
+ // Reported to the caller that owns the exchange.
285
+ }
286
+ // Another waiter may have claimed the next exchange while this one resumed, so
287
+ // the loop re-reads rather than falling straight through.
288
+ }
289
+ // No await between the loop exit and the claim below, so exactly one caller claims.
290
+ return this.refreshOnce(failedAccessToken, force);
291
+ }
292
+ refreshOnce(failedAccessToken, force) {
293
+ const current = this.currentToken();
294
+ if (!current) {
295
+ return Promise.reject(new OAuth2Error(OAuth2Category.ReauthorizationRequired, 'not authorized yet, call login() first'));
296
+ }
297
+ if (failedAccessToken !== undefined && current.accessToken !== failedAccessToken) {
298
+ // Already rotated elsewhere. Compares token values rather than timestamps: equal
299
+ // timestamps do not imply the same token.
300
+ return Promise.resolve(current);
301
+ }
302
+ if (!force && !current.needsRefresh(DEFAULT_REFRESH_AHEAD_MILLIS)) {
303
+ return Promise.resolve(current);
304
+ }
305
+ if (!current.hasRefreshToken()) {
306
+ return Promise.reject(new OAuth2Error(OAuth2Category.ReauthorizationRequired, 'no refresh_token available, re-authorization required'));
307
+ }
308
+ const running = this.exchangeRefreshToken(current)
309
+ .catch((e) => {
310
+ if (e instanceof RejectedGrantError) {
311
+ return this.recoverOrGiveUp(current, e);
312
+ }
313
+ throw e;
314
+ })
315
+ .finally(() => {
316
+ if (this.refreshInFlight === running) {
317
+ this.refreshInFlight = null;
318
+ }
319
+ });
320
+ this.refreshInFlight = running;
321
+ return running;
322
+ }
323
+ /**
324
+ * Handle a refresh the authorization server rejected.
325
+ *
326
+ * Refresh tokens rotate, so the rejected value may already have been replaced by
327
+ * another process sharing this store. The store is re-read before any local state is
328
+ * removed, and the authorization is treated as ended only once the newest stored
329
+ * credential is rejected too.
330
+ */
331
+ async recoverOrGiveUp(rejectedToken, rejected) {
332
+ const stored = this.reloadFromStore(rejectedToken.clientId);
333
+ const superseded = stored !== null && stored.accessToken !== rejectedToken.accessToken;
334
+ if (!superseded) {
335
+ // Nothing newer exists, so the rejection is about the authorization itself.
336
+ this.forgetRejectedAuthorization(rejected, rejectedToken);
337
+ throw rejected;
338
+ }
339
+ this.token = stored;
340
+ if (!stored.needsRefresh(DEFAULT_REFRESH_AHEAD_MILLIS)) {
341
+ // Already refreshed elsewhere, so no second request is needed.
342
+ return stored;
343
+ }
344
+ if (!stored.hasRefreshToken()) {
345
+ this.forgetRejectedAuthorization(rejected, rejectedToken);
346
+ throw rejected;
347
+ }
348
+ try {
349
+ return await this.exchangeRefreshToken(stored);
350
+ }
351
+ catch (e) {
352
+ // The credential adopted above was refused too.
353
+ if (e instanceof RejectedGrantError) {
354
+ this.forgetRejectedAuthorization(e, stored);
355
+ }
356
+ throw e;
357
+ }
358
+ }
359
+ /** Exchange the given token's refresh token for a new access token and persist it. */
360
+ async exchangeRefreshToken(base) {
361
+ const clientId = base.clientId || this.knownClientId();
362
+ const token = await this.exchange({
363
+ grant_type: GRANT_TYPE_REFRESH_TOKEN,
364
+ refresh_token: base.refreshToken,
365
+ client_id: clientId,
366
+ }, base.scope, clientId);
367
+ if (!token.refreshToken) {
368
+ // Preserve the existing refresh token when rotation omits a replacement.
369
+ token.refreshToken = base.refreshToken;
370
+ }
371
+ this.persist(token);
372
+ return token;
373
+ }
374
+ /**
375
+ * Read the stored token from disk, bypassing the in-memory copy.
376
+ *
377
+ * @returns the stored token, or null when there is none or it cannot be read
378
+ */
379
+ reloadFromStore(clientId) {
380
+ if (!clientId)
381
+ return null;
382
+ try {
383
+ return this.store.loadToken(clientId, this._issuer);
384
+ }
385
+ catch {
386
+ // An unreadable store tells us nothing about the grant, so the rejection stands.
387
+ return null;
388
+ }
389
+ }
390
+ /**
391
+ * Remove the local token, and the dynamic registration when that was refused.
392
+ *
393
+ * Only the credential that was refused is removed, and only while it is still the one
394
+ * on disk. Another process sharing the store may have replaced it in the meantime, and
395
+ * that replacement was never refused.
396
+ */
397
+ forgetRejectedAuthorization(rejected, rejectedToken) {
398
+ const clientId = rejectedToken?.clientId;
399
+ const stored = clientId ? this.reloadFromStore(clientId) : null;
400
+ if (stored !== null && stored.accessToken !== rejectedToken?.accessToken) {
401
+ // A newer credential was stored while this one was refused; keep it.
402
+ }
403
+ else if (clientId) {
404
+ this.store.deleteToken(clientId);
405
+ }
406
+ // The in-memory copy is the refused one either way. Dropping it makes the next call
407
+ // read whatever is on disk.
408
+ this.token = undefined;
409
+ this.forgetRejectedRegistration(rejected);
410
+ }
411
+ /**
412
+ * Remove a refused dynamic client registration so the next authorization registers a
413
+ * new one. Explicit clientIds are left alone.
414
+ */
415
+ forgetRejectedRegistration(rejected) {
416
+ if (rejected.isClientRejected && this.explicitClientId === undefined) {
417
+ this.store.deleteClientRegistration(this._issuer);
418
+ this.registeredClientId = undefined;
419
+ }
420
+ }
421
+ /**
422
+ * Return a reusable local token, refreshing it when required.
423
+ *
424
+ * @returns undefined when interactive authorization is required
425
+ */
426
+ async reusableToken() {
427
+ const existing = this.currentToken();
428
+ if (!existing || !existing.accessToken)
429
+ return undefined;
430
+ if (!existing.needsRefresh(DEFAULT_REFRESH_AHEAD_MILLIS))
431
+ return existing;
432
+ if (existing.hasRefreshToken()) {
433
+ try {
434
+ return await this.refresh(existing.accessToken);
435
+ }
436
+ catch (e) {
437
+ // Only terminal authorization errors require interactive authorization;
438
+ // transient network and server errors remain retryable.
439
+ if (!(e instanceof OAuth2Error) || e.category !== OAuth2Category.ReauthorizationRequired) {
440
+ throw e;
441
+ }
442
+ }
443
+ }
444
+ return undefined;
445
+ }
446
+ currentToken() {
447
+ if (this.token)
448
+ return this.token;
449
+ const clientId = this.knownClientId();
450
+ if (!clientId)
451
+ return undefined;
452
+ const token = this.store.loadToken(clientId, this._issuer);
453
+ if (token) {
454
+ this.token = token;
455
+ }
456
+ return token ?? undefined;
457
+ }
458
+ /**
459
+ * The known clientId, without triggering registration. Manual mode never reads
460
+ * `clients/`.
461
+ */
462
+ knownClientId() {
463
+ if (this.explicitClientId)
464
+ return this.explicitClientId;
465
+ if (this.registeredClientId)
466
+ return this.registeredClientId;
467
+ const clientId = this.store.loadClientId(this._issuer);
468
+ if (clientId) {
469
+ this.registeredClientId = clientId;
470
+ }
471
+ return clientId ?? undefined;
472
+ }
473
+ persist(token) {
474
+ this.token = token;
475
+ this.store.saveToken(token);
476
+ }
477
+ effectiveCallbackPort() {
478
+ if (this._callbackPort > 0)
479
+ return this._callbackPort;
480
+ // A manual clientId was registered by hand with a concrete redirect_uri the SDK
481
+ // cannot see, so both sides agree on this port; dynamic registration sends a
482
+ // portless redirect_uri and can let the system assign one.
483
+ return this.explicitClientId ? DEFAULT_MANUAL_CALLBACK_PORT : 0;
484
+ }
485
+ async resolveScopes() {
486
+ if (this.resolvedScopes !== undefined)
487
+ return this.resolvedScopes;
488
+ const metadata = await this.metadataOf();
489
+ if (!metadata.scopesSupported.length) {
490
+ // Missing scope metadata is rejected instead of inferring incomplete scopes.
491
+ throw new OAuth2Error(OAuth2Category.MetadataUnavailable, 'authorization server metadata has no scopes_supported');
492
+ }
493
+ this.resolvedScopes = normalizeScopes(metadata.scopesSupported.join(' '));
494
+ return this.resolvedScopes;
495
+ }
496
+ metadataOf() {
497
+ if (this.metadata)
498
+ return Promise.resolve(this.metadata);
499
+ if (this.metadataInFlight)
500
+ return this.metadataInFlight;
501
+ const url = `${this._issuer}/.well-known/oauth-authorization-server`;
502
+ const running = httpUtils.getJson(url)
503
+ .then((data) => {
504
+ const metadata = OAuth2Metadata.fromJSON(data);
505
+ if (!metadata || !metadata.authorizationEndpoint || !metadata.tokenEndpoint) {
506
+ throw new OAuth2Error(OAuth2Category.MetadataUnavailable, `incomplete authorization server metadata from ${url}`);
507
+ }
508
+ this.metadata = metadata;
509
+ return metadata;
510
+ })
511
+ .finally(() => {
512
+ if (this.metadataInFlight === running) {
513
+ this.metadataInFlight = null;
514
+ }
515
+ });
516
+ this.metadataInFlight = running;
517
+ return running;
518
+ }
519
+ /** Manual clientId > saved registration whose scopes still cover > register. */
520
+ async resolveClientId() {
521
+ if (this.explicitClientId)
522
+ return this.explicitClientId;
523
+ const required = await this.resolveScopes();
524
+ const registration = this.store.loadClientRegistration(this._issuer);
525
+ if (registration?.clientId && scopesCover(registration.registeredScopes, required)) {
526
+ this.registeredClientId = registration.clientId;
527
+ return registration.clientId;
528
+ }
529
+ return this.register(required);
530
+ }
531
+ /**
532
+ * Register a public client under RFC 7591.
533
+ *
534
+ * Registration omits scope. The granted scope set is stored separately so a wider
535
+ * requirement triggers re-registration instead of `invalid_scope`.
536
+ */
537
+ async register(scopes) {
538
+ const metadata = await this.metadataOf();
539
+ if (!metadata.registrationEndpoint) {
540
+ throw new OAuth2Error(OAuth2Category.ClientRegistrationRequired, 'authorization server does not support dynamic registration; pass clientId explicitly');
541
+ }
542
+ const port = this._callbackPort;
543
+ const redirectUri = port > 0 ? `http://127.0.0.1:${port}/callback` : 'http://127.0.0.1/callback';
544
+ const response = await httpUtils.postJson(metadata.registrationEndpoint, {
545
+ client_name: this.clientName,
546
+ redirect_uris: [redirectUri],
547
+ grant_types: [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN, GRANT_TYPE_DEVICE_CODE],
548
+ response_types: ['code'],
549
+ // Public client: there is no client_secret to hide, PKCE is the protection
550
+ token_endpoint_auth_method: 'none',
551
+ });
552
+ const error = response['error'];
553
+ if (error) {
554
+ throw new OAuth2Error(OAuth2Category.ClientRegistrationRequired, `dynamic registration failed: ${String(error)} ${String(response['error_description'] ?? '')}`);
555
+ }
556
+ const clientId = response['client_id'];
557
+ if (typeof clientId !== 'string' || !clientId) {
558
+ throw new OAuth2Error(OAuth2Category.ClientRegistrationRequired, 'dynamic registration response has no client_id');
559
+ }
560
+ this.store.saveClientRegistration(new OAuth2ClientRegistration({
561
+ issuer: this._issuer,
562
+ clientId,
563
+ clientName: this.clientName,
564
+ registeredScopes: normalizeScopes(response['scope']) ?? scopes,
565
+ clientIdIssuedAt: typeof response['client_id_issued_at'] === 'number'
566
+ ? response['client_id_issued_at']
567
+ : Math.floor(currentMillis() / 1000),
568
+ }));
569
+ this.registeredClientId = clientId;
570
+ return clientId;
571
+ }
572
+ async requestDeviceAuthorization(metadata, clientId, scopes) {
573
+ const response = await httpUtils.postForm(metadata.deviceAuthorizationEndpoint, {
574
+ client_id: clientId,
575
+ scope: scopes,
576
+ });
577
+ const error = response['error'];
578
+ if (error) {
579
+ throw new OAuth2Error(OAuth2Category.DeviceAuthorizationFailed, `device authorization failed: ${String(error)} ${String(response['error_description'] ?? '')}`);
580
+ }
581
+ const device = OAuth2DeviceAuthorization.fromJSON(response);
582
+ if (!device || !device.deviceCode || !device.userCode) {
583
+ throw new OAuth2Error(OAuth2Category.DeviceAuthorizationFailed, 'incomplete device authorization response');
584
+ }
585
+ return device;
586
+ }
587
+ /** Poll the token endpoint according to RFC 8628 response semantics. */
588
+ async pollForDeviceToken(metadata, device, clientId, scopes) {
589
+ let interval = device.effectiveIntervalSeconds();
590
+ const expiresIn = device.expiresIn || DEFAULT_DEVICE_EXPIRES_IN_SECONDS;
591
+ const deadline = currentMillis() + expiresIn * 1000;
592
+ for (;;) {
593
+ if (currentMillis() >= deadline) {
594
+ throw new OAuth2Error(OAuth2Category.DeviceAuthorizationFailed, 'device authorization was not approved in time');
595
+ }
596
+ await sleep(interval * 1000);
597
+ const response = await httpUtils.postForm(metadata.tokenEndpoint, {
598
+ grant_type: GRANT_TYPE_DEVICE_CODE,
599
+ device_code: device.deviceCode,
600
+ client_id: clientId,
601
+ });
602
+ const error = response['error'];
603
+ if (!error) {
604
+ return this.toToken(response, scopes, clientId);
605
+ }
606
+ if (error === 'authorization_pending')
607
+ continue;
608
+ if (error === 'slow_down') {
609
+ interval += DEVICE_SLOW_DOWN_INCREMENT_SECONDS;
610
+ continue;
611
+ }
612
+ if (error === 'access_denied') {
613
+ throw new OAuth2Error(OAuth2Category.DeviceAuthorizationFailed, 'device authorization was denied by the user');
614
+ }
615
+ if (error === 'expired_token') {
616
+ throw new OAuth2Error(OAuth2Category.DeviceAuthorizationFailed, 'device code expired before it was approved');
617
+ }
618
+ throw new OAuth2Error(OAuth2Category.TokenRequestFailed, `device token request failed: ${String(error)} ${String(response['error_description'] ?? '')}`);
619
+ }
620
+ }
621
+ async exchange(form, requestedScopes, clientId) {
622
+ const metadata = await this.metadataOf();
623
+ const response = await httpUtils.postForm(metadata.tokenEndpoint, form);
624
+ const error = response['error'];
625
+ if (error) {
626
+ const description = String(response['error_description'] ?? '');
627
+ if (error === 'invalid_grant' || error === 'invalid_client') {
628
+ // Reported only; removing local state is left to the caller.
629
+ throw new RejectedGrantError(String(error), description);
630
+ }
631
+ throw new OAuth2Error(OAuth2Category.TokenRequestFailed, `token request failed: ${String(error)} ${description}`);
632
+ }
633
+ return this.toToken(response, requestedScopes, clientId);
634
+ }
635
+ toToken(response, requestedScopes, clientId) {
636
+ const accessToken = response['access_token'];
637
+ if (typeof accessToken !== 'string' || !accessToken) {
638
+ throw new OAuth2Error(OAuth2Category.TokenRequestFailed, 'token response has no access_token');
639
+ }
640
+ const expiresIn = typeof response['expires_in'] === 'number' ? response['expires_in'] : 3600;
641
+ return new OAuth2Token({
642
+ issuer: this._issuer,
643
+ clientId,
644
+ accessToken,
645
+ refreshToken: typeof response['refresh_token'] === 'string' ? response['refresh_token'] : undefined,
646
+ tokenType: typeof response['token_type'] === 'string' ? response['token_type'] : 'Bearer',
647
+ // The response scope wins: what was granted may be narrower than requested
648
+ scope: normalizeScopes(response['scope']) ?? requestedScopes,
649
+ expiresAt: currentMillis() + expiresIn * 1000,
650
+ });
651
+ }
652
+ /**
653
+ * Attempt server-side revocation without throwing.
654
+ *
655
+ * The refresh token is preferred because RFC 7009 §2.1 permits revocation of its
656
+ * associated access tokens. RFC 7009 §2.2 also returns success for unknown or
657
+ * previously revoked tokens.
658
+ */
659
+ async revoke(current, clientId) {
660
+ if (!current || !clientId)
661
+ return false;
662
+ const value = current.refreshToken || current.accessToken;
663
+ const hint = current.refreshToken ? 'refresh_token' : 'access_token';
664
+ if (!value)
665
+ return false;
666
+ try {
667
+ const endpoint = (await this.metadataOf()).revocationEndpoint;
668
+ if (!endpoint) {
669
+ // Revocation is optional under RFC 8414.
670
+ return false;
671
+ }
672
+ const status = await httpUtils.postFormForStatus(endpoint, {
673
+ token: value,
674
+ token_type_hint: hint,
675
+ client_id: clientId,
676
+ });
677
+ // HTTP 400 and 401 indicate that the server rejected the client.
678
+ return status >= 200 && status < 300;
679
+ }
680
+ catch {
681
+ return false;
682
+ }
683
+ }
684
+ }
685
+ function scopesCover(granted, required) {
686
+ if (!granted || !required)
687
+ return false;
688
+ const grantedSet = new Set(granted.split(' '));
689
+ return required.split(' ').every((scope) => grantedSet.has(scope));
690
+ }
691
+ /**
692
+ * Split on whitespace, dedupe, sort, join with spaces.
693
+ *
694
+ * Sorting keeps {@link scopesCover} and the stored value order-independent: the
695
+ * authorization server does not guarantee a stable order, and without normalization
696
+ * that would spuriously trigger re-registration.
697
+ */
698
+ function normalizeScopes(raw) {
699
+ if (typeof raw !== 'string')
700
+ return undefined;
701
+ const parts = raw.trim().split(/\s+/).filter(Boolean);
702
+ if (!parts.length)
703
+ return undefined;
704
+ return Array.from(new Set(parts)).sort().join(' ');
705
+ }
706
+ function trimTrailingSlash(value) {
707
+ return value.endsWith('/') ? value.slice(0, -1) : value;
708
+ }
709
+ function query(params) {
710
+ const search = new URLSearchParams();
711
+ for (const [key, value] of Object.entries(params)) {
712
+ if (value !== undefined)
713
+ search.append(key, value);
714
+ }
715
+ return search.toString();
716
+ }
717
+ function randomUrlSafe(numBytes) {
718
+ return randomBytes(numBytes).toString('base64url');
719
+ }
720
+ function s256(verifier) {
721
+ return createHash('sha256').update(verifier, 'ascii').digest('base64url');
722
+ }
723
+ function sleep(ms) {
724
+ return new Promise((resolve) => setTimeout(resolve, ms));
725
+ }