@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.
- package/README.md +27 -0
- package/dist/cjs/auth/authentication.js +74 -0
- package/dist/cjs/auth/index.js +24 -0
- package/dist/cjs/auth/oauth2/client-registration.js +44 -0
- package/dist/cjs/auth/oauth2/device-authorization.js +40 -0
- package/dist/cjs/auth/oauth2/errors.js +64 -0
- package/dist/cjs/auth/oauth2/http-utils.js +112 -0
- package/dist/cjs/auth/oauth2/index.js +42 -0
- package/dist/cjs/auth/oauth2/loopback-receiver.js +215 -0
- package/dist/cjs/auth/oauth2/metadata.js +34 -0
- package/dist/cjs/auth/oauth2/oauth2-authentication.js +74 -0
- package/dist/cjs/auth/oauth2/session-manager.js +762 -0
- package/dist/cjs/auth/oauth2/token-store.js +252 -0
- package/dist/cjs/auth/oauth2/token.js +76 -0
- package/dist/cjs/push/pb/Request.js +12 -1
- package/dist/cjs/push/proto-message.js +5 -1
- package/dist/cjs/push/push-client.js +29 -13
- package/dist/cjs/trade/trade-client.js +12 -3
- package/dist/cjs/version.js +1 -1
- package/dist/esm/auth/authentication.js +68 -0
- package/dist/esm/auth/index.js +3 -0
- package/dist/esm/auth/oauth2/client-registration.js +40 -0
- package/dist/esm/auth/oauth2/device-authorization.js +36 -0
- package/dist/esm/auth/oauth2/errors.js +59 -0
- package/dist/esm/auth/oauth2/http-utils.js +105 -0
- package/dist/esm/auth/oauth2/index.js +11 -0
- package/dist/esm/auth/oauth2/loopback-receiver.js +178 -0
- package/dist/esm/auth/oauth2/metadata.js +30 -0
- package/dist/esm/auth/oauth2/oauth2-authentication.js +70 -0
- package/dist/esm/auth/oauth2/session-manager.js +725 -0
- package/dist/esm/auth/oauth2/token-store.js +214 -0
- package/dist/esm/auth/oauth2/token.js +71 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/push/pb/Request.js +12 -1
- package/dist/esm/push/proto-message.js +5 -1
- package/dist/esm/push/push-client.js +29 -13
- package/dist/esm/trade/trade-client.js +12 -3
- package/dist/esm/version.js +1 -1
- package/dist/types/auth/authentication.d.ts +98 -0
- package/dist/types/auth/index.d.ts +4 -0
- package/dist/types/auth/oauth2/client-registration.d.ts +32 -0
- package/dist/types/auth/oauth2/device-authorization.d.ts +40 -0
- package/dist/types/auth/oauth2/errors.d.ts +46 -0
- package/dist/types/auth/oauth2/http-utils.d.ts +21 -0
- package/dist/types/auth/oauth2/index.d.ts +16 -0
- package/dist/types/auth/oauth2/loopback-receiver.d.ts +29 -0
- package/dist/types/auth/oauth2/metadata.d.ts +32 -0
- package/dist/types/auth/oauth2/oauth2-authentication.d.ts +31 -0
- package/dist/types/auth/oauth2/session-manager.d.ts +241 -0
- package/dist/types/auth/oauth2/token-store.d.ts +49 -0
- package/dist/types/auth/oauth2/token.d.ts +56 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/model/quote.d.ts +1 -0
- package/dist/types/model/trade-requests.d.ts +5 -0
- package/dist/types/push/index.d.ts +1 -1
- package/dist/types/push/pb/Request.d.ts +1 -0
- package/dist/types/push/proto-message.d.ts +4 -1
- package/dist/types/push/push-client.d.ts +33 -0
- package/dist/types/trade/trade-client.d.ts +12 -3
- package/dist/types/version.d.ts +1 -1
- package/package.json +6 -4
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Categorized errors from OAuth 2.0 authorization flows.
|
|
3
|
+
*
|
|
4
|
+
* Authorization server error details are retained in the error message.
|
|
5
|
+
*/
|
|
6
|
+
/** Error category. Each one implies a different action. */
|
|
7
|
+
export var OAuth2Category;
|
|
8
|
+
(function (OAuth2Category) {
|
|
9
|
+
/** Cannot fetch authorization server metadata: wrong address, service down, no network */
|
|
10
|
+
OAuth2Category["MetadataUnavailable"] = "METADATA_UNAVAILABLE";
|
|
11
|
+
/** No usable client_id, and automatic registration is not possible */
|
|
12
|
+
OAuth2Category["ClientRegistrationRequired"] = "CLIENT_REGISTRATION_REQUIRED";
|
|
13
|
+
/** Callback stage failed: timeout, user denial, or state mismatch */
|
|
14
|
+
OAuth2Category["CallbackFailed"] = "CALLBACK_FAILED";
|
|
15
|
+
/**
|
|
16
|
+
* Device flow not approved in time, or explicitly denied. Kept separate from
|
|
17
|
+
* ReauthorizationRequired because only a new device authorization is needed -- the
|
|
18
|
+
* stored token was not touched.
|
|
19
|
+
*/
|
|
20
|
+
OAuth2Category["DeviceAuthorizationFailed"] = "DEVICE_AUTHORIZATION_FAILED";
|
|
21
|
+
/** refresh_token expired, revoked, or never obtained. Retrying is pointless */
|
|
22
|
+
OAuth2Category["ReauthorizationRequired"] = "REAUTHORIZATION_REQUIRED";
|
|
23
|
+
/** Token endpoint call failed, and not in the terminal way above */
|
|
24
|
+
OAuth2Category["TokenRequestFailed"] = "TOKEN_REQUEST_FAILED";
|
|
25
|
+
/** Reading or writing the local token file failed */
|
|
26
|
+
OAuth2Category["StorageFailed"] = "STORAGE_FAILED";
|
|
27
|
+
})(OAuth2Category || (OAuth2Category = {}));
|
|
28
|
+
/** Thrown by any stage of the OAuth2 flow. */
|
|
29
|
+
export class OAuth2Error extends Error {
|
|
30
|
+
constructor(category, message, cause) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'OAuth2Error';
|
|
33
|
+
this.category = category;
|
|
34
|
+
this.cause = cause;
|
|
35
|
+
}
|
|
36
|
+
get reauthorizationRequired() {
|
|
37
|
+
return this.category === OAuth2Category.ReauthorizationRequired;
|
|
38
|
+
}
|
|
39
|
+
toString() {
|
|
40
|
+
return `OAuth2Error{${this.category}: ${this.message}}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A grant the authorization server refused.
|
|
45
|
+
*
|
|
46
|
+
* Carries the error code so a caller can tell a rejected credential from a rejected
|
|
47
|
+
* client registration. Its category is `ReauthorizationRequired`, so callers that only
|
|
48
|
+
* know the public type behave as they would for any terminal authorization failure.
|
|
49
|
+
*/
|
|
50
|
+
export class RejectedGrantError extends OAuth2Error {
|
|
51
|
+
constructor(error, description) {
|
|
52
|
+
super(OAuth2Category.ReauthorizationRequired, `token request rejected: ${error} ${description}`);
|
|
53
|
+
this.name = 'RejectedGrantError';
|
|
54
|
+
this.error = error;
|
|
55
|
+
}
|
|
56
|
+
get isClientRejected() {
|
|
57
|
+
return this.error === 'invalid_client';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP helpers for OAuth 2.0 JSON and form-encoded requests.
|
|
3
|
+
*
|
|
4
|
+
* OAuth error responses retain 4xx response bodies for protocol-level error handling.
|
|
5
|
+
*/
|
|
6
|
+
import { OAuth2Category, OAuth2Error } from './errors.js';
|
|
7
|
+
/**
|
|
8
|
+
* Every call here is a small JSON exchange with the authorization server, so a request
|
|
9
|
+
* that has not answered in this long is not going to. Same values as the Java and
|
|
10
|
+
* Python SDKs.
|
|
11
|
+
*/
|
|
12
|
+
export const CONNECT_TIMEOUT_SECONDS = 5;
|
|
13
|
+
export const READ_TIMEOUT_SECONDS = 10;
|
|
14
|
+
/** Combined budget handed to `AbortSignal.timeout`; fetch does not split the two. */
|
|
15
|
+
const REQUEST_TIMEOUT_MS = (CONNECT_TIMEOUT_SECONDS + READ_TIMEOUT_SECONDS) * 1000;
|
|
16
|
+
const JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
|
|
17
|
+
const FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded; charset=utf-8';
|
|
18
|
+
function encodeForm(form) {
|
|
19
|
+
const params = new URLSearchParams();
|
|
20
|
+
for (const [key, value] of Object.entries(form)) {
|
|
21
|
+
if (value !== undefined && value !== null) {
|
|
22
|
+
params.append(key, value);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return params.toString();
|
|
26
|
+
}
|
|
27
|
+
export async function getJson(url) {
|
|
28
|
+
return execute('GET', url, undefined, undefined);
|
|
29
|
+
}
|
|
30
|
+
export async function postJson(url, payload) {
|
|
31
|
+
return execute('POST', url, JSON.stringify(payload), JSON_CONTENT_TYPE);
|
|
32
|
+
}
|
|
33
|
+
export async function postForm(url, form) {
|
|
34
|
+
return execute('POST', url, encodeForm(form), FORM_CONTENT_TYPE);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Post a form and return the HTTP status without requiring a response body.
|
|
38
|
+
*
|
|
39
|
+
* RFC 7009 revocation returns an empty success body, so no parsing is performed.
|
|
40
|
+
*
|
|
41
|
+
* @returns the HTTP status code
|
|
42
|
+
* @throws OAuth2Error on connection failure
|
|
43
|
+
*/
|
|
44
|
+
export async function postFormForStatus(url, form) {
|
|
45
|
+
let response;
|
|
46
|
+
try {
|
|
47
|
+
response = await fetch(url, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { Accept: 'application/json', 'Content-Type': FORM_CONTENT_TYPE },
|
|
50
|
+
body: encodeForm(form),
|
|
51
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
throw new OAuth2Error(OAuth2Category.TokenRequestFailed, `request fail: ${url}`, e);
|
|
56
|
+
}
|
|
57
|
+
return response.status;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Send the request and parse the response body as an object.
|
|
61
|
+
*
|
|
62
|
+
* A 4xx returns its body instead of throwing: OAuth2 errors (`invalid_grant`,
|
|
63
|
+
* `authorization_pending` and friends) live in the 4xx body, and the caller needs the
|
|
64
|
+
* `error` field to tell "wait and retry" from "re-authorize". Only a connection failure
|
|
65
|
+
* or a non-JSON response throws.
|
|
66
|
+
*/
|
|
67
|
+
async function execute(method, url, body, contentType) {
|
|
68
|
+
const headers = { Accept: 'application/json' };
|
|
69
|
+
if (contentType) {
|
|
70
|
+
headers['Content-Type'] = contentType;
|
|
71
|
+
}
|
|
72
|
+
let response;
|
|
73
|
+
try {
|
|
74
|
+
response = await fetch(url, {
|
|
75
|
+
method,
|
|
76
|
+
headers,
|
|
77
|
+
body,
|
|
78
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
throw new OAuth2Error(OAuth2Category.MetadataUnavailable, `request fail: ${url}`, e);
|
|
83
|
+
}
|
|
84
|
+
let text;
|
|
85
|
+
try {
|
|
86
|
+
text = await response.text();
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
throw new OAuth2Error(OAuth2Category.TokenRequestFailed, `cannot decode response from ${url}, http status ${response.status}`, e);
|
|
90
|
+
}
|
|
91
|
+
if (!text) {
|
|
92
|
+
throw new OAuth2Error(OAuth2Category.TokenRequestFailed, `empty response from ${url}, http status ${response.status}`);
|
|
93
|
+
}
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(text);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
throw new OAuth2Error(OAuth2Category.TokenRequestFailed, `non-JSON response from ${url}, http status ${response.status}`, e);
|
|
100
|
+
}
|
|
101
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
102
|
+
throw new OAuth2Error(OAuth2Category.TokenRequestFailed, `unexpected response from ${url}, http status ${response.status}`);
|
|
103
|
+
}
|
|
104
|
+
return parsed;
|
|
105
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** OAuth 2.0 authentication, authorization flows, and token persistence. */
|
|
2
|
+
export { OAuth2ClientRegistration } from './client-registration.js';
|
|
3
|
+
export { OAuth2DeviceAuthorization, DEFAULT_DEVICE_INTERVAL_SECONDS } from './device-authorization.js';
|
|
4
|
+
export { OAuth2Category, OAuth2Error, RejectedGrantError } from './errors.js';
|
|
5
|
+
export { LoopbackReceiver, CALLBACK_PATH } from './loopback-receiver.js';
|
|
6
|
+
export { OAuth2Metadata } from './metadata.js';
|
|
7
|
+
export { OAuth2Authentication, BEARER_PREFIX } from './oauth2-authentication.js';
|
|
8
|
+
export { OAuth2SessionManager, BUILTIN_ISSUER, CLIENT_NAME, DEFAULT_AUTHORIZE_TIMEOUT_SECONDS, DEFAULT_MANUAL_CALLBACK_PORT, DEFAULT_REFRESH_AHEAD_MILLIS, ENV_ISSUER, MIN_USABLE_REMAINING_MILLIS, } from './session-manager.js';
|
|
9
|
+
export { OAuth2Token, TOKEN_SCHEMA_VERSION, currentMillis } from './token.js';
|
|
10
|
+
export { OAuth2TokenStore, DEFAULT_SURFACE, ENV_HOME, hashIssuer } from './token-store.js';
|
|
11
|
+
export { CONNECT_TIMEOUT_SECONDS, READ_TIMEOUT_SECONDS } from './http-utils.js';
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-use OAuth 2.0 loopback redirect receiver (RFC 8252).
|
|
3
|
+
*
|
|
4
|
+
* The receiver binds only to `127.0.0.1` to prevent authorization code exposure on the
|
|
5
|
+
* local network. Dynamic registration uses a system-assigned port as permitted by
|
|
6
|
+
* RFC 8252 §7.3; manual registration requires a port matching its redirect URI. The IP
|
|
7
|
+
* literal avoids the name-resolution risks described by RFC 8252 §8.3.
|
|
8
|
+
*/
|
|
9
|
+
import * as http from 'http';
|
|
10
|
+
import { OAuth2Category, OAuth2Error } from './errors.js';
|
|
11
|
+
export const CALLBACK_PATH = '/callback';
|
|
12
|
+
/** Inline SVG avoids external resources and font-dependent glyph rendering. */
|
|
13
|
+
function icon(color, path) {
|
|
14
|
+
return `<svg width='64' height='64' viewBox='0 0 64 64' aria-hidden='true'>`
|
|
15
|
+
+ `<circle cx='32' cy='32' r='29' fill='none' stroke='${color}' stroke-width='4'/>`
|
|
16
|
+
+ `<path d='${path}' fill='none' stroke='${color}' stroke-width='5'`
|
|
17
|
+
+ ` stroke-linecap='round' stroke-linejoin='round'/></svg>`;
|
|
18
|
+
}
|
|
19
|
+
/** Green tick. */
|
|
20
|
+
const ICON_SUCCESS = icon('#22c55e', 'M19 33l9.5 9.5L45 22');
|
|
21
|
+
/** Grey dash for the normal `access_denied` outcome. */
|
|
22
|
+
const ICON_CANCELLED = icon('#9ca3af', 'M20 32h24');
|
|
23
|
+
/** Red cross, for outcomes that really are failures. */
|
|
24
|
+
const ICON_FAILED = icon('#ef4444', 'M22 22l20 20M42 22L22 42');
|
|
25
|
+
/** English callback text, kept consistent with the Java and Python SDKs. */
|
|
26
|
+
const TEXT = {
|
|
27
|
+
title: 'Tiger OpenAPI',
|
|
28
|
+
success: ['Authorization successful', 'You can close this page and return to the application.'],
|
|
29
|
+
cancelled: ['Authorization cancelled', 'You declined this authorization. You can close this page.'],
|
|
30
|
+
failed: ['Authorization failed', 'Please return to the application and try again.'],
|
|
31
|
+
};
|
|
32
|
+
/** Classify `access_denied` as cancellation and other errors as failures. */
|
|
33
|
+
function outcomeOf(params) {
|
|
34
|
+
if ('code' in params)
|
|
35
|
+
return 'success';
|
|
36
|
+
if (params['error'] === 'access_denied')
|
|
37
|
+
return 'cancelled';
|
|
38
|
+
return 'failed';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Render a redacted callback outcome page.
|
|
42
|
+
*
|
|
43
|
+
* Callback parameters, including authorization codes and errors, are omitted because
|
|
44
|
+
* browser history, screenshots, and extensions may expose page content. Full parameters
|
|
45
|
+
* remain available to internal callback processing.
|
|
46
|
+
*/
|
|
47
|
+
function renderPage(params) {
|
|
48
|
+
const outcome = outcomeOf(params);
|
|
49
|
+
const svg = { success: ICON_SUCCESS, cancelled: ICON_CANCELLED, failed: ICON_FAILED }[outcome];
|
|
50
|
+
const [heading, detail] = TEXT[outcome];
|
|
51
|
+
return `<!DOCTYPE html><html><head><meta charset='utf-8'>`
|
|
52
|
+
+ `<meta name='viewport' content='width=device-width,initial-scale=1'>`
|
|
53
|
+
+ `<title>${TEXT['title']}</title></head>`
|
|
54
|
+
+ `<body style="margin:0;min-height:100vh;display:flex;align-items:center;`
|
|
55
|
+
+ `justify-content:center;`
|
|
56
|
+
+ `font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',`
|
|
57
|
+
+ `sans-serif;background:#fafafa;color:#1f2328">`
|
|
58
|
+
+ `<main style='text-align:center;padding:48px 24px;max-width:420px'>`
|
|
59
|
+
+ `${svg}`
|
|
60
|
+
+ `<h2 style='margin:24px 0 8px;font-size:20px;font-weight:600'>${heading}</h2>`
|
|
61
|
+
+ `<p style='margin:0;font-size:14px;line-height:1.6;color:#6b7280'>${detail}</p>`
|
|
62
|
+
+ `</main></body></html>`;
|
|
63
|
+
}
|
|
64
|
+
/** Single-shot local callback server. */
|
|
65
|
+
export class LoopbackReceiver {
|
|
66
|
+
constructor(server) {
|
|
67
|
+
this.port = 0;
|
|
68
|
+
this.params = {};
|
|
69
|
+
this.fired = false;
|
|
70
|
+
this.waiters = [];
|
|
71
|
+
this.server = server;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Bind the receiver. It must be listening before the authorization request is issued.
|
|
75
|
+
*
|
|
76
|
+
* @param port port to bind; 0 lets the system assign a free one
|
|
77
|
+
*/
|
|
78
|
+
static async start(port = 0) {
|
|
79
|
+
const server = http.createServer();
|
|
80
|
+
const receiver = new LoopbackReceiver(server);
|
|
81
|
+
server.on('request', (req, res) => {
|
|
82
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
83
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
84
|
+
res.writeHead(404, { 'Content-Length': '0' });
|
|
85
|
+
res.end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const snapshot = receiver.record(url.searchParams);
|
|
89
|
+
const body = Buffer.from(renderPage(snapshot), 'utf-8');
|
|
90
|
+
res.writeHead(200, {
|
|
91
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
92
|
+
'Content-Length': String(body.length),
|
|
93
|
+
});
|
|
94
|
+
res.end(body);
|
|
95
|
+
receiver.release(snapshot);
|
|
96
|
+
});
|
|
97
|
+
await new Promise((resolve, reject) => {
|
|
98
|
+
const onError = (e) => {
|
|
99
|
+
// A fixed port can conflict with another listener; system-assigned ports do not.
|
|
100
|
+
const hint = port === 0 ? '' : ` (port ${port} is in use; free it or set another callbackPort`
|
|
101
|
+
+ ' matching the redirect_uri registered for your client)';
|
|
102
|
+
reject(new OAuth2Error(OAuth2Category.CallbackFailed, `cannot bind loopback callback server on 127.0.0.1:${port}${hint}`, e));
|
|
103
|
+
};
|
|
104
|
+
server.once('error', onError);
|
|
105
|
+
server.listen(port, '127.0.0.1', () => {
|
|
106
|
+
server.removeListener('error', onError);
|
|
107
|
+
const address = server.address();
|
|
108
|
+
receiver.port = typeof address === 'object' && address ? address.port : port;
|
|
109
|
+
resolve();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
return receiver;
|
|
113
|
+
}
|
|
114
|
+
get redirectUri() {
|
|
115
|
+
return `http://127.0.0.1:${this.port}${CALLBACK_PATH}`;
|
|
116
|
+
}
|
|
117
|
+
get boundPort() {
|
|
118
|
+
return this.port;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Wait for the browser callback.
|
|
122
|
+
*
|
|
123
|
+
* @returns the query parameters carried by the callback
|
|
124
|
+
* @throws OAuth2Error on timeout
|
|
125
|
+
*/
|
|
126
|
+
waitForCallback(timeoutSeconds) {
|
|
127
|
+
if (this.fired) {
|
|
128
|
+
return Promise.resolve({ ...this.params });
|
|
129
|
+
}
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const timer = setTimeout(() => {
|
|
132
|
+
this.waiters = this.waiters.filter((w) => w !== onParams);
|
|
133
|
+
reject(new OAuth2Error(OAuth2Category.CallbackFailed, 'waiting for authorization callback timed out'));
|
|
134
|
+
}, timeoutSeconds * 1000);
|
|
135
|
+
const onParams = (params) => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
resolve(params);
|
|
138
|
+
};
|
|
139
|
+
this.waiters.push(onParams);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
close() {
|
|
143
|
+
try {
|
|
144
|
+
// The browser keeps its connection alive after the redirect, and close() alone
|
|
145
|
+
// waits for it. That would hold the port -- which matters for the fixed port a
|
|
146
|
+
// manually registered client has to reuse on the next authorization.
|
|
147
|
+
this.server.closeAllConnections?.();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Older runtimes have no such method; the idle timeout releases the port instead.
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
this.server.close();
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Already closed; nothing to release.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Record the callback params (first one wins) and return a snapshot of them. */
|
|
160
|
+
record(search) {
|
|
161
|
+
if (!this.fired) {
|
|
162
|
+
for (const [key, value] of search.entries()) {
|
|
163
|
+
this.params[key] = value;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { ...this.params };
|
|
167
|
+
}
|
|
168
|
+
release(snapshot) {
|
|
169
|
+
if (this.fired)
|
|
170
|
+
return;
|
|
171
|
+
this.fired = true;
|
|
172
|
+
const waiters = this.waiters;
|
|
173
|
+
this.waiters = [];
|
|
174
|
+
for (const waiter of waiters) {
|
|
175
|
+
waiter(snapshot);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization server metadata (RFC 8414).
|
|
3
|
+
*
|
|
4
|
+
* Endpoints always come from metadata, never hardcoded, so switching environments only
|
|
5
|
+
* means changing the issuer.
|
|
6
|
+
*/
|
|
7
|
+
export class OAuth2Metadata {
|
|
8
|
+
constructor(init = {}) {
|
|
9
|
+
this.issuer = init.issuer;
|
|
10
|
+
this.authorizationEndpoint = init.authorizationEndpoint;
|
|
11
|
+
this.tokenEndpoint = init.tokenEndpoint;
|
|
12
|
+
this.registrationEndpoint = init.registrationEndpoint;
|
|
13
|
+
this.deviceAuthorizationEndpoint = init.deviceAuthorizationEndpoint;
|
|
14
|
+
this.revocationEndpoint = init.revocationEndpoint;
|
|
15
|
+
this.scopesSupported = init.scopesSupported ?? [];
|
|
16
|
+
}
|
|
17
|
+
static fromJSON(data) {
|
|
18
|
+
if (!data)
|
|
19
|
+
return null;
|
|
20
|
+
return new OAuth2Metadata({
|
|
21
|
+
issuer: data.issuer,
|
|
22
|
+
authorizationEndpoint: data.authorization_endpoint,
|
|
23
|
+
tokenEndpoint: data.token_endpoint,
|
|
24
|
+
registrationEndpoint: data.registration_endpoint,
|
|
25
|
+
deviceAuthorizationEndpoint: data.device_authorization_endpoint,
|
|
26
|
+
revocationEndpoint: data.revocation_endpoint,
|
|
27
|
+
scopesSupported: data.scopes_supported ?? [],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Request authentication backed by an OAuth 2.0 session manager. */
|
|
2
|
+
import { AuthenticationAttempt, AuthenticationType, RetryDecision, } from '../authentication.js';
|
|
3
|
+
import { OAuth2Error } from './errors.js';
|
|
4
|
+
export const BEARER_PREFIX = 'Bearer ';
|
|
5
|
+
const UNAUTHORIZED_STATUS = 401;
|
|
6
|
+
/** Authenticate with an OAuth2 access token. */
|
|
7
|
+
export class OAuth2Authentication {
|
|
8
|
+
constructor(sessionManager) {
|
|
9
|
+
if (!sessionManager) {
|
|
10
|
+
throw new Error('OAuth2SessionManager is required');
|
|
11
|
+
}
|
|
12
|
+
this.sessions = sessionManager;
|
|
13
|
+
}
|
|
14
|
+
get authType() {
|
|
15
|
+
return AuthenticationType.OAuth2;
|
|
16
|
+
}
|
|
17
|
+
get sessionManager() {
|
|
18
|
+
return this.sessions;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Attach a Bearer token to the request context.
|
|
22
|
+
*
|
|
23
|
+
* Near-expiry refreshes use the session manager's single-flight synchronization.
|
|
24
|
+
* Missing authorization rejects with `ReauthorizationRequired` without starting an
|
|
25
|
+
* implicit browser flow.
|
|
26
|
+
*/
|
|
27
|
+
async apply(context) {
|
|
28
|
+
const accessToken = await this.sessions.getAccessToken();
|
|
29
|
+
context.authorizationHeader = BEARER_PREFIX + accessToken;
|
|
30
|
+
return new AuthenticationAttempt(AuthenticationType.OAuth2, accessToken);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Process HTTP 401 responses.
|
|
34
|
+
*
|
|
35
|
+
* HTTP 403 indicates insufficient permission and cannot be resolved by refreshing a
|
|
36
|
+
* token with the same scope. Server errors and timeouts are not credential failures.
|
|
37
|
+
*/
|
|
38
|
+
async onUnauthorized(attempt, statusCode) {
|
|
39
|
+
if (statusCode !== UNAUTHORIZED_STATUS) {
|
|
40
|
+
return RetryDecision.noRetry();
|
|
41
|
+
}
|
|
42
|
+
const credential = attempt?.credential;
|
|
43
|
+
try {
|
|
44
|
+
// Reuse a token already rotated elsewhere without another token request.
|
|
45
|
+
const refreshed = await this.sessions.refreshAfterUnauthorized(credential);
|
|
46
|
+
if (!refreshed || !refreshed.accessToken) {
|
|
47
|
+
return RetryDecision.noRetry();
|
|
48
|
+
}
|
|
49
|
+
if (credential !== undefined && refreshed.accessToken === credential) {
|
|
50
|
+
// Credential unchanged, so a retry would get the same 401.
|
|
51
|
+
return RetryDecision.noRetry();
|
|
52
|
+
}
|
|
53
|
+
return RetryDecision.retry(BEARER_PREFIX + refreshed.accessToken);
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
if (!(e instanceof OAuth2Error)) {
|
|
57
|
+
// Not an OAuth2Error, so most likely a defect in the refresh path.
|
|
58
|
+
console.warn('[TigerOpen] oauth2 refresh after 401 error', e);
|
|
59
|
+
}
|
|
60
|
+
return RetryDecision.noRetry();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The session is owned by the application and may still be in use, so it is not
|
|
65
|
+
* closed here.
|
|
66
|
+
*/
|
|
67
|
+
close() {
|
|
68
|
+
// Nothing to release.
|
|
69
|
+
}
|
|
70
|
+
}
|