@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,279 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { ensureFreshTokens } from './ensure-fresh.js';
|
|
6
|
+
import { withCredentialLock } from './credential-lock.js';
|
|
7
|
+
import { storeTokenSet, type OAuthVault } from './storage.js';
|
|
8
|
+
import type { OAuthProviderProfile } from './profile.js';
|
|
9
|
+
|
|
10
|
+
// The refresh path takes a cross-process lockfile under `<moxxy home>/locks`;
|
|
11
|
+
// point MOXXY_HOME at a temp dir so tests never touch the real ~/.moxxy.
|
|
12
|
+
let moxxyHomeTmp: string;
|
|
13
|
+
const priorMoxxyHome = process.env.MOXXY_HOME;
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
moxxyHomeTmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-ensure-fresh-'));
|
|
16
|
+
process.env.MOXXY_HOME = moxxyHomeTmp;
|
|
17
|
+
});
|
|
18
|
+
afterAll(async () => {
|
|
19
|
+
if (priorMoxxyHome === undefined) delete process.env.MOXXY_HOME;
|
|
20
|
+
else process.env.MOXXY_HOME = priorMoxxyHome;
|
|
21
|
+
await fs.rm(moxxyHomeTmp, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.unstubAllGlobals();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
interface FakeVault extends OAuthVault {
|
|
28
|
+
readonly store: Map<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeVault(): FakeVault {
|
|
32
|
+
const store = new Map<string, string>();
|
|
33
|
+
return {
|
|
34
|
+
store,
|
|
35
|
+
get: async (k) => store.get(k) ?? null,
|
|
36
|
+
set: async (k, v) => {
|
|
37
|
+
store.set(k, v);
|
|
38
|
+
},
|
|
39
|
+
delete: async (k) => store.delete(k),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const profile: OAuthProviderProfile = {
|
|
44
|
+
id: 'test-rotating',
|
|
45
|
+
displayName: 'Test',
|
|
46
|
+
authUrl: 'https://idp.example/authorize',
|
|
47
|
+
tokenUrl: 'https://idp.example/token',
|
|
48
|
+
clientId: 'client-1',
|
|
49
|
+
scopes: ['openid'],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const META = { clientId: profile.clientId, tokenUrl: profile.tokenUrl };
|
|
53
|
+
|
|
54
|
+
function tokenResponse(obj: unknown, status = 200): Response {
|
|
55
|
+
return {
|
|
56
|
+
ok: status >= 200 && status < 300,
|
|
57
|
+
status,
|
|
58
|
+
json: async () => obj,
|
|
59
|
+
text: async () => JSON.stringify(obj),
|
|
60
|
+
} as unknown as Response;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function seedExpired(vault: OAuthVault, refreshToken: string): Promise<void> {
|
|
64
|
+
await storeTokenSet(
|
|
65
|
+
vault,
|
|
66
|
+
profile.id,
|
|
67
|
+
{ accessToken: 'old', refreshToken, expiresAt: Date.now() - 1000, tokenType: 'Bearer' },
|
|
68
|
+
META,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('ensureFreshTokens — rotating refresh-token serialization', () => {
|
|
73
|
+
it('coalesces concurrent refreshes into one IdP call; both consumers get the rotated token', async () => {
|
|
74
|
+
const vault = makeVault();
|
|
75
|
+
await seedExpired(vault, 'r-old');
|
|
76
|
+
let refreshCalls = 0;
|
|
77
|
+
vi.stubGlobal(
|
|
78
|
+
'fetch',
|
|
79
|
+
vi.fn(async () => {
|
|
80
|
+
refreshCalls++;
|
|
81
|
+
return tokenResponse({ access_token: 'new', refresh_token: 'r-new', expires_in: 3600 });
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const [a, b] = await Promise.all([
|
|
86
|
+
ensureFreshTokens(profile, vault),
|
|
87
|
+
ensureFreshTokens(profile, vault),
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
expect(refreshCalls).toBe(1);
|
|
91
|
+
expect(a.tokens.accessToken).toBe('new');
|
|
92
|
+
expect(b.tokens.accessToken).toBe('new');
|
|
93
|
+
expect(vault.store.get(`oauth/${profile.id}/refresh_token`)).toBe('r-new');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('force=true coalesces too when another refresher already rotated the token', async () => {
|
|
97
|
+
const vault = makeVault();
|
|
98
|
+
await storeTokenSet(
|
|
99
|
+
vault,
|
|
100
|
+
profile.id,
|
|
101
|
+
{ accessToken: 'a1', refreshToken: 'r1', expiresAt: Date.now() + 3_600_000, tokenType: 'Bearer' },
|
|
102
|
+
META,
|
|
103
|
+
);
|
|
104
|
+
let refreshCalls = 0;
|
|
105
|
+
vi.stubGlobal(
|
|
106
|
+
'fetch',
|
|
107
|
+
vi.fn(async () => {
|
|
108
|
+
refreshCalls++;
|
|
109
|
+
return tokenResponse({ access_token: `a${refreshCalls + 1}`, refresh_token: `r${refreshCalls + 1}`, expires_in: 3600 });
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Two 401-recovery refreshes racing: the loser must reuse the winner's
|
|
114
|
+
// rotated token instead of burning the single-use refresh_token again.
|
|
115
|
+
const [a, b] = await Promise.all([
|
|
116
|
+
ensureFreshTokens(profile, vault, { force: true }),
|
|
117
|
+
ensureFreshTokens(profile, vault, { force: true }),
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
expect(refreshCalls).toBe(1);
|
|
121
|
+
expect(a.tokens.accessToken).toBe('a2');
|
|
122
|
+
expect(b.tokens.accessToken).toBe('a2');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('recovers from invalid_grant by retrying once with the refresh_token another process rotated in', async () => {
|
|
126
|
+
const vault = makeVault();
|
|
127
|
+
await seedExpired(vault, 'r-dead');
|
|
128
|
+
const attempted: string[] = [];
|
|
129
|
+
vi.stubGlobal(
|
|
130
|
+
'fetch',
|
|
131
|
+
vi.fn(async (_url: unknown, init?: RequestInit) => {
|
|
132
|
+
const rt = new URLSearchParams(String(init?.body ?? '')).get('refresh_token') ?? '';
|
|
133
|
+
attempted.push(rt);
|
|
134
|
+
if (rt === 'r-dead') {
|
|
135
|
+
// Simulate the cross-process race: another moxxy rotated the stored
|
|
136
|
+
// bundle after we read it, so OUR refresh token is already spent.
|
|
137
|
+
vault.store.set(`oauth/${profile.id}/refresh_token`, 'r-fresh');
|
|
138
|
+
return tokenResponse({ error: 'invalid_grant' }, 400);
|
|
139
|
+
}
|
|
140
|
+
return tokenResponse({ access_token: 'new-2', refresh_token: 'r-next', expires_in: 3600 });
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const res = await ensureFreshTokens(profile, vault);
|
|
145
|
+
|
|
146
|
+
expect(attempted).toEqual(['r-dead', 'r-fresh']);
|
|
147
|
+
expect(res.tokens.accessToken).toBe('new-2');
|
|
148
|
+
expect(vault.store.get(`oauth/${profile.id}/refresh_token`)).toBe('r-next');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('does NOT retry an invalid_grant when the stored refresh_token is unchanged (true re-auth case)', async () => {
|
|
152
|
+
const vault = makeVault();
|
|
153
|
+
await seedExpired(vault, 'r-revoked');
|
|
154
|
+
let calls = 0;
|
|
155
|
+
vi.stubGlobal(
|
|
156
|
+
'fetch',
|
|
157
|
+
vi.fn(async () => {
|
|
158
|
+
calls++;
|
|
159
|
+
return tokenResponse({ error: 'invalid_grant' }, 400);
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
await expect(ensureFreshTokens(profile, vault)).rejects.toThrow(/400/);
|
|
163
|
+
expect(calls).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('withCredentialLock', () => {
|
|
168
|
+
it('serializes same-key sections and runs different keys independently', async () => {
|
|
169
|
+
const order: string[] = [];
|
|
170
|
+
const gate = new Promise<void>((r) => setTimeout(r, 30));
|
|
171
|
+
const first = withCredentialLock('lock-test-a', async () => {
|
|
172
|
+
order.push('a1-start');
|
|
173
|
+
await gate;
|
|
174
|
+
order.push('a1-end');
|
|
175
|
+
});
|
|
176
|
+
const second = withCredentialLock('lock-test-a', async () => {
|
|
177
|
+
order.push('a2');
|
|
178
|
+
});
|
|
179
|
+
const other = withCredentialLock('lock-test-b', async () => {
|
|
180
|
+
order.push('b');
|
|
181
|
+
});
|
|
182
|
+
await Promise.all([first, second, other]);
|
|
183
|
+
// b (different key) is free to run before a1 finishes; a2 is not.
|
|
184
|
+
expect(order.indexOf('a1-end')).toBeLessThan(order.indexOf('a2'));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('creates and removes an O_EXCL lockfile in the lock dir', async () => {
|
|
188
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-lockdir-'));
|
|
189
|
+
let seenDuring: string[] = [];
|
|
190
|
+
await withCredentialLock(
|
|
191
|
+
'lock-test-file',
|
|
192
|
+
async () => {
|
|
193
|
+
seenDuring = await fs.readdir(dir);
|
|
194
|
+
},
|
|
195
|
+
{ dir },
|
|
196
|
+
);
|
|
197
|
+
expect(seenDuring).toEqual(['lock-test-file.lock']);
|
|
198
|
+
expect(await fs.readdir(dir)).toEqual([]);
|
|
199
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('takes over a stale lockfile left behind by a crashed holder', async () => {
|
|
203
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-lockstale-'));
|
|
204
|
+
const lockPath = path.join(dir, 'lock-test-stale.lock');
|
|
205
|
+
await fs.writeFile(lockPath, '999999 crashed\n');
|
|
206
|
+
const old = new Date(Date.now() - 120_000);
|
|
207
|
+
await fs.utimes(lockPath, old, old);
|
|
208
|
+
|
|
209
|
+
let ran = false;
|
|
210
|
+
await withCredentialLock(
|
|
211
|
+
'lock-test-stale',
|
|
212
|
+
async () => {
|
|
213
|
+
ran = true;
|
|
214
|
+
},
|
|
215
|
+
{ dir, staleMs: 60_000, pollMs: 5, waitMs: 500 },
|
|
216
|
+
);
|
|
217
|
+
expect(ran).toBe(true);
|
|
218
|
+
expect(await fs.readdir(dir)).toEqual([]);
|
|
219
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('proceeds without the lock (best effort) when a live holder outlasts waitMs', async () => {
|
|
223
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-lockwait-'));
|
|
224
|
+
const lockPath = path.join(dir, 'lock-test-held.lock');
|
|
225
|
+
await fs.writeFile(lockPath, `${process.pid} live\n`); // fresh mtime = live holder
|
|
226
|
+
let ran = false;
|
|
227
|
+
await withCredentialLock(
|
|
228
|
+
'lock-test-held',
|
|
229
|
+
async () => {
|
|
230
|
+
ran = true;
|
|
231
|
+
},
|
|
232
|
+
{ dir, staleMs: 60_000, pollMs: 5, waitMs: 50 },
|
|
233
|
+
);
|
|
234
|
+
expect(ran).toBe(true);
|
|
235
|
+
// The foreign lockfile must be left alone (we never owned it).
|
|
236
|
+
expect(await fs.readdir(dir)).toEqual(['lock-test-held.lock']);
|
|
237
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('stale takeover leaves no `.stale-*` temp litter (rename target is cleaned up)', async () => {
|
|
241
|
+
// The rename-based takeover moves the stale lock to a unique temp then rm()s
|
|
242
|
+
// it. A dropped cleanup would accumulate `<lock>.stale-<pid>-<rand>` files in
|
|
243
|
+
// the lock dir forever. Assert the dir holds nothing but the caller's own
|
|
244
|
+
// (already-released) lock — i.e. zero temp residue.
|
|
245
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-lockstaletmp-'));
|
|
246
|
+
const lockPath = path.join(dir, 'lock-test-staletmp.lock');
|
|
247
|
+
await fs.writeFile(lockPath, '424242 crashed\n');
|
|
248
|
+
const old = new Date(Date.now() - 300_000);
|
|
249
|
+
await fs.utimes(lockPath, old, old);
|
|
250
|
+
|
|
251
|
+
await withCredentialLock('lock-test-staletmp', async () => {}, {
|
|
252
|
+
dir,
|
|
253
|
+
staleMs: 60_000,
|
|
254
|
+
pollMs: 5,
|
|
255
|
+
waitMs: 500,
|
|
256
|
+
});
|
|
257
|
+
const left = await fs.readdir(dir);
|
|
258
|
+
expect(left.filter((f) => f.includes('.stale-'))).toEqual([]);
|
|
259
|
+
expect(left).toEqual([]); // own lock released too
|
|
260
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('isAuthRejection — gates the rotation-race retry', () => {
|
|
265
|
+
it('is true only for deterministic credential-rejection codes', async () => {
|
|
266
|
+
const { MoxxyError } = await import('@moxxy/sdk');
|
|
267
|
+
const { isAuthRejection } = await import('./credential-lock.js');
|
|
268
|
+
for (const code of ['AUTH_INVALID', 'AUTH_DENIED', 'AUTH_EXPIRED', 'PROVIDER_BAD_REQUEST']) {
|
|
269
|
+
expect(isAuthRejection(new MoxxyError({ code: code as never, message: 'x' }))).toBe(true);
|
|
270
|
+
}
|
|
271
|
+
// Transient / unrelated failures must NOT trigger the single-use-token retry.
|
|
272
|
+
for (const code of ['NETWORK_ABORTED', 'INTERNAL', 'PROVIDER_UNKNOWN_RESPONSE', 'OAUTH_FLOW_TIMEOUT']) {
|
|
273
|
+
expect(isAuthRejection(new MoxxyError({ code: code as never, message: 'x' }))).toBe(false);
|
|
274
|
+
}
|
|
275
|
+
expect(isAuthRejection(new Error('plain'))).toBe(false);
|
|
276
|
+
expect(isAuthRejection(undefined)).toBe(false);
|
|
277
|
+
expect(isAuthRejection('invalid_grant')).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
|
|
17
|
+
import { MoxxyError, classifyNetworkError } from '@moxxy/sdk';
|
|
18
|
+
import { isExpired, readStoredCreds, storeTokenSet, type OAuthVault, type StoredCreds } from './storage.js';
|
|
19
|
+
import { refreshAccessToken } from './oauth/token-exchange.js';
|
|
20
|
+
import { isAuthRejection, withCredentialLock } from './credential-lock.js';
|
|
21
|
+
import type { TokenSet } from './oauth/types.js';
|
|
22
|
+
import type { OAuthProviderProfile } from './profile.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Single refresh-and-persist critical section shared by `ensureFreshTokens`
|
|
26
|
+
* (provider profiles) and `oauth_get_token` (raw stored creds). Encodes the
|
|
27
|
+
* security-critical sequence ONCE — refresh-token guard, rotation-race
|
|
28
|
+
* recovery, refresh_token preservation (RFC 6749 §6), atomic re-persist —
|
|
29
|
+
* and lets each caller plug in its own provider key, error wording, and
|
|
30
|
+
* extras handling via a {@link RefreshSpec}. MUST run inside
|
|
31
|
+
* `withCredentialLock`: it is a writer to the `oauth/<provider>/*` keys.
|
|
32
|
+
*/
|
|
33
|
+
export interface RefreshSpec {
|
|
34
|
+
/** Vault namespace + lock key for this credential. */
|
|
35
|
+
readonly provider: string;
|
|
36
|
+
/** Raised when the stored creds carry no refresh_token to spend. */
|
|
37
|
+
noRefreshTokenError(): MoxxyError;
|
|
38
|
+
/**
|
|
39
|
+
* Wrap a non-network refresh failure (after `classifyNetworkError` declined
|
|
40
|
+
* it). Return a MoxxyError to surface a friendly message, or the original
|
|
41
|
+
* error to re-throw it verbatim.
|
|
42
|
+
*/
|
|
43
|
+
wrapRefreshFailure(err: unknown): unknown;
|
|
44
|
+
/**
|
|
45
|
+
* Compute the extras to persist alongside the refreshed tokens, given the
|
|
46
|
+
* merged token set and the previously-stored extras. Return `undefined` to
|
|
47
|
+
* omit the extras key entirely (matching `storeTokenSet`'s present-only
|
|
48
|
+
* write semantics).
|
|
49
|
+
*/
|
|
50
|
+
resolveExtras(merged: TokenSet, storedExtras: Readonly<Record<string, string>>): Record<string, string> | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function refreshAndStore(
|
|
54
|
+
vault: OAuthVault,
|
|
55
|
+
spec: RefreshSpec,
|
|
56
|
+
stored: StoredCreds,
|
|
57
|
+
retried = false,
|
|
58
|
+
): Promise<{ tokens: TokenSet; extras: Readonly<Record<string, string>> }> {
|
|
59
|
+
if (!stored.tokenSet.refreshToken) {
|
|
60
|
+
throw spec.noRefreshTokenError();
|
|
61
|
+
}
|
|
62
|
+
let refreshed: TokenSet;
|
|
63
|
+
try {
|
|
64
|
+
refreshed = await refreshAccessToken({
|
|
65
|
+
tokenUrl: stored.tokenUrl,
|
|
66
|
+
clientId: stored.clientId,
|
|
67
|
+
...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}),
|
|
68
|
+
refreshToken: stored.tokenSet.refreshToken,
|
|
69
|
+
});
|
|
70
|
+
} catch (err) {
|
|
71
|
+
// Rotation-race recovery: an invalid_grant-style rejection with a
|
|
72
|
+
// DIFFERENT refresh_token now on disk means another process rotated ours
|
|
73
|
+
// away after we read it. Retry once with the fresher token before
|
|
74
|
+
// declaring re-auth necessary. Transient (network/5xx) failures are not
|
|
75
|
+
// recovered here — they aren't evidence of rotation.
|
|
76
|
+
if (!retried && isAuthRejection(err)) {
|
|
77
|
+
const latest = await readStoredCreds(vault, spec.provider);
|
|
78
|
+
if (
|
|
79
|
+
latest?.tokenSet.refreshToken &&
|
|
80
|
+
latest.tokenSet.refreshToken !== stored.tokenSet.refreshToken
|
|
81
|
+
) {
|
|
82
|
+
return refreshAndStore(vault, spec, latest, true);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const net = classifyNetworkError(err, { url: stored.tokenUrl, provider: spec.provider });
|
|
86
|
+
if (net) throw net;
|
|
87
|
+
throw spec.wrapRefreshFailure(err);
|
|
88
|
+
}
|
|
89
|
+
// Providers MAY rotate refresh_token — preserve the prior one if not.
|
|
90
|
+
const merged: TokenSet = {
|
|
91
|
+
...refreshed,
|
|
92
|
+
refreshToken: refreshed.refreshToken ?? stored.tokenSet.refreshToken,
|
|
93
|
+
};
|
|
94
|
+
const extras = spec.resolveExtras(merged, stored.extras);
|
|
95
|
+
await storeTokenSet(vault, spec.provider, merged, {
|
|
96
|
+
clientId: stored.clientId,
|
|
97
|
+
...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}),
|
|
98
|
+
tokenUrl: stored.tokenUrl,
|
|
99
|
+
...(extras ? { extras } : {}),
|
|
100
|
+
});
|
|
101
|
+
return { tokens: merged, extras: extras ?? stored.extras };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface EnsureFreshOptions {
|
|
105
|
+
/** Force a refresh even if the access token hasn't expired. */
|
|
106
|
+
readonly force?: boolean;
|
|
107
|
+
/** Refresh when within this many ms of expiry. Default 60_000. */
|
|
108
|
+
readonly skewMs?: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface EnsureFreshResult {
|
|
112
|
+
readonly tokens: TokenSet;
|
|
113
|
+
readonly extras: Readonly<Record<string, string>>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function ensureFreshTokens(
|
|
117
|
+
profile: OAuthProviderProfile,
|
|
118
|
+
vault: OAuthVault,
|
|
119
|
+
opts: EnsureFreshOptions = {},
|
|
120
|
+
): Promise<EnsureFreshResult> {
|
|
121
|
+
const stored = await readStoredCreds(vault, profile.id);
|
|
122
|
+
if (!stored) {
|
|
123
|
+
throw new MoxxyError({
|
|
124
|
+
code: 'AUTH_NO_CREDENTIALS',
|
|
125
|
+
message: `No stored OAuth credentials for "${profile.id}".`,
|
|
126
|
+
hint: `Run \`moxxy login ${profile.id}\` to sign in.`,
|
|
127
|
+
context: { provider: profile.id },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (!opts.force && !isExpired(stored.tokenSet, opts.skewMs)) {
|
|
131
|
+
return { tokens: stored.tokenSet, extras: stored.extras };
|
|
132
|
+
}
|
|
133
|
+
return withCredentialLock(`oauth-${profile.id}`, async () => {
|
|
134
|
+
// Re-read under the lock: another consumer/process may have refreshed
|
|
135
|
+
// while we waited. If the vault now holds a different, still-fresh access
|
|
136
|
+
// token, reuse it — even under `force`, which exists for 401 recovery and
|
|
137
|
+
// is satisfied by ANY rotation, not specifically ours.
|
|
138
|
+
const current = (await readStoredCreds(vault, profile.id)) ?? stored;
|
|
139
|
+
const rotatedMeanwhile = current.tokenSet.accessToken !== stored.tokenSet.accessToken;
|
|
140
|
+
if (!isExpired(current.tokenSet, opts.skewMs) && (!opts.force || rotatedMeanwhile)) {
|
|
141
|
+
return { tokens: current.tokenSet, extras: current.extras };
|
|
142
|
+
}
|
|
143
|
+
return refreshAndStore(vault, profileRefreshSpec(profile), current);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* RefreshSpec for a provider profile: friendly `moxxy login`-flavored errors
|
|
149
|
+
* and extras re-derived from the fresh tokens (id_token re-issuance varies by
|
|
150
|
+
* provider, so fall back to stored extras for fields like account_id).
|
|
151
|
+
*/
|
|
152
|
+
function profileRefreshSpec(profile: OAuthProviderProfile): RefreshSpec {
|
|
153
|
+
return {
|
|
154
|
+
provider: profile.id,
|
|
155
|
+
noRefreshTokenError: () =>
|
|
156
|
+
new MoxxyError({
|
|
157
|
+
code: 'AUTH_EXPIRED',
|
|
158
|
+
message: `OAuth token for "${profile.id}" expired and no refresh_token is stored.`,
|
|
159
|
+
hint: `Re-run \`moxxy login ${profile.id}\` to sign in again.`,
|
|
160
|
+
context: { provider: profile.id },
|
|
161
|
+
}),
|
|
162
|
+
wrapRefreshFailure: (err) =>
|
|
163
|
+
new MoxxyError({
|
|
164
|
+
code: 'AUTH_EXPIRED',
|
|
165
|
+
message: `Couldn't refresh the OAuth token for "${profile.id}".`,
|
|
166
|
+
hint: `Re-run \`moxxy login ${profile.id}\` to sign in again.`,
|
|
167
|
+
context: { provider: profile.id },
|
|
168
|
+
cause: err,
|
|
169
|
+
}),
|
|
170
|
+
resolveExtras: (merged, storedExtras) => {
|
|
171
|
+
const freshAccountId = profile.extractAccountId?.(merged);
|
|
172
|
+
const freshExtras = profile.extractExtras?.(merged) ?? {};
|
|
173
|
+
const mergedExtras: Record<string, string> = { ...storedExtras, ...freshExtras };
|
|
174
|
+
if (freshAccountId) mergedExtras.account_id = freshAccountId;
|
|
175
|
+
return mergedExtras;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/flow.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { buildAuthUrl, runAuthorizationCodeFlow, type BuildAuthUrlInput } from './oauth/browser-flow.js';
|
|
2
|
+
export { runDeviceCodeFlow } from './oauth/device-flow.js';
|
|
3
|
+
export {
|
|
4
|
+
exchangeCodeForToken,
|
|
5
|
+
parseTokenResponse,
|
|
6
|
+
refreshAccessToken,
|
|
7
|
+
} from './oauth/token-exchange.js';
|
|
8
|
+
export type { DeviceFlowOptions, DevicePrompt, OAuthFlowOptions, TokenSet } from './oauth/types.js';
|
|
9
|
+
export { pollUntil, type PollOutcome, type PollState, type PollUntilOpts } from './oauth/poll-until.js';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { definePlugin, type LifecycleHooks, type Plugin } from '@moxxy/sdk';
|
|
2
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
3
|
+
import {
|
|
4
|
+
buildOauthAuthorizeTool,
|
|
5
|
+
buildOauthClearTool,
|
|
6
|
+
buildOauthGetTokenTool,
|
|
7
|
+
type OAuthToolDeps,
|
|
8
|
+
} from './tools.js';
|
|
9
|
+
|
|
10
|
+
// Low-level flow primitives — useful for ad-hoc usage or custom adapters.
|
|
11
|
+
export {
|
|
12
|
+
buildAuthUrl,
|
|
13
|
+
exchangeCodeForToken,
|
|
14
|
+
parseTokenResponse,
|
|
15
|
+
refreshAccessToken,
|
|
16
|
+
runAuthorizationCodeFlow,
|
|
17
|
+
runDeviceCodeFlow,
|
|
18
|
+
pollUntil,
|
|
19
|
+
} from './flow.js';
|
|
20
|
+
export type {
|
|
21
|
+
BuildAuthUrlInput,
|
|
22
|
+
DeviceFlowOptions,
|
|
23
|
+
DevicePrompt,
|
|
24
|
+
OAuthFlowOptions,
|
|
25
|
+
PollOutcome,
|
|
26
|
+
PollState,
|
|
27
|
+
PollUntilOpts,
|
|
28
|
+
TokenSet,
|
|
29
|
+
} from './flow.js';
|
|
30
|
+
|
|
31
|
+
export { computeCodeChallenge, generateCodeVerifier, generateState } from './pkce.js';
|
|
32
|
+
export { openInBrowser } from './open-browser.js';
|
|
33
|
+
|
|
34
|
+
// Storage (vault-backed; extras-aware).
|
|
35
|
+
export {
|
|
36
|
+
clearStoredCreds,
|
|
37
|
+
isExpired,
|
|
38
|
+
readStoredCreds,
|
|
39
|
+
storeTokenSet,
|
|
40
|
+
validateProvider,
|
|
41
|
+
type OAuthVault,
|
|
42
|
+
type StoreTokenSetMeta,
|
|
43
|
+
type StoredCreds,
|
|
44
|
+
} from './storage.js';
|
|
45
|
+
|
|
46
|
+
// Provider framework — declare a profile, plug in a device adapter,
|
|
47
|
+
// orchestrate via runOauthLogin / ensureFreshTokens.
|
|
48
|
+
export type {
|
|
49
|
+
DeviceFlowAdapter,
|
|
50
|
+
DeviceFlowInit,
|
|
51
|
+
DeviceFlowStartArgs,
|
|
52
|
+
OAuthProviderProfile,
|
|
53
|
+
RunOauthLoginCtx,
|
|
54
|
+
RunOauthLoginResult,
|
|
55
|
+
} from './profile.js';
|
|
56
|
+
export { runOauthLogin } from './run-login.js';
|
|
57
|
+
export {
|
|
58
|
+
ensureFreshTokens,
|
|
59
|
+
type EnsureFreshOptions,
|
|
60
|
+
type EnsureFreshResult,
|
|
61
|
+
} from './ensure-fresh.js';
|
|
62
|
+
|
|
63
|
+
// Per-credential refresh serialization (in-process mutex + best-effort
|
|
64
|
+
// cross-process lockfile) for providers with single-use rotating
|
|
65
|
+
// refresh tokens.
|
|
66
|
+
export {
|
|
67
|
+
isAuthRejection,
|
|
68
|
+
withCredentialLock,
|
|
69
|
+
type CredentialLockOptions,
|
|
70
|
+
} from './credential-lock.js';
|
|
71
|
+
|
|
72
|
+
// Bundled device-flow adapters. Custom dialects implement `DeviceFlowAdapter`.
|
|
73
|
+
export { rfc8628DeviceFlow, type Rfc8628AdapterOpts } from './adapters/rfc8628-device-flow.js';
|
|
74
|
+
export { openaiDeviceFlow, type OpenaiDeviceFlowOpts } from './adapters/openai-device-flow.js';
|
|
75
|
+
|
|
76
|
+
// Tools layer — `oauth_authorize`, `oauth_get_token`, `oauth_clear_token`.
|
|
77
|
+
export {
|
|
78
|
+
buildOauthAuthorizeTool,
|
|
79
|
+
buildOauthClearTool,
|
|
80
|
+
buildOauthGetTokenTool,
|
|
81
|
+
type OAuthToolDeps,
|
|
82
|
+
} from './tools.js';
|
|
83
|
+
|
|
84
|
+
export interface BuildOauthPluginOpts {
|
|
85
|
+
readonly vault: VaultStore;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `@moxxy/plugin-oauth` — generic OAuth 2.0 + PKCE provider framework.
|
|
90
|
+
*
|
|
91
|
+
* Two surfaces:
|
|
92
|
+
* 1. **Tools** — `oauth_authorize`, `oauth_get_token`, `oauth_clear_token`.
|
|
93
|
+
* Model-callable; suits ad-hoc usage and MCP server wiring.
|
|
94
|
+
* 2. **Provider framework** — declare an `OAuthProviderProfile`, plug in
|
|
95
|
+
* (or write) a `DeviceFlowAdapter`, drive with `runOauthLogin(profile, ctx)`
|
|
96
|
+
* and `ensureFreshTokens(profile, vault)`. Suits LLM providers (e.g.
|
|
97
|
+
* `@moxxy/plugin-provider-openai-codex`) that own their auth lifecycle.
|
|
98
|
+
*
|
|
99
|
+
* Tokens persist in the vault under `oauth/<provider>/*`. Bundled device
|
|
100
|
+
* adapters: `rfc8628DeviceFlow` (standards-compliant), `openaiDeviceFlow`
|
|
101
|
+
* (OpenAI's non-standard flavor). New dialects ship their own adapter.
|
|
102
|
+
*/
|
|
103
|
+
export function buildOauthPlugin(opts: BuildOauthPluginOpts): Plugin {
|
|
104
|
+
// Host-injected vault (available immediately).
|
|
105
|
+
return makeOauthPlugin(() => opts.vault);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Discovery-loadable default export: resolves the vault from the inter-plugin
|
|
110
|
+
* service registry in `onInit` (the vault plugin publishes `'vault'`). Requires
|
|
111
|
+
* `@moxxy/plugin-vault` to load first (declared in `package.json`
|
|
112
|
+
* `moxxy.requirements`), so its `onInit` registers the service before this one
|
|
113
|
+
* consumes it. The tools read `deps.vault` lazily via a getter, so they resolve
|
|
114
|
+
* the store at call time — after `onInit` has wired it.
|
|
115
|
+
*/
|
|
116
|
+
export const oauthPlugin: Plugin = (() => {
|
|
117
|
+
let resolved: VaultStore | null = null;
|
|
118
|
+
const getVault = (): VaultStore => {
|
|
119
|
+
if (!resolved) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
'@moxxy/plugin-oauth: the "vault" service is unavailable — @moxxy/plugin-vault must load first',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return resolved;
|
|
125
|
+
};
|
|
126
|
+
const hooks: LifecycleHooks = {
|
|
127
|
+
onInit: (ctx) => {
|
|
128
|
+
resolved = ctx.services.require<VaultStore>('vault');
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
return makeOauthPlugin(getVault, hooks);
|
|
132
|
+
})();
|
|
133
|
+
|
|
134
|
+
function makeOauthPlugin(getVault: () => VaultStore, hooks?: LifecycleHooks): Plugin {
|
|
135
|
+
// The getter satisfies `OAuthToolDeps.vault` while deferring resolution to call
|
|
136
|
+
// time, so the same tools work whether the vault is host-injected or resolved
|
|
137
|
+
// from the service registry in onInit.
|
|
138
|
+
const deps: OAuthToolDeps = {
|
|
139
|
+
get vault(): VaultStore {
|
|
140
|
+
return getVault();
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
return definePlugin({
|
|
144
|
+
name: '@moxxy/plugin-oauth',
|
|
145
|
+
version: '0.0.0',
|
|
146
|
+
tools: [
|
|
147
|
+
buildOauthAuthorizeTool(deps),
|
|
148
|
+
buildOauthGetTokenTool(deps),
|
|
149
|
+
buildOauthClearTool(deps),
|
|
150
|
+
],
|
|
151
|
+
...(hooks ? { hooks } : {}),
|
|
152
|
+
});
|
|
153
|
+
}
|