@moxxy/plugin-oauth 0.0.33
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/LICENSE +21 -0
- package/dist/adapters/openai-device-flow.d.ts +34 -0
- package/dist/adapters/openai-device-flow.d.ts.map +1 -0
- package/dist/adapters/openai-device-flow.js +136 -0
- package/dist/adapters/openai-device-flow.js.map +1 -0
- package/dist/adapters/rfc8628-device-flow.d.ts +20 -0
- package/dist/adapters/rfc8628-device-flow.d.ts.map +1 -0
- package/dist/adapters/rfc8628-device-flow.js +54 -0
- package/dist/adapters/rfc8628-device-flow.js.map +1 -0
- package/dist/credential-lock.d.ts +60 -0
- package/dist/credential-lock.d.ts.map +1 -0
- package/dist/credential-lock.js +149 -0
- package/dist/credential-lock.js.map +1 -0
- package/dist/ensure-fresh.d.ts +63 -0
- package/dist/ensure-fresh.d.ts.map +1 -0
- package/dist/ensure-fresh.js +122 -0
- package/dist/ensure-fresh.js.map +1 -0
- package/dist/flow.d.ts +6 -0
- package/dist/flow.d.ts.map +1 -0
- package/dist/flow.js +5 -0
- package/dist/flow.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +82 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth/browser-flow.d.ts +23 -0
- package/dist/oauth/browser-flow.d.ts.map +1 -0
- package/dist/oauth/browser-flow.js +82 -0
- package/dist/oauth/browser-flow.js.map +1 -0
- package/dist/oauth/callback-server.d.ts +10 -0
- package/dist/oauth/callback-server.d.ts.map +1 -0
- package/dist/oauth/callback-server.js +158 -0
- package/dist/oauth/callback-server.js.map +1 -0
- package/dist/oauth/device-flow-shared.d.ts +39 -0
- package/dist/oauth/device-flow-shared.d.ts.map +1 -0
- package/dist/oauth/device-flow-shared.js +101 -0
- package/dist/oauth/device-flow-shared.js.map +1 -0
- package/dist/oauth/device-flow.d.ts +29 -0
- package/dist/oauth/device-flow.d.ts.map +1 -0
- package/dist/oauth/device-flow.js +74 -0
- package/dist/oauth/device-flow.js.map +1 -0
- package/dist/oauth/poll-until.d.ts +41 -0
- package/dist/oauth/poll-until.d.ts.map +1 -0
- package/dist/oauth/poll-until.js +70 -0
- package/dist/oauth/poll-until.js.map +1 -0
- package/dist/oauth/token-exchange.d.ts +28 -0
- package/dist/oauth/token-exchange.d.ts.map +1 -0
- package/dist/oauth/token-exchange.js +142 -0
- package/dist/oauth/token-exchange.js.map +1 -0
- package/dist/oauth/types.d.ts +90 -0
- package/dist/oauth/types.d.ts.map +1 -0
- package/dist/oauth/types.js +2 -0
- package/dist/oauth/types.js.map +1 -0
- package/dist/open-browser.d.ts +32 -0
- package/dist/open-browser.d.ts.map +1 -0
- package/dist/open-browser.js +83 -0
- package/dist/open-browser.js.map +1 -0
- package/dist/pkce.d.ts +20 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +34 -0
- package/dist/pkce.js.map +1 -0
- package/dist/profile.d.ts +129 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +11 -0
- package/dist/profile.js.map +1 -0
- package/dist/run-login.d.ts +9 -0
- package/dist/run-login.d.ts.map +1 -0
- package/dist/run-login.js +93 -0
- package/dist/run-login.js.map +1 -0
- package/dist/storage.d.ts +46 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +167 -0
- package/dist/storage.js.map +1 -0
- package/dist/tools.d.ts +8 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +283 -0
- package/dist/tools.js.map +1 -0
- package/package.json +73 -0
- package/skills/google-oauth.md +313 -0
- package/skills/oauth-flow.md +132 -0
- package/src/adapters/openai-device-flow.test.ts +191 -0
- package/src/adapters/openai-device-flow.ts +179 -0
- package/src/adapters/rfc8628-device-flow.ts +75 -0
- package/src/credential-lock.ts +183 -0
- package/src/discovery.test.ts +35 -0
- package/src/ensure-fresh.test.ts +279 -0
- package/src/ensure-fresh.ts +178 -0
- package/src/flow.ts +9 -0
- package/src/index.ts +153 -0
- package/src/oauth/browser-flow.ts +96 -0
- package/src/oauth/callback-server.test.ts +90 -0
- package/src/oauth/callback-server.ts +208 -0
- package/src/oauth/device-flow-shared.test.ts +142 -0
- package/src/oauth/device-flow-shared.ts +129 -0
- package/src/oauth/device-flow.test.ts +102 -0
- package/src/oauth/device-flow.ts +82 -0
- package/src/oauth/poll-until.test.ts +63 -0
- package/src/oauth/poll-until.ts +100 -0
- package/src/oauth/token-exchange.test.ts +224 -0
- package/src/oauth/token-exchange.ts +169 -0
- package/src/oauth/types.ts +92 -0
- package/src/oauth.test.ts +185 -0
- package/src/open-browser-spawn.test.ts +69 -0
- package/src/open-browser.test.ts +30 -0
- package/src/open-browser.ts +84 -0
- package/src/pkce.ts +37 -0
- package/src/profile.test.ts +331 -0
- package/src/profile.ts +135 -0
- package/src/run-login.ts +123 -0
- package/src/storage.test.ts +191 -0
- package/src/storage.ts +208 -0
- package/src/tools.test.ts +239 -0
- package/src/tools.ts +332 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moxxy (moxxy.ai)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-authorization adapter for OpenAI's non-standard flow (used by
|
|
3
|
+
* Codex/ChatGPT, opencode, codex-rs). Not RFC 8628 — speaks a JSON dialect:
|
|
4
|
+
*
|
|
5
|
+
* 1. POST `${issuer}/api/accounts/deviceauth/usercode` JSON `{client_id}`
|
|
6
|
+
* → `{device_auth_id, user_code, interval}`.
|
|
7
|
+
* 2. Surface `user_code` + a product-specific verification URI
|
|
8
|
+
* (`verificationUri`) to the user.
|
|
9
|
+
* 3. Poll `${issuer}/api/accounts/deviceauth/token` JSON
|
|
10
|
+
* `{device_auth_id, user_code}`. 403/404 ⇒ keep polling. 200 returns
|
|
11
|
+
* `{authorization_code, code_verifier}`.
|
|
12
|
+
* 4. Exchange that `authorization_code` + `code_verifier` for real tokens
|
|
13
|
+
* via the standard `/oauth/token` endpoint with
|
|
14
|
+
* `redirect_uri=${issuer}/deviceauth/callback` (not an actual redirect
|
|
15
|
+
* target — just a registered value the server expects to see).
|
|
16
|
+
*
|
|
17
|
+
* Reusable for any OpenAI-issued client; product-specific bits are the
|
|
18
|
+
* verification URI and the `client_id`.
|
|
19
|
+
*/
|
|
20
|
+
import type { DeviceFlowAdapter } from '../profile.js';
|
|
21
|
+
export interface OpenaiDeviceFlowOpts {
|
|
22
|
+
/** Auth issuer base URL, e.g. `https://auth.openai.com`. */
|
|
23
|
+
readonly issuer: string;
|
|
24
|
+
/** Standard OAuth token endpoint — `parseTokenResponse` consumes its reply. */
|
|
25
|
+
readonly tokenUrl: string;
|
|
26
|
+
/**
|
|
27
|
+
* URL the user opens to enter the device code. Product-specific —
|
|
28
|
+
* Codex uses `${issuer}/codex/device`. The init endpoint does NOT
|
|
29
|
+
* return one, so the caller supplies it.
|
|
30
|
+
*/
|
|
31
|
+
readonly verificationUri: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function openaiDeviceFlow(opts: OpenaiDeviceFlowOpts): DeviceFlowAdapter;
|
|
34
|
+
//# sourceMappingURL=openai-device-flow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openai-device-flow.d.ts","sourceRoot":"","sources":["../../src/adapters/openai-device-flow.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAKH,OAAO,KAAK,EACV,iBAAiB,EAGlB,MAAM,eAAe,CAAC;AAGvB,MAAM,WAAW,oBAAoB;IACnC,4DAA4D;IAC5D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAQD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,oBAAoB,GAAG,iBAAiB,CAiI9E"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-authorization adapter for OpenAI's non-standard flow (used by
|
|
3
|
+
* Codex/ChatGPT, opencode, codex-rs). Not RFC 8628 — speaks a JSON dialect:
|
|
4
|
+
*
|
|
5
|
+
* 1. POST `${issuer}/api/accounts/deviceauth/usercode` JSON `{client_id}`
|
|
6
|
+
* → `{device_auth_id, user_code, interval}`.
|
|
7
|
+
* 2. Surface `user_code` + a product-specific verification URI
|
|
8
|
+
* (`verificationUri`) to the user.
|
|
9
|
+
* 3. Poll `${issuer}/api/accounts/deviceauth/token` JSON
|
|
10
|
+
* `{device_auth_id, user_code}`. 403/404 ⇒ keep polling. 200 returns
|
|
11
|
+
* `{authorization_code, code_verifier}`.
|
|
12
|
+
* 4. Exchange that `authorization_code` + `code_verifier` for real tokens
|
|
13
|
+
* via the standard `/oauth/token` endpoint with
|
|
14
|
+
* `redirect_uri=${issuer}/deviceauth/callback` (not an actual redirect
|
|
15
|
+
* target — just a registered value the server expects to see).
|
|
16
|
+
*
|
|
17
|
+
* Reusable for any OpenAI-issued client; product-specific bits are the
|
|
18
|
+
* verification URI and the `client_id`.
|
|
19
|
+
*/
|
|
20
|
+
import { classifyHttpStatus, MoxxyError } from '@moxxy/sdk';
|
|
21
|
+
import { exchangeCodeForToken } from '../oauth/token-exchange.js';
|
|
22
|
+
export function openaiDeviceFlow(opts) {
|
|
23
|
+
const initUrl = `${opts.issuer}/api/accounts/deviceauth/usercode`;
|
|
24
|
+
const pollUrl = `${opts.issuer}/api/accounts/deviceauth/token`;
|
|
25
|
+
const exchangeRedirectUri = `${opts.issuer}/deviceauth/callback`;
|
|
26
|
+
return {
|
|
27
|
+
async start(args) {
|
|
28
|
+
const res = await fetch(initUrl, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ client_id: args.clientId }),
|
|
32
|
+
...(args.signal ? { signal: args.signal } : {}),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const text = await res.text().catch(() => '');
|
|
36
|
+
throw (classifyHttpStatus(res.status, { url: initUrl, body: text || res.statusText }) ??
|
|
37
|
+
new MoxxyError({
|
|
38
|
+
code: 'AUTH_INVALID',
|
|
39
|
+
message: `device auth init failed: ${res.status} ${text || res.statusText}`,
|
|
40
|
+
context: { status: res.status, url: initUrl },
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
const data = (await res.json().catch(() => {
|
|
44
|
+
throw new MoxxyError({
|
|
45
|
+
code: 'PROVIDER_UNKNOWN_RESPONSE',
|
|
46
|
+
message: 'device-auth init returned a non-JSON success body',
|
|
47
|
+
context: { url: initUrl },
|
|
48
|
+
});
|
|
49
|
+
}));
|
|
50
|
+
// Required-field validation: a 200 missing device_auth_id/user_code would
|
|
51
|
+
// otherwise produce a poll body of `{ device_auth_id: undefined }` that
|
|
52
|
+
// 403s forever until timeout. Reject clearly instead.
|
|
53
|
+
if (typeof data.device_auth_id !== 'string' || typeof data.user_code !== 'string') {
|
|
54
|
+
throw new MoxxyError({
|
|
55
|
+
code: 'PROVIDER_UNKNOWN_RESPONSE',
|
|
56
|
+
message: 'device-auth init response missing device_auth_id or user_code',
|
|
57
|
+
context: { url: initUrl },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// Coerce-then-validate: a malformed server value (e.g. interval:"" or a
|
|
61
|
+
// non-numeric string) parses to NaN, which would poison the poll timing
|
|
62
|
+
// (`setTimeout(_, NaN)` busy-loops; a NaN deadline aborts the flow). Only
|
|
63
|
+
// accept a finite number; otherwise fall back to the RFC-style defaults.
|
|
64
|
+
const rawInterval = typeof data.interval === 'string' ? parseInt(data.interval, 10) : data.interval;
|
|
65
|
+
const intervalSec = Math.max(Number.isFinite(rawInterval) ? rawInterval : 5, 1);
|
|
66
|
+
const rawExpiresIn = typeof data.expires_in === 'string' ? parseInt(data.expires_in, 10) : data.expires_in;
|
|
67
|
+
const expiresInSec = Number.isFinite(rawExpiresIn) ? rawExpiresIn : 600;
|
|
68
|
+
return {
|
|
69
|
+
userCode: data.user_code,
|
|
70
|
+
verificationUri: opts.verificationUri,
|
|
71
|
+
intervalMs: intervalSec * 1000,
|
|
72
|
+
expiresInMs: expiresInSec * 1000,
|
|
73
|
+
providerData: {
|
|
74
|
+
deviceAuthId: data.device_auth_id,
|
|
75
|
+
userCode: data.user_code,
|
|
76
|
+
clientId: args.clientId,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
async poll(init, state) {
|
|
81
|
+
const { deviceAuthId, userCode, clientId } = init.providerData;
|
|
82
|
+
const res = await fetch(pollUrl, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
|
|
86
|
+
...(state.signal ? { signal: state.signal } : {}),
|
|
87
|
+
});
|
|
88
|
+
if (res.ok) {
|
|
89
|
+
const data = (await res.json().catch(() => {
|
|
90
|
+
throw new MoxxyError({
|
|
91
|
+
code: 'PROVIDER_UNKNOWN_RESPONSE',
|
|
92
|
+
message: 'device-auth poll returned a non-JSON success body',
|
|
93
|
+
context: { url: pollUrl },
|
|
94
|
+
});
|
|
95
|
+
}));
|
|
96
|
+
// Required-field validation: without it a 200 omitting these would call
|
|
97
|
+
// exchangeCodeForToken with undefined, which URLSearchParams coerces to
|
|
98
|
+
// the literal 'undefined' — a wasted exchange that fails opaquely.
|
|
99
|
+
if (typeof data.authorization_code !== 'string' || typeof data.code_verifier !== 'string') {
|
|
100
|
+
throw new MoxxyError({
|
|
101
|
+
code: 'PROVIDER_UNKNOWN_RESPONSE',
|
|
102
|
+
message: 'device-auth poll response missing authorization_code or code_verifier',
|
|
103
|
+
context: { url: pollUrl },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Two-step: poll returns a server-side authorization_code we
|
|
107
|
+
// exchange via the standard token endpoint. The redirect_uri
|
|
108
|
+
// here is the issuer's device callback — a registered value,
|
|
109
|
+
// not a URI we listen on. Route through the shared exchange
|
|
110
|
+
// helper so this dialect can't drift from browser-flow.
|
|
111
|
+
return {
|
|
112
|
+
done: await exchangeCodeForToken({
|
|
113
|
+
tokenUrl: opts.tokenUrl,
|
|
114
|
+
code: data.authorization_code,
|
|
115
|
+
redirectUri: exchangeRedirectUri,
|
|
116
|
+
clientId,
|
|
117
|
+
codeVerifier: data.code_verifier,
|
|
118
|
+
...(state.signal ? { signal: state.signal } : {}),
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// OpenAI's "still waiting" signal — 403 or 404 with no further detail.
|
|
123
|
+
if (res.status === 403 || res.status === 404) {
|
|
124
|
+
return { pending: true };
|
|
125
|
+
}
|
|
126
|
+
const text = await res.text().catch(() => '');
|
|
127
|
+
throw (classifyHttpStatus(res.status, { url: pollUrl, body: text || res.statusText }) ??
|
|
128
|
+
new MoxxyError({
|
|
129
|
+
code: 'AUTH_INVALID',
|
|
130
|
+
message: `Device auth poll failed: ${res.status} ${text || res.statusText}`,
|
|
131
|
+
context: { status: res.status, url: pollUrl },
|
|
132
|
+
}));
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=openai-device-flow.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openai-device-flow.js","sourceRoot":"","sources":["../../src/adapters/openai-device-flow.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standards-compliant device-authorization adapter per RFC 8628.
|
|
3
|
+
*
|
|
4
|
+
* Phases:
|
|
5
|
+
* 1. POST `client_id` + `scope` to `deviceUrl` (form-encoded).
|
|
6
|
+
* 2. Surface `user_code` + `verification_uri` to the user.
|
|
7
|
+
* 3. Poll `tokenUrl` every `interval` with
|
|
8
|
+
* `grant_type=urn:ietf:params:oauth:grant-type:device_code` + `device_code`.
|
|
9
|
+
* 4. Handle `authorization_pending` / `slow_down` / fatal codes per spec.
|
|
10
|
+
*
|
|
11
|
+
* The device-authorization request + poll-response classification are shared
|
|
12
|
+
* with the legacy `runDeviceCodeFlow` via `oauth/device-flow-shared`.
|
|
13
|
+
*/
|
|
14
|
+
import type { DeviceFlowAdapter } from '../profile.js';
|
|
15
|
+
export interface Rfc8628AdapterOpts {
|
|
16
|
+
readonly deviceUrl: string;
|
|
17
|
+
readonly tokenUrl: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function rfc8628DeviceFlow(opts: Rfc8628AdapterOpts): DeviceFlowAdapter;
|
|
20
|
+
//# sourceMappingURL=rfc8628-device-flow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rfc8628-device-flow.d.ts","sourceRoot":"","sources":["../../src/adapters/rfc8628-device-flow.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAOH,OAAO,KAAK,EACV,iBAAiB,EAGlB,MAAM,eAAe,CAAC;AAGvB,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAMD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,kBAAkB,GAAG,iBAAiB,CAuC7E"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standards-compliant device-authorization adapter per RFC 8628.
|
|
3
|
+
*
|
|
4
|
+
* Phases:
|
|
5
|
+
* 1. POST `client_id` + `scope` to `deviceUrl` (form-encoded).
|
|
6
|
+
* 2. Surface `user_code` + `verification_uri` to the user.
|
|
7
|
+
* 3. Poll `tokenUrl` every `interval` with
|
|
8
|
+
* `grant_type=urn:ietf:params:oauth:grant-type:device_code` + `device_code`.
|
|
9
|
+
* 4. Handle `authorization_pending` / `slow_down` / fatal codes per spec.
|
|
10
|
+
*
|
|
11
|
+
* The device-authorization request + poll-response classification are shared
|
|
12
|
+
* with the legacy `runDeviceCodeFlow` via `oauth/device-flow-shared`.
|
|
13
|
+
*/
|
|
14
|
+
import { classifyDeviceTokenResponse, requestDeviceAuthorization, } from '../oauth/device-flow-shared.js';
|
|
15
|
+
export function rfc8628DeviceFlow(opts) {
|
|
16
|
+
return {
|
|
17
|
+
async start(args) {
|
|
18
|
+
const auth = await requestDeviceAuthorization({
|
|
19
|
+
deviceUrl: opts.deviceUrl,
|
|
20
|
+
clientId: args.clientId,
|
|
21
|
+
scopes: args.scopes,
|
|
22
|
+
...(args.signal ? { signal: args.signal } : {}),
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
userCode: auth.userCode,
|
|
26
|
+
verificationUri: auth.verificationUri,
|
|
27
|
+
...(auth.verificationUriComplete
|
|
28
|
+
? { verificationUriComplete: auth.verificationUriComplete }
|
|
29
|
+
: {}),
|
|
30
|
+
intervalMs: auth.intervalSec * 1000,
|
|
31
|
+
expiresInMs: auth.expiresInSec * 1000,
|
|
32
|
+
providerData: { deviceCode: auth.deviceCode },
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
async poll(init, state) {
|
|
36
|
+
const { deviceCode } = init.providerData;
|
|
37
|
+
const body = new URLSearchParams();
|
|
38
|
+
body.set('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
|
|
39
|
+
body.set('device_code', deviceCode);
|
|
40
|
+
const res = await fetch(opts.tokenUrl, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: {
|
|
43
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
44
|
+
Accept: 'application/json',
|
|
45
|
+
},
|
|
46
|
+
body: body.toString(),
|
|
47
|
+
...(state.signal ? { signal: state.signal } : {}),
|
|
48
|
+
});
|
|
49
|
+
const json = (await res.json().catch(() => ({})));
|
|
50
|
+
return classifyDeviceTokenResponse(res, json, state);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=rfc8628-device-flow.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rfc8628-device-flow.js","sourceRoot":"","sources":["../../src/adapters/rfc8628-device-flow.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EACL,2BAA2B,EAC3B,0BAA0B,GAC3B,MAAM,gCAAgC,CAAC;AAkBxC,MAAM,UAAU,iBAAiB,CAAC,IAAwB;IACxD,OAAO;QACL,KAAK,CAAC,KAAK,CAAC,IAAyB;YACnC,MAAM,IAAI,GAAG,MAAM,0BAA0B,CAAC;gBAC5C,SAAS,EAAE,IAAI,CAAC,SAAS;gBACzB,QAAQ,EAAE,IAAI,CAAC,QAAQ;gBACvB,MAAM,EAAE,IAAI,CAAC,MAAM;gBACnB,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAChD,CAAC,CAAC;YACH,OAAO;gBACL,QAAQ,EAAE,IAAI,CAAC,QAAQ;gBACvB,eAAe,EAAE,IAAI,CAAC,eAAe;gBACrC,GAAG,CAAC,IAAI,CAAC,uBAAuB;oBAC9B,CAAC,CAAC,EAAE,uBAAuB,EAAE,IAAI,CAAC,uBAAuB,EAAE;oBAC3D,CAAC,CAAC,EAAE,CAAC;gBACP,UAAU,EAAE,IAAI,CAAC,WAAW,GAAG,IAAI;gBACnC,WAAW,EAAE,IAAI,CAAC,YAAY,GAAG,IAAI;gBACrC,YAAY,EAAE,EAAE,UAAU,EAAE,IAAI,CAAC,UAAU,EAAyB;aACrE,CAAC;QACJ,CAAC;QAED,KAAK,CAAC,IAAI,CAAC,IAAoB,EAAE,KAAgB;YAC/C,MAAM,EAAE,UAAU,EAAE,GAAG,IAAI,CAAC,YAA4B,CAAC;YACzD,MAAM,IAAI,GAAG,IAAI,eAAe,EAAE,CAAC;YACnC,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,8CAA8C,CAAC,CAAC;YACvE,IAAI,CAAC,GAAG,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC;YACpC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,QAAQ,EAAE;gBACrC,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE;oBACP,cAAc,EAAE,mCAAmC;oBACnD,MAAM,EAAE,kBAAkB;iBAC3B;gBACD,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE;gBACrB,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAClD,CAAC,CAAC;YACH,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAA4B,CAAC;YAC7E,OAAO,2BAA2B,CAAC,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QACvD,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-credential refresh serialization.
|
|
3
|
+
*
|
|
4
|
+
* OAuth providers with ROTATING single-use refresh tokens (Anthropic's Claude
|
|
5
|
+
* subscription, OpenAI's Codex/ChatGPT plan) invalidate the previous
|
|
6
|
+
* refresh_token on every refresh. Two concurrent refreshes with the same
|
|
7
|
+
* stored token therefore race: one wins, the other burns a now-dead token and
|
|
8
|
+
* logs the user out. `withCredentialLock` serializes "refresh + persist"
|
|
9
|
+
* critical sections at two levels:
|
|
10
|
+
*
|
|
11
|
+
* 1. In-process: a per-key `Mutex` (promise chain) so concurrent consumers
|
|
12
|
+
* in one process (e.g. the chat provider and the whisper-stt transcriber
|
|
13
|
+
* sharing the codex credential) coalesce into a single refresh — the
|
|
14
|
+
* followers re-read the vault under the lock and reuse the winner's
|
|
15
|
+
* rotated tokens.
|
|
16
|
+
* 2. Cross-process: a best-effort O_EXCL lockfile under
|
|
17
|
+
* `<moxxy home>/locks/` with stale-lock takeover, so a TUI and a desktop
|
|
18
|
+
* runner refreshing the same credential queue up instead of racing.
|
|
19
|
+
*
|
|
20
|
+
* The file lock is deliberately best-effort: if the lock directory is
|
|
21
|
+
* unusable, or a live-but-slow holder keeps it past `waitMs`, we proceed
|
|
22
|
+
* WITHOUT the lock rather than deadlocking auth — the vault's read-merge-write
|
|
23
|
+
* persistence and the callers' invalid_grant re-read-retry recovery keep the
|
|
24
|
+
* losing side recoverable even then.
|
|
25
|
+
*/
|
|
26
|
+
export interface CredentialLockOptions {
|
|
27
|
+
/** Directory holding the lock files. Default `<moxxy home>/locks`. */
|
|
28
|
+
readonly dir?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Take over a lock file whose mtime is older than this (holder crashed
|
|
31
|
+
* without releasing). Default 60s — comfortably above a worst-case retried
|
|
32
|
+
* token refresh.
|
|
33
|
+
*/
|
|
34
|
+
readonly staleMs?: number;
|
|
35
|
+
/** Poll interval while waiting on a held lock. Default 150ms. */
|
|
36
|
+
readonly pollMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Max time to wait for the file lock before proceeding WITHOUT it
|
|
39
|
+
* (best effort — never deadlock auth on a wedged lock). Default 30s.
|
|
40
|
+
*/
|
|
41
|
+
readonly waitMs?: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Run `fn` while holding the per-credential lock (in-process mutex +
|
|
45
|
+
* best-effort cross-process lockfile). Callers should RE-READ their stored
|
|
46
|
+
* credential inside `fn` — a queued waiter usually finds the winner's
|
|
47
|
+
* freshly rotated tokens and can skip its own refresh entirely.
|
|
48
|
+
*/
|
|
49
|
+
export declare function withCredentialLock<T>(key: string, fn: () => Promise<T>, opts?: CredentialLockOptions): Promise<T>;
|
|
50
|
+
/**
|
|
51
|
+
* True for deterministic credential rejections from a token endpoint, as
|
|
52
|
+
* opposed to transient network/5xx failures. Covers the AUTH_* codes (401/403
|
|
53
|
+
* and refresh-specific failures) plus PROVIDER_BAD_REQUEST — `invalid_grant`
|
|
54
|
+
* is canonically an HTTP 400 (RFC 6749 §5.2), which `classifyHttpStatus` maps
|
|
55
|
+
* there. Used to decide whether a failed refresh is worth retrying with a
|
|
56
|
+
* fresher refresh_token re-read from the vault (another process may have
|
|
57
|
+
* rotated ours away).
|
|
58
|
+
*/
|
|
59
|
+
export declare function isAuthRejection(err: unknown): boolean;
|
|
60
|
+
//# sourceMappingURL=credential-lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential-lock.d.ts","sourceRoot":"","sources":["../src/credential-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAQH,MAAM,WAAW,qBAAqB;IACpC,sEAAsE;IACtE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,iEAAiE;IACjE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAYD;;;;;GAKG;AACH,wBAAsB,kBAAkB,CAAC,CAAC,EACxC,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,IAAI,GAAE,qBAA0B,GAC/B,OAAO,CAAC,CAAC,CAAC,CAoBZ;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAQrD"}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-credential refresh serialization.
|
|
3
|
+
*
|
|
4
|
+
* OAuth providers with ROTATING single-use refresh tokens (Anthropic's Claude
|
|
5
|
+
* subscription, OpenAI's Codex/ChatGPT plan) invalidate the previous
|
|
6
|
+
* refresh_token on every refresh. Two concurrent refreshes with the same
|
|
7
|
+
* stored token therefore race: one wins, the other burns a now-dead token and
|
|
8
|
+
* logs the user out. `withCredentialLock` serializes "refresh + persist"
|
|
9
|
+
* critical sections at two levels:
|
|
10
|
+
*
|
|
11
|
+
* 1. In-process: a per-key `Mutex` (promise chain) so concurrent consumers
|
|
12
|
+
* in one process (e.g. the chat provider and the whisper-stt transcriber
|
|
13
|
+
* sharing the codex credential) coalesce into a single refresh — the
|
|
14
|
+
* followers re-read the vault under the lock and reuse the winner's
|
|
15
|
+
* rotated tokens.
|
|
16
|
+
* 2. Cross-process: a best-effort O_EXCL lockfile under
|
|
17
|
+
* `<moxxy home>/locks/` with stale-lock takeover, so a TUI and a desktop
|
|
18
|
+
* runner refreshing the same credential queue up instead of racing.
|
|
19
|
+
*
|
|
20
|
+
* The file lock is deliberately best-effort: if the lock directory is
|
|
21
|
+
* unusable, or a live-but-slow holder keeps it past `waitMs`, we proceed
|
|
22
|
+
* WITHOUT the lock rather than deadlocking auth — the vault's read-merge-write
|
|
23
|
+
* persistence and the callers' invalid_grant re-read-retry recovery keep the
|
|
24
|
+
* losing side recoverable even then.
|
|
25
|
+
*/
|
|
26
|
+
import { mkdir, open, rename, rm, stat } from 'node:fs/promises';
|
|
27
|
+
import { randomBytes } from 'node:crypto';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { MoxxyError, createMutex } from '@moxxy/sdk';
|
|
30
|
+
import { moxxyPath } from '@moxxy/sdk/server';
|
|
31
|
+
const DEFAULT_STALE_MS = 60_000;
|
|
32
|
+
const DEFAULT_POLL_MS = 150;
|
|
33
|
+
const DEFAULT_WAIT_MS = 30_000;
|
|
34
|
+
/**
|
|
35
|
+
* In-process locks, keyed by sanitized credential key. Module-level so every
|
|
36
|
+
* consumer of this package instance (providers, stt, tools) shares them.
|
|
37
|
+
*/
|
|
38
|
+
const inProcessLocks = new Map();
|
|
39
|
+
/**
|
|
40
|
+
* Run `fn` while holding the per-credential lock (in-process mutex +
|
|
41
|
+
* best-effort cross-process lockfile). Callers should RE-READ their stored
|
|
42
|
+
* credential inside `fn` — a queued waiter usually finds the winner's
|
|
43
|
+
* freshly rotated tokens and can skip its own refresh entirely.
|
|
44
|
+
*/
|
|
45
|
+
export async function withCredentialLock(key, fn, opts = {}) {
|
|
46
|
+
const safe = sanitizeKey(key);
|
|
47
|
+
let mutex = inProcessLocks.get(safe);
|
|
48
|
+
if (!mutex) {
|
|
49
|
+
mutex = createMutex();
|
|
50
|
+
inProcessLocks.set(safe, mutex);
|
|
51
|
+
}
|
|
52
|
+
return mutex.run(async () => {
|
|
53
|
+
const release = await acquireFileLock(join(opts.dir ?? moxxyPath('locks'), `${safe}.lock`), opts.staleMs ?? DEFAULT_STALE_MS, opts.pollMs ?? DEFAULT_POLL_MS, opts.waitMs ?? DEFAULT_WAIT_MS);
|
|
54
|
+
try {
|
|
55
|
+
return await fn();
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
await release();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* True for deterministic credential rejections from a token endpoint, as
|
|
64
|
+
* opposed to transient network/5xx failures. Covers the AUTH_* codes (401/403
|
|
65
|
+
* and refresh-specific failures) plus PROVIDER_BAD_REQUEST — `invalid_grant`
|
|
66
|
+
* is canonically an HTTP 400 (RFC 6749 §5.2), which `classifyHttpStatus` maps
|
|
67
|
+
* there. Used to decide whether a failed refresh is worth retrying with a
|
|
68
|
+
* fresher refresh_token re-read from the vault (another process may have
|
|
69
|
+
* rotated ours away).
|
|
70
|
+
*/
|
|
71
|
+
export function isAuthRejection(err) {
|
|
72
|
+
return (err instanceof MoxxyError &&
|
|
73
|
+
(err.code === 'AUTH_INVALID' ||
|
|
74
|
+
err.code === 'AUTH_DENIED' ||
|
|
75
|
+
err.code === 'AUTH_EXPIRED' ||
|
|
76
|
+
err.code === 'PROVIDER_BAD_REQUEST'));
|
|
77
|
+
}
|
|
78
|
+
function sanitizeKey(key) {
|
|
79
|
+
return key.replace(/[^a-zA-Z0-9._-]+/g, '_');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* O_EXCL ('wx') lockfile acquisition with stale takeover. Returns a release
|
|
83
|
+
* function. Never throws: an unusable lock dir degrades to running unlocked.
|
|
84
|
+
*/
|
|
85
|
+
async function acquireFileLock(lockPath, staleMs, pollMs, waitMs) {
|
|
86
|
+
const deadline = Date.now() + waitMs;
|
|
87
|
+
try {
|
|
88
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return async () => { };
|
|
92
|
+
}
|
|
93
|
+
for (;;) {
|
|
94
|
+
try {
|
|
95
|
+
const handle = await open(lockPath, 'wx');
|
|
96
|
+
try {
|
|
97
|
+
await handle.writeFile(`${process.pid} ${new Date().toISOString()}\n`, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await handle.close().catch(() => { });
|
|
101
|
+
}
|
|
102
|
+
return async () => {
|
|
103
|
+
await rm(lockPath, { force: true }).catch(() => { });
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (err.code !== 'EEXIST') {
|
|
108
|
+
// Read-only FS, permissions, … — degrade to unlocked rather than
|
|
109
|
+
// blocking the user's auth on infrastructure trouble.
|
|
110
|
+
return async () => { };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Lock held — take over if the holder looks dead, else wait and re-try.
|
|
114
|
+
// Takeover is made atomic via rename() to dodge the rm()+open() TOCTOU: a
|
|
115
|
+
// naive rm() lets two processes both judge the same lock stale and both
|
|
116
|
+
// delete it, and one could unlink a holder's freshly-recreated lock. POSIX
|
|
117
|
+
// rename is atomic and single-winner — only one racer's rename of the SAME
|
|
118
|
+
// inode succeeds; the loser's rename fails (ENOENT/the file moved) and it
|
|
119
|
+
// spins to re-acquire instead of clobbering the winner. We rename to a
|
|
120
|
+
// unique temp then rm that temp, so the original path is freed for the
|
|
121
|
+
// winner's `open('wx')` on the next spin. The file lock stays best-effort
|
|
122
|
+
// (see the module header): any failure degrades to the unlocked-but-
|
|
123
|
+
// recoverable path guarded by the in-process mutex + invalid_grant retry.
|
|
124
|
+
try {
|
|
125
|
+
const st = await stat(lockPath);
|
|
126
|
+
if (Date.now() - st.mtimeMs > staleMs) {
|
|
127
|
+
const tmp = `${lockPath}.stale-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
128
|
+
try {
|
|
129
|
+
await rename(lockPath, tmp);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
continue; // lost the takeover race (someone moved/recreated it) — respin
|
|
133
|
+
}
|
|
134
|
+
await rm(tmp, { force: true }).catch(() => { });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue; // released between open() and stat() — grab it on the next spin
|
|
140
|
+
}
|
|
141
|
+
if (Date.now() >= deadline)
|
|
142
|
+
return async () => { };
|
|
143
|
+
await sleep(pollMs);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function sleep(ms) {
|
|
147
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=credential-lock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential-lock.js","sourceRoot":"","sources":["../src/credential-lock.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-request token freshness helper. Reads stored creds, refreshes when
|
|
3
|
+
* within `skewMs` of expiry (or forced), persists the rotated tokens BEFORE
|
|
4
|
+
* returning so a crash mid-flight can't strand a single-use refresh_token.
|
|
5
|
+
*
|
|
6
|
+
* The refresh + persist critical section runs under the per-credential lock
|
|
7
|
+
* (`withCredentialLock`): concurrent consumers — in this process or another
|
|
8
|
+
* moxxy process — coalesce into a single IdP refresh, with the followers
|
|
9
|
+
* re-reading the winner's rotated tokens from the vault instead of burning
|
|
10
|
+
* the (single-use, rotating) refresh_token a second time.
|
|
11
|
+
*
|
|
12
|
+
* Returns the live TokenSet + extras the provider can use to build headers
|
|
13
|
+
* (e.g. ChatGPT-Account-Id). Throws when no credential is stored or the
|
|
14
|
+
* refresh fails permanently.
|
|
15
|
+
*/
|
|
16
|
+
import { MoxxyError } from '@moxxy/sdk';
|
|
17
|
+
import { type OAuthVault, type StoredCreds } from './storage.js';
|
|
18
|
+
import type { TokenSet } from './oauth/types.js';
|
|
19
|
+
import type { OAuthProviderProfile } from './profile.js';
|
|
20
|
+
/**
|
|
21
|
+
* Single refresh-and-persist critical section shared by `ensureFreshTokens`
|
|
22
|
+
* (provider profiles) and `oauth_get_token` (raw stored creds). Encodes the
|
|
23
|
+
* security-critical sequence ONCE — refresh-token guard, rotation-race
|
|
24
|
+
* recovery, refresh_token preservation (RFC 6749 §6), atomic re-persist —
|
|
25
|
+
* and lets each caller plug in its own provider key, error wording, and
|
|
26
|
+
* extras handling via a {@link RefreshSpec}. MUST run inside
|
|
27
|
+
* `withCredentialLock`: it is a writer to the `oauth/<provider>/*` keys.
|
|
28
|
+
*/
|
|
29
|
+
export interface RefreshSpec {
|
|
30
|
+
/** Vault namespace + lock key for this credential. */
|
|
31
|
+
readonly provider: string;
|
|
32
|
+
/** Raised when the stored creds carry no refresh_token to spend. */
|
|
33
|
+
noRefreshTokenError(): MoxxyError;
|
|
34
|
+
/**
|
|
35
|
+
* Wrap a non-network refresh failure (after `classifyNetworkError` declined
|
|
36
|
+
* it). Return a MoxxyError to surface a friendly message, or the original
|
|
37
|
+
* error to re-throw it verbatim.
|
|
38
|
+
*/
|
|
39
|
+
wrapRefreshFailure(err: unknown): unknown;
|
|
40
|
+
/**
|
|
41
|
+
* Compute the extras to persist alongside the refreshed tokens, given the
|
|
42
|
+
* merged token set and the previously-stored extras. Return `undefined` to
|
|
43
|
+
* omit the extras key entirely (matching `storeTokenSet`'s present-only
|
|
44
|
+
* write semantics).
|
|
45
|
+
*/
|
|
46
|
+
resolveExtras(merged: TokenSet, storedExtras: Readonly<Record<string, string>>): Record<string, string> | undefined;
|
|
47
|
+
}
|
|
48
|
+
export declare function refreshAndStore(vault: OAuthVault, spec: RefreshSpec, stored: StoredCreds, retried?: boolean): Promise<{
|
|
49
|
+
tokens: TokenSet;
|
|
50
|
+
extras: Readonly<Record<string, string>>;
|
|
51
|
+
}>;
|
|
52
|
+
export interface EnsureFreshOptions {
|
|
53
|
+
/** Force a refresh even if the access token hasn't expired. */
|
|
54
|
+
readonly force?: boolean;
|
|
55
|
+
/** Refresh when within this many ms of expiry. Default 60_000. */
|
|
56
|
+
readonly skewMs?: number;
|
|
57
|
+
}
|
|
58
|
+
export interface EnsureFreshResult {
|
|
59
|
+
readonly tokens: TokenSet;
|
|
60
|
+
readonly extras: Readonly<Record<string, string>>;
|
|
61
|
+
}
|
|
62
|
+
export declare function ensureFreshTokens(profile: OAuthProviderProfile, vault: OAuthVault, opts?: EnsureFreshOptions): Promise<EnsureFreshResult>;
|
|
63
|
+
//# sourceMappingURL=ensure-fresh.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ensure-fresh.d.ts","sourceRoot":"","sources":["../src/ensure-fresh.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,UAAU,EAAwB,MAAM,YAAY,CAAC;AAC9D,OAAO,EAA6C,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAG5G,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAEzD;;;;;;;;GAQG;AACH,MAAM,WAAW,WAAW;IAC1B,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oEAAoE;IACpE,mBAAmB,IAAI,UAAU,CAAC;IAClC;;;;OAIG;IACH,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC;IAC1C;;;;;OAKG;IACH,aAAa,CAAC,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;CACrH;AAED,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,EACjB,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,WAAW,EACnB,OAAO,UAAQ,GACd,OAAO,CAAC;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAAE,CAAC,CA4CzE;AAED,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB,kEAAkE;IAClE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACnD;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,oBAAoB,EAC7B,KAAK,EAAE,UAAU,EACjB,IAAI,GAAE,kBAAuB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CAyB5B"}
|