@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
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
|
|
21
|
+
import { classifyHttpStatus, MoxxyError } from '@moxxy/sdk';
|
|
22
|
+
import type { TokenSet } from '../oauth/types.js';
|
|
23
|
+
import { exchangeCodeForToken } from '../oauth/token-exchange.js';
|
|
24
|
+
import type {
|
|
25
|
+
DeviceFlowAdapter,
|
|
26
|
+
DeviceFlowInit,
|
|
27
|
+
DeviceFlowStartArgs,
|
|
28
|
+
} from '../profile.js';
|
|
29
|
+
import type { PollOutcome, PollState } from '../oauth/poll-until.js';
|
|
30
|
+
|
|
31
|
+
export interface OpenaiDeviceFlowOpts {
|
|
32
|
+
/** Auth issuer base URL, e.g. `https://auth.openai.com`. */
|
|
33
|
+
readonly issuer: string;
|
|
34
|
+
/** Standard OAuth token endpoint — `parseTokenResponse` consumes its reply. */
|
|
35
|
+
readonly tokenUrl: string;
|
|
36
|
+
/**
|
|
37
|
+
* URL the user opens to enter the device code. Product-specific —
|
|
38
|
+
* Codex uses `${issuer}/codex/device`. The init endpoint does NOT
|
|
39
|
+
* return one, so the caller supplies it.
|
|
40
|
+
*/
|
|
41
|
+
readonly verificationUri: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface OpenaiDeviceState {
|
|
45
|
+
readonly deviceAuthId: string;
|
|
46
|
+
readonly userCode: string;
|
|
47
|
+
readonly clientId: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function openaiDeviceFlow(opts: OpenaiDeviceFlowOpts): DeviceFlowAdapter {
|
|
51
|
+
const initUrl = `${opts.issuer}/api/accounts/deviceauth/usercode`;
|
|
52
|
+
const pollUrl = `${opts.issuer}/api/accounts/deviceauth/token`;
|
|
53
|
+
const exchangeRedirectUri = `${opts.issuer}/deviceauth/callback`;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
async start(args: DeviceFlowStartArgs): Promise<DeviceFlowInit> {
|
|
57
|
+
const res = await fetch(initUrl, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({ client_id: args.clientId }),
|
|
61
|
+
...(args.signal ? { signal: args.signal } : {}),
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const text = await res.text().catch(() => '');
|
|
65
|
+
throw (
|
|
66
|
+
classifyHttpStatus(res.status, { url: initUrl, body: text || res.statusText }) ??
|
|
67
|
+
new MoxxyError({
|
|
68
|
+
code: 'AUTH_INVALID',
|
|
69
|
+
message: `device auth init failed: ${res.status} ${text || res.statusText}`,
|
|
70
|
+
context: { status: res.status, url: initUrl },
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const data = (await res.json().catch(() => {
|
|
75
|
+
throw new MoxxyError({
|
|
76
|
+
code: 'PROVIDER_UNKNOWN_RESPONSE',
|
|
77
|
+
message: 'device-auth init returned a non-JSON success body',
|
|
78
|
+
context: { url: initUrl },
|
|
79
|
+
});
|
|
80
|
+
})) as {
|
|
81
|
+
device_auth_id?: unknown;
|
|
82
|
+
user_code?: unknown;
|
|
83
|
+
interval?: string | number;
|
|
84
|
+
expires_in?: string | number;
|
|
85
|
+
};
|
|
86
|
+
// Required-field validation: a 200 missing device_auth_id/user_code would
|
|
87
|
+
// otherwise produce a poll body of `{ device_auth_id: undefined }` that
|
|
88
|
+
// 403s forever until timeout. Reject clearly instead.
|
|
89
|
+
if (typeof data.device_auth_id !== 'string' || typeof data.user_code !== 'string') {
|
|
90
|
+
throw new MoxxyError({
|
|
91
|
+
code: 'PROVIDER_UNKNOWN_RESPONSE',
|
|
92
|
+
message: 'device-auth init response missing device_auth_id or user_code',
|
|
93
|
+
context: { url: initUrl },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// Coerce-then-validate: a malformed server value (e.g. interval:"" or a
|
|
97
|
+
// non-numeric string) parses to NaN, which would poison the poll timing
|
|
98
|
+
// (`setTimeout(_, NaN)` busy-loops; a NaN deadline aborts the flow). Only
|
|
99
|
+
// accept a finite number; otherwise fall back to the RFC-style defaults.
|
|
100
|
+
const rawInterval =
|
|
101
|
+
typeof data.interval === 'string' ? parseInt(data.interval, 10) : data.interval;
|
|
102
|
+
const intervalSec = Math.max(Number.isFinite(rawInterval) ? (rawInterval as number) : 5, 1);
|
|
103
|
+
const rawExpiresIn =
|
|
104
|
+
typeof data.expires_in === 'string' ? parseInt(data.expires_in, 10) : data.expires_in;
|
|
105
|
+
const expiresInSec = Number.isFinite(rawExpiresIn) ? (rawExpiresIn as number) : 600;
|
|
106
|
+
return {
|
|
107
|
+
userCode: data.user_code,
|
|
108
|
+
verificationUri: opts.verificationUri,
|
|
109
|
+
intervalMs: intervalSec * 1000,
|
|
110
|
+
expiresInMs: expiresInSec * 1000,
|
|
111
|
+
providerData: {
|
|
112
|
+
deviceAuthId: data.device_auth_id,
|
|
113
|
+
userCode: data.user_code,
|
|
114
|
+
clientId: args.clientId,
|
|
115
|
+
} satisfies OpenaiDeviceState,
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async poll(init: DeviceFlowInit, state: PollState): Promise<PollOutcome<TokenSet>> {
|
|
120
|
+
const { deviceAuthId, userCode, clientId } = init.providerData as OpenaiDeviceState;
|
|
121
|
+
const res = await fetch(pollUrl, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
|
|
125
|
+
...(state.signal ? { signal: state.signal } : {}),
|
|
126
|
+
});
|
|
127
|
+
if (res.ok) {
|
|
128
|
+
const data = (await res.json().catch(() => {
|
|
129
|
+
throw new MoxxyError({
|
|
130
|
+
code: 'PROVIDER_UNKNOWN_RESPONSE',
|
|
131
|
+
message: 'device-auth poll returned a non-JSON success body',
|
|
132
|
+
context: { url: pollUrl },
|
|
133
|
+
});
|
|
134
|
+
})) as {
|
|
135
|
+
authorization_code?: unknown;
|
|
136
|
+
code_verifier?: unknown;
|
|
137
|
+
};
|
|
138
|
+
// Required-field validation: without it a 200 omitting these would call
|
|
139
|
+
// exchangeCodeForToken with undefined, which URLSearchParams coerces to
|
|
140
|
+
// the literal 'undefined' — a wasted exchange that fails opaquely.
|
|
141
|
+
if (typeof data.authorization_code !== 'string' || typeof data.code_verifier !== 'string') {
|
|
142
|
+
throw new MoxxyError({
|
|
143
|
+
code: 'PROVIDER_UNKNOWN_RESPONSE',
|
|
144
|
+
message: 'device-auth poll response missing authorization_code or code_verifier',
|
|
145
|
+
context: { url: pollUrl },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
// Two-step: poll returns a server-side authorization_code we
|
|
149
|
+
// exchange via the standard token endpoint. The redirect_uri
|
|
150
|
+
// here is the issuer's device callback — a registered value,
|
|
151
|
+
// not a URI we listen on. Route through the shared exchange
|
|
152
|
+
// helper so this dialect can't drift from browser-flow.
|
|
153
|
+
return {
|
|
154
|
+
done: await exchangeCodeForToken({
|
|
155
|
+
tokenUrl: opts.tokenUrl,
|
|
156
|
+
code: data.authorization_code,
|
|
157
|
+
redirectUri: exchangeRedirectUri,
|
|
158
|
+
clientId,
|
|
159
|
+
codeVerifier: data.code_verifier,
|
|
160
|
+
...(state.signal ? { signal: state.signal } : {}),
|
|
161
|
+
}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// OpenAI's "still waiting" signal — 403 or 404 with no further detail.
|
|
165
|
+
if (res.status === 403 || res.status === 404) {
|
|
166
|
+
return { pending: true };
|
|
167
|
+
}
|
|
168
|
+
const text = await res.text().catch(() => '');
|
|
169
|
+
throw (
|
|
170
|
+
classifyHttpStatus(res.status, { url: pollUrl, body: text || res.statusText }) ??
|
|
171
|
+
new MoxxyError({
|
|
172
|
+
code: 'AUTH_INVALID',
|
|
173
|
+
message: `Device auth poll failed: ${res.status} ${text || res.statusText}`,
|
|
174
|
+
context: { status: res.status, url: pollUrl },
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
|
|
15
|
+
import {
|
|
16
|
+
classifyDeviceTokenResponse,
|
|
17
|
+
requestDeviceAuthorization,
|
|
18
|
+
} from '../oauth/device-flow-shared.js';
|
|
19
|
+
import type { TokenSet } from '../oauth/types.js';
|
|
20
|
+
import type {
|
|
21
|
+
DeviceFlowAdapter,
|
|
22
|
+
DeviceFlowInit,
|
|
23
|
+
DeviceFlowStartArgs,
|
|
24
|
+
} from '../profile.js';
|
|
25
|
+
import type { PollOutcome, PollState } from '../oauth/poll-until.js';
|
|
26
|
+
|
|
27
|
+
export interface Rfc8628AdapterOpts {
|
|
28
|
+
readonly deviceUrl: string;
|
|
29
|
+
readonly tokenUrl: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Rfc8628State {
|
|
33
|
+
readonly deviceCode: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function rfc8628DeviceFlow(opts: Rfc8628AdapterOpts): DeviceFlowAdapter {
|
|
37
|
+
return {
|
|
38
|
+
async start(args: DeviceFlowStartArgs): Promise<DeviceFlowInit> {
|
|
39
|
+
const auth = await requestDeviceAuthorization({
|
|
40
|
+
deviceUrl: opts.deviceUrl,
|
|
41
|
+
clientId: args.clientId,
|
|
42
|
+
scopes: args.scopes,
|
|
43
|
+
...(args.signal ? { signal: args.signal } : {}),
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
userCode: auth.userCode,
|
|
47
|
+
verificationUri: auth.verificationUri,
|
|
48
|
+
...(auth.verificationUriComplete
|
|
49
|
+
? { verificationUriComplete: auth.verificationUriComplete }
|
|
50
|
+
: {}),
|
|
51
|
+
intervalMs: auth.intervalSec * 1000,
|
|
52
|
+
expiresInMs: auth.expiresInSec * 1000,
|
|
53
|
+
providerData: { deviceCode: auth.deviceCode } satisfies Rfc8628State,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async poll(init: DeviceFlowInit, state: PollState): Promise<PollOutcome<TokenSet>> {
|
|
58
|
+
const { deviceCode } = init.providerData as Rfc8628State;
|
|
59
|
+
const body = new URLSearchParams();
|
|
60
|
+
body.set('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
|
|
61
|
+
body.set('device_code', deviceCode);
|
|
62
|
+
const res = await fetch(opts.tokenUrl, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: {
|
|
65
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
66
|
+
Accept: 'application/json',
|
|
67
|
+
},
|
|
68
|
+
body: body.toString(),
|
|
69
|
+
...(state.signal ? { signal: state.signal } : {}),
|
|
70
|
+
});
|
|
71
|
+
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
72
|
+
return classifyDeviceTokenResponse(res, json, state);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
|
|
27
|
+
import { mkdir, open, rename, rm, stat } from 'node:fs/promises';
|
|
28
|
+
import { randomBytes } from 'node:crypto';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
import { MoxxyError, createMutex, type Mutex } from '@moxxy/sdk';
|
|
31
|
+
import { moxxyPath } from '@moxxy/sdk/server';
|
|
32
|
+
|
|
33
|
+
export interface CredentialLockOptions {
|
|
34
|
+
/** Directory holding the lock files. Default `<moxxy home>/locks`. */
|
|
35
|
+
readonly dir?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Take over a lock file whose mtime is older than this (holder crashed
|
|
38
|
+
* without releasing). Default 60s — comfortably above a worst-case retried
|
|
39
|
+
* token refresh.
|
|
40
|
+
*/
|
|
41
|
+
readonly staleMs?: number;
|
|
42
|
+
/** Poll interval while waiting on a held lock. Default 150ms. */
|
|
43
|
+
readonly pollMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Max time to wait for the file lock before proceeding WITHOUT it
|
|
46
|
+
* (best effort — never deadlock auth on a wedged lock). Default 30s.
|
|
47
|
+
*/
|
|
48
|
+
readonly waitMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT_STALE_MS = 60_000;
|
|
52
|
+
const DEFAULT_POLL_MS = 150;
|
|
53
|
+
const DEFAULT_WAIT_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* In-process locks, keyed by sanitized credential key. Module-level so every
|
|
57
|
+
* consumer of this package instance (providers, stt, tools) shares them.
|
|
58
|
+
*/
|
|
59
|
+
const inProcessLocks = new Map<string, Mutex>();
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run `fn` while holding the per-credential lock (in-process mutex +
|
|
63
|
+
* best-effort cross-process lockfile). Callers should RE-READ their stored
|
|
64
|
+
* credential inside `fn` — a queued waiter usually finds the winner's
|
|
65
|
+
* freshly rotated tokens and can skip its own refresh entirely.
|
|
66
|
+
*/
|
|
67
|
+
export async function withCredentialLock<T>(
|
|
68
|
+
key: string,
|
|
69
|
+
fn: () => Promise<T>,
|
|
70
|
+
opts: CredentialLockOptions = {},
|
|
71
|
+
): Promise<T> {
|
|
72
|
+
const safe = sanitizeKey(key);
|
|
73
|
+
let mutex = inProcessLocks.get(safe);
|
|
74
|
+
if (!mutex) {
|
|
75
|
+
mutex = createMutex();
|
|
76
|
+
inProcessLocks.set(safe, mutex);
|
|
77
|
+
}
|
|
78
|
+
return mutex.run(async () => {
|
|
79
|
+
const release = await acquireFileLock(
|
|
80
|
+
join(opts.dir ?? moxxyPath('locks'), `${safe}.lock`),
|
|
81
|
+
opts.staleMs ?? DEFAULT_STALE_MS,
|
|
82
|
+
opts.pollMs ?? DEFAULT_POLL_MS,
|
|
83
|
+
opts.waitMs ?? DEFAULT_WAIT_MS,
|
|
84
|
+
);
|
|
85
|
+
try {
|
|
86
|
+
return await fn();
|
|
87
|
+
} finally {
|
|
88
|
+
await release();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* True for deterministic credential rejections from a token endpoint, as
|
|
95
|
+
* opposed to transient network/5xx failures. Covers the AUTH_* codes (401/403
|
|
96
|
+
* and refresh-specific failures) plus PROVIDER_BAD_REQUEST — `invalid_grant`
|
|
97
|
+
* is canonically an HTTP 400 (RFC 6749 §5.2), which `classifyHttpStatus` maps
|
|
98
|
+
* there. Used to decide whether a failed refresh is worth retrying with a
|
|
99
|
+
* fresher refresh_token re-read from the vault (another process may have
|
|
100
|
+
* rotated ours away).
|
|
101
|
+
*/
|
|
102
|
+
export function isAuthRejection(err: unknown): boolean {
|
|
103
|
+
return (
|
|
104
|
+
err instanceof MoxxyError &&
|
|
105
|
+
(err.code === 'AUTH_INVALID' ||
|
|
106
|
+
err.code === 'AUTH_DENIED' ||
|
|
107
|
+
err.code === 'AUTH_EXPIRED' ||
|
|
108
|
+
err.code === 'PROVIDER_BAD_REQUEST')
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sanitizeKey(key: string): string {
|
|
113
|
+
return key.replace(/[^a-zA-Z0-9._-]+/g, '_');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* O_EXCL ('wx') lockfile acquisition with stale takeover. Returns a release
|
|
118
|
+
* function. Never throws: an unusable lock dir degrades to running unlocked.
|
|
119
|
+
*/
|
|
120
|
+
async function acquireFileLock(
|
|
121
|
+
lockPath: string,
|
|
122
|
+
staleMs: number,
|
|
123
|
+
pollMs: number,
|
|
124
|
+
waitMs: number,
|
|
125
|
+
): Promise<() => Promise<void>> {
|
|
126
|
+
const deadline = Date.now() + waitMs;
|
|
127
|
+
try {
|
|
128
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
129
|
+
} catch {
|
|
130
|
+
return async () => {};
|
|
131
|
+
}
|
|
132
|
+
for (;;) {
|
|
133
|
+
try {
|
|
134
|
+
const handle = await open(lockPath, 'wx');
|
|
135
|
+
try {
|
|
136
|
+
await handle.writeFile(`${process.pid} ${new Date().toISOString()}\n`, 'utf8');
|
|
137
|
+
} finally {
|
|
138
|
+
await handle.close().catch(() => {});
|
|
139
|
+
}
|
|
140
|
+
return async () => {
|
|
141
|
+
await rm(lockPath, { force: true }).catch(() => {});
|
|
142
|
+
};
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
145
|
+
// Read-only FS, permissions, … — degrade to unlocked rather than
|
|
146
|
+
// blocking the user's auth on infrastructure trouble.
|
|
147
|
+
return async () => {};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Lock held — take over if the holder looks dead, else wait and re-try.
|
|
151
|
+
// Takeover is made atomic via rename() to dodge the rm()+open() TOCTOU: a
|
|
152
|
+
// naive rm() lets two processes both judge the same lock stale and both
|
|
153
|
+
// delete it, and one could unlink a holder's freshly-recreated lock. POSIX
|
|
154
|
+
// rename is atomic and single-winner — only one racer's rename of the SAME
|
|
155
|
+
// inode succeeds; the loser's rename fails (ENOENT/the file moved) and it
|
|
156
|
+
// spins to re-acquire instead of clobbering the winner. We rename to a
|
|
157
|
+
// unique temp then rm that temp, so the original path is freed for the
|
|
158
|
+
// winner's `open('wx')` on the next spin. The file lock stays best-effort
|
|
159
|
+
// (see the module header): any failure degrades to the unlocked-but-
|
|
160
|
+
// recoverable path guarded by the in-process mutex + invalid_grant retry.
|
|
161
|
+
try {
|
|
162
|
+
const st = await stat(lockPath);
|
|
163
|
+
if (Date.now() - st.mtimeMs > staleMs) {
|
|
164
|
+
const tmp = `${lockPath}.stale-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
165
|
+
try {
|
|
166
|
+
await rename(lockPath, tmp);
|
|
167
|
+
} catch {
|
|
168
|
+
continue; // lost the takeover race (someone moved/recreated it) — respin
|
|
169
|
+
}
|
|
170
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
continue; // released between open() and stat() — grab it on the next spin
|
|
175
|
+
}
|
|
176
|
+
if (Date.now() >= deadline) return async () => {};
|
|
177
|
+
await sleep(pollMs);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sleep(ms: number): Promise<void> {
|
|
182
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
183
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { ServiceRegistry } from '@moxxy/sdk';
|
|
3
|
+
import { oauthPlugin } from './index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The discovery-loadable default export resolves the vault from the inter-plugin
|
|
7
|
+
* service registry in onInit instead of a `build*({ vault })` closure. (The tool
|
|
8
|
+
* behaviour with an injected vault is covered by tools.test via buildOauthPlugin.)
|
|
9
|
+
*/
|
|
10
|
+
describe('oauthPlugin (discovery-loadable)', () => {
|
|
11
|
+
it('exposes the oauth tools and an onInit hook', () => {
|
|
12
|
+
expect(oauthPlugin.tools?.map((t) => t.name).sort()).toEqual([
|
|
13
|
+
'oauth_authorize',
|
|
14
|
+
'oauth_clear_token',
|
|
15
|
+
'oauth_get_token',
|
|
16
|
+
]);
|
|
17
|
+
expect(typeof oauthPlugin.hooks?.onInit).toBe('function');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('onInit resolves the "vault" service from the registry', () => {
|
|
21
|
+
const fakeVault = { tag: 'vault-store' };
|
|
22
|
+
const require = vi.fn((name: string) => {
|
|
23
|
+
if (name !== 'vault') throw new Error(`unexpected service: ${name}`);
|
|
24
|
+
return fakeVault;
|
|
25
|
+
});
|
|
26
|
+
const services = {
|
|
27
|
+
require,
|
|
28
|
+
get: () => fakeVault,
|
|
29
|
+
has: () => true,
|
|
30
|
+
register: () => {},
|
|
31
|
+
} as unknown as ServiceRegistry;
|
|
32
|
+
oauthPlugin.hooks!.onInit!({ services } as never);
|
|
33
|
+
expect(require).toHaveBeenCalledWith('vault');
|
|
34
|
+
});
|
|
35
|
+
});
|