@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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/openai-device-flow.d.ts +34 -0
  3. package/dist/adapters/openai-device-flow.d.ts.map +1 -0
  4. package/dist/adapters/openai-device-flow.js +136 -0
  5. package/dist/adapters/openai-device-flow.js.map +1 -0
  6. package/dist/adapters/rfc8628-device-flow.d.ts +20 -0
  7. package/dist/adapters/rfc8628-device-flow.d.ts.map +1 -0
  8. package/dist/adapters/rfc8628-device-flow.js +54 -0
  9. package/dist/adapters/rfc8628-device-flow.js.map +1 -0
  10. package/dist/credential-lock.d.ts +60 -0
  11. package/dist/credential-lock.d.ts.map +1 -0
  12. package/dist/credential-lock.js +149 -0
  13. package/dist/credential-lock.js.map +1 -0
  14. package/dist/ensure-fresh.d.ts +63 -0
  15. package/dist/ensure-fresh.d.ts.map +1 -0
  16. package/dist/ensure-fresh.js +122 -0
  17. package/dist/ensure-fresh.js.map +1 -0
  18. package/dist/flow.d.ts +6 -0
  19. package/dist/flow.d.ts.map +1 -0
  20. package/dist/flow.js +5 -0
  21. package/dist/flow.js.map +1 -0
  22. package/dist/index.d.ts +43 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +82 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/oauth/browser-flow.d.ts +23 -0
  27. package/dist/oauth/browser-flow.d.ts.map +1 -0
  28. package/dist/oauth/browser-flow.js +82 -0
  29. package/dist/oauth/browser-flow.js.map +1 -0
  30. package/dist/oauth/callback-server.d.ts +10 -0
  31. package/dist/oauth/callback-server.d.ts.map +1 -0
  32. package/dist/oauth/callback-server.js +158 -0
  33. package/dist/oauth/callback-server.js.map +1 -0
  34. package/dist/oauth/device-flow-shared.d.ts +39 -0
  35. package/dist/oauth/device-flow-shared.d.ts.map +1 -0
  36. package/dist/oauth/device-flow-shared.js +101 -0
  37. package/dist/oauth/device-flow-shared.js.map +1 -0
  38. package/dist/oauth/device-flow.d.ts +29 -0
  39. package/dist/oauth/device-flow.d.ts.map +1 -0
  40. package/dist/oauth/device-flow.js +74 -0
  41. package/dist/oauth/device-flow.js.map +1 -0
  42. package/dist/oauth/poll-until.d.ts +41 -0
  43. package/dist/oauth/poll-until.d.ts.map +1 -0
  44. package/dist/oauth/poll-until.js +70 -0
  45. package/dist/oauth/poll-until.js.map +1 -0
  46. package/dist/oauth/token-exchange.d.ts +28 -0
  47. package/dist/oauth/token-exchange.d.ts.map +1 -0
  48. package/dist/oauth/token-exchange.js +142 -0
  49. package/dist/oauth/token-exchange.js.map +1 -0
  50. package/dist/oauth/types.d.ts +90 -0
  51. package/dist/oauth/types.d.ts.map +1 -0
  52. package/dist/oauth/types.js +2 -0
  53. package/dist/oauth/types.js.map +1 -0
  54. package/dist/open-browser.d.ts +32 -0
  55. package/dist/open-browser.d.ts.map +1 -0
  56. package/dist/open-browser.js +83 -0
  57. package/dist/open-browser.js.map +1 -0
  58. package/dist/pkce.d.ts +20 -0
  59. package/dist/pkce.d.ts.map +1 -0
  60. package/dist/pkce.js +34 -0
  61. package/dist/pkce.js.map +1 -0
  62. package/dist/profile.d.ts +129 -0
  63. package/dist/profile.d.ts.map +1 -0
  64. package/dist/profile.js +11 -0
  65. package/dist/profile.js.map +1 -0
  66. package/dist/run-login.d.ts +9 -0
  67. package/dist/run-login.d.ts.map +1 -0
  68. package/dist/run-login.js +93 -0
  69. package/dist/run-login.js.map +1 -0
  70. package/dist/storage.d.ts +46 -0
  71. package/dist/storage.d.ts.map +1 -0
  72. package/dist/storage.js +167 -0
  73. package/dist/storage.js.map +1 -0
  74. package/dist/tools.d.ts +8 -0
  75. package/dist/tools.d.ts.map +1 -0
  76. package/dist/tools.js +283 -0
  77. package/dist/tools.js.map +1 -0
  78. package/package.json +73 -0
  79. package/skills/google-oauth.md +313 -0
  80. package/skills/oauth-flow.md +132 -0
  81. package/src/adapters/openai-device-flow.test.ts +191 -0
  82. package/src/adapters/openai-device-flow.ts +179 -0
  83. package/src/adapters/rfc8628-device-flow.ts +75 -0
  84. package/src/credential-lock.ts +183 -0
  85. package/src/discovery.test.ts +35 -0
  86. package/src/ensure-fresh.test.ts +279 -0
  87. package/src/ensure-fresh.ts +178 -0
  88. package/src/flow.ts +9 -0
  89. package/src/index.ts +153 -0
  90. package/src/oauth/browser-flow.ts +96 -0
  91. package/src/oauth/callback-server.test.ts +90 -0
  92. package/src/oauth/callback-server.ts +208 -0
  93. package/src/oauth/device-flow-shared.test.ts +142 -0
  94. package/src/oauth/device-flow-shared.ts +129 -0
  95. package/src/oauth/device-flow.test.ts +102 -0
  96. package/src/oauth/device-flow.ts +82 -0
  97. package/src/oauth/poll-until.test.ts +63 -0
  98. package/src/oauth/poll-until.ts +100 -0
  99. package/src/oauth/token-exchange.test.ts +224 -0
  100. package/src/oauth/token-exchange.ts +169 -0
  101. package/src/oauth/types.ts +92 -0
  102. package/src/oauth.test.ts +185 -0
  103. package/src/open-browser-spawn.test.ts +69 -0
  104. package/src/open-browser.test.ts +30 -0
  105. package/src/open-browser.ts +84 -0
  106. package/src/pkce.ts +37 -0
  107. package/src/profile.test.ts +331 -0
  108. package/src/profile.ts +135 -0
  109. package/src/run-login.ts +123 -0
  110. package/src/storage.test.ts +191 -0
  111. package/src/storage.ts +208 -0
  112. package/src/tools.test.ts +239 -0
  113. package/src/tools.ts +332 -0
@@ -0,0 +1,191 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ clearStoredCreds,
4
+ isExpired,
5
+ readStoredCreds,
6
+ storeTokenSet,
7
+ validateProvider,
8
+ type OAuthVault,
9
+ } from './storage.js';
10
+ import type { TokenSet } from './flow.js';
11
+
12
+ interface FakeVault extends OAuthVault {
13
+ readonly store: Map<string, string>;
14
+ }
15
+
16
+ function makeVault(): FakeVault {
17
+ const store = new Map<string, string>();
18
+ return {
19
+ store,
20
+ get: async (k) => store.get(k) ?? null,
21
+ set: async (k, v) => {
22
+ store.set(k, v);
23
+ },
24
+ delete: async (k) => store.delete(k),
25
+ };
26
+ }
27
+
28
+ const META = { clientId: 'client-1', tokenUrl: 'https://idp.example/token' };
29
+
30
+ describe('isExpired — hostile / corrupt expiry data degrades to a refresh', () => {
31
+ it('treats a token with no expiresAt as non-expiring', () => {
32
+ expect(isExpired({ accessToken: 'a', tokenType: 'Bearer' })).toBe(false);
33
+ });
34
+
35
+ it('treats a NaN expiresAt as EXPIRED, never as eternally fresh', () => {
36
+ // The trap: `Date.now() + skew >= NaN` is always false. A bare `>=` would
37
+ // mark a token with corrupt expiry as forever-valid and suppress every
38
+ // refresh — the user silently presents a possibly-dead token. It must read
39
+ // as expired so the caller refreshes (or fails loudly with AUTH_EXPIRED).
40
+ expect(isExpired({ accessToken: 'a', tokenType: 'Bearer', expiresAt: NaN })).toBe(true);
41
+ });
42
+
43
+ it('treats an Infinity expiresAt as EXPIRED (non-finite is corrupt)', () => {
44
+ expect(
45
+ isExpired({ accessToken: 'a', tokenType: 'Bearer', expiresAt: Number.POSITIVE_INFINITY }),
46
+ ).toBe(true);
47
+ });
48
+
49
+ it('honours the skew window for a finite expiresAt', () => {
50
+ const soon = Date.now() + 30_000; // 30s out, inside the default 60s skew
51
+ expect(isExpired({ accessToken: 'a', tokenType: 'Bearer', expiresAt: soon })).toBe(true);
52
+ const far = Date.now() + 3_600_000;
53
+ expect(isExpired({ accessToken: 'a', tokenType: 'Bearer', expiresAt: far })).toBe(false);
54
+ expect(isExpired({ accessToken: 'a', tokenType: 'Bearer', expiresAt: far }, 0)).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe('readStoredCreds — sanitizes a corrupt persisted expires_at', () => {
59
+ it('drops a non-numeric expires_at instead of carrying NaN into the TokenSet', async () => {
60
+ const vault = makeVault();
61
+ await storeTokenSet(
62
+ vault,
63
+ 'p',
64
+ { accessToken: 'a', tokenType: 'Bearer', expiresAt: 123 },
65
+ META,
66
+ );
67
+ // Simulate corruption / a hand-edit / a partial flush on disk.
68
+ vault.store.set('oauth/p/expires_at', 'not-a-number');
69
+
70
+ const creds = await readStoredCreds(vault, 'p');
71
+ expect(creds).not.toBeNull();
72
+ // Poison must not survive: expiresAt is omitted, not NaN.
73
+ expect(creds?.tokenSet.expiresAt).toBeUndefined();
74
+ expect(Number.isNaN(creds?.tokenSet.expiresAt as number)).toBe(false);
75
+ });
76
+
77
+ it('round-trips a finite expires_at unchanged', async () => {
78
+ const vault = makeVault();
79
+ const at = Date.now() + 1_000_000;
80
+ await storeTokenSet(vault, 'p', { accessToken: 'a', tokenType: 'Bearer', expiresAt: at }, META);
81
+ const creds = await readStoredCreds(vault, 'p');
82
+ expect(creds?.tokenSet.expiresAt).toBe(at);
83
+ });
84
+
85
+ it('returns null when setup-meta (client_id/token_url) is missing — partial store is unusable', async () => {
86
+ const vault = makeVault();
87
+ vault.store.set('oauth/p/access_token', 'a'); // access token only, no client_id/token_url
88
+ expect(await readStoredCreds(vault, 'p')).toBeNull();
89
+ });
90
+ });
91
+
92
+ describe('readStoredCreds — parseExtras tolerates hostile / malformed JSON', () => {
93
+ async function seedExtras(raw: string): Promise<TokenSet | undefined> {
94
+ const vault = makeVault();
95
+ await storeTokenSet(vault, 'p', { accessToken: 'a', tokenType: 'Bearer' }, META);
96
+ vault.store.set('oauth/p/extras', raw);
97
+ const creds = await readStoredCreds(vault, 'p');
98
+ return creds?.tokenSet;
99
+ }
100
+
101
+ it('does not throw on invalid JSON in extras — degrades to {}', async () => {
102
+ const vault = makeVault();
103
+ await storeTokenSet(vault, 'p', { accessToken: 'a', tokenType: 'Bearer' }, META);
104
+ vault.store.set('oauth/p/extras', '{not json');
105
+ const creds = await readStoredCreds(vault, 'p');
106
+ expect(creds?.extras).toEqual({});
107
+ });
108
+
109
+ it('ignores a JSON array (non-object) in extras', async () => {
110
+ await seedExtras('[1,2,3]');
111
+ const vault = makeVault();
112
+ await storeTokenSet(vault, 'p', { accessToken: 'a', tokenType: 'Bearer' }, META);
113
+ vault.store.set('oauth/p/extras', '["evil"]');
114
+ const creds = await readStoredCreds(vault, 'p');
115
+ expect(creds?.extras).toEqual({});
116
+ });
117
+
118
+ it('keeps only string-valued fields from an extras object', async () => {
119
+ const vault = makeVault();
120
+ await storeTokenSet(vault, 'p', { accessToken: 'a', tokenType: 'Bearer' }, META);
121
+ vault.store.set(
122
+ 'oauth/p/extras',
123
+ JSON.stringify({ account_id: 'acc-1', n: 42, nested: { x: 1 }, ok: 'yes' }),
124
+ );
125
+ const creds = await readStoredCreds(vault, 'p');
126
+ expect(creds?.extras).toEqual({ account_id: 'acc-1', ok: 'yes' });
127
+ });
128
+ });
129
+
130
+ describe('storeTokenSet — mirrors the live TokenSet (deletes stale optional keys)', () => {
131
+ it('clears a previously-stored expires_at/scope/id_token when the new set omits them', async () => {
132
+ const vault = makeVault();
133
+ await storeTokenSet(
134
+ vault,
135
+ 'p',
136
+ {
137
+ accessToken: 'a1',
138
+ refreshToken: 'r1',
139
+ expiresAt: Date.now() + 1000,
140
+ scope: 'a b',
141
+ idToken: 'idt',
142
+ tokenType: 'Bearer',
143
+ },
144
+ META,
145
+ );
146
+ expect(vault.store.has('oauth/p/expires_at')).toBe(true);
147
+ expect(vault.store.has('oauth/p/scope')).toBe(true);
148
+ expect(vault.store.has('oauth/p/id_token')).toBe(true);
149
+
150
+ // A refresh that returns only access_token (RFC 6749 §5.1 permits this).
151
+ await storeTokenSet(vault, 'p', { accessToken: 'a2', tokenType: 'Bearer' }, META);
152
+ expect(vault.store.has('oauth/p/expires_at')).toBe(false);
153
+ expect(vault.store.has('oauth/p/scope')).toBe(false);
154
+ expect(vault.store.has('oauth/p/id_token')).toBe(false);
155
+ expect(vault.store.get('oauth/p/access_token')).toBe('a2');
156
+ });
157
+ });
158
+
159
+ describe('validateProvider — rejects keys that could escape the vault namespace', () => {
160
+ it('rejects path-traversal and separator characters', () => {
161
+ for (const bad of ['../etc', 'a/b', 'a b', 'UPPER', 'a:b', '', 'a\\b']) {
162
+ expect(() => validateProvider(bad)).toThrow();
163
+ }
164
+ });
165
+
166
+ it('storeTokenSet / readStoredCreds / clearStoredCreds all reject a bad provider', async () => {
167
+ const vault = makeVault();
168
+ await expect(
169
+ storeTokenSet(vault, '../escape', { accessToken: 'a', tokenType: 'Bearer' }, META),
170
+ ).rejects.toThrow();
171
+ await expect(readStoredCreds(vault, 'a/b')).rejects.toThrow();
172
+ await expect(clearStoredCreds(vault, 'a b')).rejects.toThrow();
173
+ });
174
+ });
175
+
176
+ describe('clearStoredCreds — counts only the keys actually removed', () => {
177
+ it('removes every persisted key and reports the count', async () => {
178
+ const vault = makeVault();
179
+ await storeTokenSet(
180
+ vault,
181
+ 'p',
182
+ { accessToken: 'a', refreshToken: 'r', expiresAt: Date.now(), scope: 's', tokenType: 'Bearer' },
183
+ { ...META, extras: { account_id: 'x' } },
184
+ );
185
+ const removed = await clearStoredCreds(vault, 'p');
186
+ expect(removed).toBeGreaterThan(0);
187
+ expect(await readStoredCreds(vault, 'p')).toBeNull();
188
+ // Nothing left under the namespace.
189
+ expect([...vault.store.keys()].filter((k) => k.startsWith('oauth/p/'))).toEqual([]);
190
+ });
191
+ });
package/src/storage.ts ADDED
@@ -0,0 +1,208 @@
1
+ import type { TokenSet } from './flow.js';
2
+
3
+ /**
4
+ * Vault key convention:
5
+ * oauth/<provider>/access_token
6
+ * oauth/<provider>/refresh_token
7
+ * oauth/<provider>/expires_at (epoch ms, string)
8
+ * oauth/<provider>/scope
9
+ * oauth/<provider>/token_type
10
+ * oauth/<provider>/id_token (OIDC providers only)
11
+ * oauth/<provider>/client_id (so refresh doesn't need the model to re-pass it)
12
+ * oauth/<provider>/client_secret (confidential clients only)
13
+ * oauth/<provider>/token_url (so refresh works without re-supplying)
14
+ * oauth/<provider>/extras (JSON map of provider-specific fields, e.g. account_id)
15
+ *
16
+ * Provider names are namespaced with a `/` so the vault's `list-by-tag`
17
+ * UI groups them. Provider names MUST be `[a-z0-9._-]+`; the schema in
18
+ * the tool layer enforces this.
19
+ */
20
+ const PROVIDER_RE = /^[a-z0-9._-]+$/;
21
+
22
+ /**
23
+ * Minimal vault shape this module uses. Both `@moxxy/plugin-vault`'s
24
+ * `VaultStore` and the SDK's `ProviderVault` structurally satisfy it, so
25
+ * callers can pass whichever they hold.
26
+ */
27
+ export interface OAuthVault {
28
+ get(key: string): Promise<string | null>;
29
+ set(key: string, value: string, tags?: ReadonlyArray<string>): Promise<void>;
30
+ delete?(key: string): Promise<boolean>;
31
+ }
32
+
33
+ export function validateProvider(name: string): void {
34
+ if (!PROVIDER_RE.test(name)) {
35
+ throw new Error(
36
+ `oauth provider name "${name}" must match ${PROVIDER_RE.source} (lowercase, digits, ._-)`,
37
+ );
38
+ }
39
+ }
40
+
41
+ export interface StoreTokenSetMeta {
42
+ readonly clientId: string;
43
+ readonly clientSecret?: string;
44
+ readonly tokenUrl: string;
45
+ /**
46
+ * Provider-specific extras the framework persists alongside the token
47
+ * set. Used for fields like `account_id`, `org_id`, `team_slug` that
48
+ * don't fit `TokenSet` but the caller wants to recover later.
49
+ */
50
+ readonly extras?: Readonly<Record<string, string>>;
51
+ }
52
+
53
+ export interface StoredCreds {
54
+ readonly tokenSet: TokenSet;
55
+ readonly clientId: string;
56
+ readonly clientSecret: string | null;
57
+ readonly tokenUrl: string;
58
+ readonly extras: Readonly<Record<string, string>>;
59
+ }
60
+
61
+ export async function storeTokenSet(
62
+ vault: OAuthVault,
63
+ provider: string,
64
+ tokens: TokenSet,
65
+ meta: StoreTokenSetMeta,
66
+ ): Promise<void> {
67
+ validateProvider(provider);
68
+ const tag = `oauth:${provider}`;
69
+ const base = `oauth/${provider}`;
70
+ // Write each optional field when present, but DELETE the stored key when the
71
+ // new TokenSet omits it, so the persisted bundle exactly mirrors the live
72
+ // TokenSet. A refresh response that omits expires_in/scope/id_token (RFC 6749
73
+ // §5.1 permits this) must not leave a stale expires_at (which would make
74
+ // isExpired() loop forever or trust a token off dead data) or a stale OIDC
75
+ // id_token (which extractAccountId/extractExtras might re-derive identity
76
+ // from). Callers preserve a rotated-or-prior refresh_token BEFORE calling us
77
+ // (RFC 6749 §6: a refresh MAY omit refresh_token), so an undefined
78
+ // refreshToken here genuinely means "no refresh token" — mirror that too.
79
+ const upsert = async (key: string, value: string | undefined): Promise<void> => {
80
+ if (value !== undefined) {
81
+ await vault.set(`${base}/${key}`, value, [tag]);
82
+ } else {
83
+ await vault.delete?.(`${base}/${key}`);
84
+ }
85
+ };
86
+ await vault.set(`${base}/access_token`, tokens.accessToken, [tag]);
87
+ await upsert('refresh_token', tokens.refreshToken);
88
+ await upsert('expires_at', tokens.expiresAt !== undefined ? String(tokens.expiresAt) : undefined);
89
+ await upsert('scope', tokens.scope);
90
+ await vault.set(`${base}/token_type`, tokens.tokenType, [tag]);
91
+ await upsert('id_token', tokens.idToken);
92
+ await vault.set(`${base}/client_id`, meta.clientId, [tag]);
93
+ if (meta.clientSecret) {
94
+ await vault.set(`${base}/client_secret`, meta.clientSecret, [tag]);
95
+ }
96
+ await vault.set(`${base}/token_url`, meta.tokenUrl, [tag]);
97
+ if (meta.extras && Object.keys(meta.extras).length > 0) {
98
+ await vault.set(`${base}/extras`, JSON.stringify(meta.extras), [tag]);
99
+ }
100
+ }
101
+
102
+ export async function readStoredCreds(
103
+ vault: OAuthVault,
104
+ provider: string,
105
+ ): Promise<StoredCreds | null> {
106
+ validateProvider(provider);
107
+ const base = `oauth/${provider}`;
108
+ const access = await vault.get(`${base}/access_token`);
109
+ if (!access) return null;
110
+ // The remaining nine keys are independent reads with no ordering dependency;
111
+ // ensureFreshTokens runs this before every upstream LLM call, so fetch them
112
+ // concurrently instead of serializing ~9 vault round-trips on the hot path.
113
+ const [refresh, expiresStr, scope, tokenTypeRaw, idToken, clientId, clientSecret, tokenUrl, extrasRaw] =
114
+ await Promise.all([
115
+ vault.get(`${base}/refresh_token`),
116
+ vault.get(`${base}/expires_at`),
117
+ vault.get(`${base}/scope`),
118
+ vault.get(`${base}/token_type`),
119
+ vault.get(`${base}/id_token`),
120
+ vault.get(`${base}/client_id`),
121
+ vault.get(`${base}/client_secret`),
122
+ vault.get(`${base}/token_url`),
123
+ vault.get(`${base}/extras`),
124
+ ]);
125
+ const tokenType = tokenTypeRaw ?? 'Bearer';
126
+ if (!clientId || !tokenUrl) {
127
+ // Missing setup-meta means a partial store — refresh impossible. Treat as absent.
128
+ return null;
129
+ }
130
+ // A stored `expires_at` that doesn't parse to a finite number (corrupt vault
131
+ // write, a hand-edited value, a partial flush) is poison: carried through as
132
+ // NaN it makes `isExpired` return false (NaN comparisons are always false),
133
+ // so the token would read as ETERNALLY fresh and never refresh — the user
134
+ // silently keeps presenting a possibly-dead token. Drop the unparseable value
135
+ // and let `expiresAt` go undefined; `isExpired` then defends the
136
+ // no-known-expiry case explicitly (see below).
137
+ const expiresAtParsed = expiresStr !== null ? Number.parseInt(expiresStr, 10) : null;
138
+ const expiresAt =
139
+ expiresAtParsed !== null && Number.isFinite(expiresAtParsed) ? expiresAtParsed : undefined;
140
+ return {
141
+ tokenSet: {
142
+ accessToken: access,
143
+ ...(refresh !== null ? { refreshToken: refresh } : {}),
144
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
145
+ ...(scope !== null ? { scope } : {}),
146
+ tokenType,
147
+ ...(idToken !== null ? { idToken } : {}),
148
+ },
149
+ clientId,
150
+ clientSecret,
151
+ tokenUrl,
152
+ extras: parseExtras(extrasRaw),
153
+ };
154
+ }
155
+
156
+ export async function clearStoredCreds(vault: OAuthVault, provider: string): Promise<number> {
157
+ validateProvider(provider);
158
+ const base = `oauth/${provider}`;
159
+ const keys = [
160
+ `${base}/access_token`,
161
+ `${base}/refresh_token`,
162
+ `${base}/expires_at`,
163
+ `${base}/scope`,
164
+ `${base}/token_type`,
165
+ `${base}/id_token`,
166
+ `${base}/client_id`,
167
+ `${base}/client_secret`,
168
+ `${base}/token_url`,
169
+ `${base}/extras`,
170
+ ];
171
+ let removed = 0;
172
+ for (const k of keys) {
173
+ if (await vault.delete?.(k)) removed += 1;
174
+ }
175
+ return removed;
176
+ }
177
+
178
+ /**
179
+ * True when the access token has expired (or is within `skewMs` of doing so).
180
+ *
181
+ * A token with no `expiresAt` is treated as non-expiring (some providers issue
182
+ * tokens without `expires_in`). A non-FINITE `expiresAt` (NaN/Infinity — corrupt
183
+ * data, an out-of-range parse) is treated as EXPIRED, never as fresh: a bare
184
+ * `>=` against NaN is always false, which would otherwise mark a token eternally
185
+ * valid and suppress every refresh. Forcing the expired branch degrades to a
186
+ * refresh (or a clear AUTH_EXPIRED if no refresh_token) instead of silently
187
+ * serving a token whose real expiry is unknown.
188
+ */
189
+ export function isExpired(tokens: TokenSet, skewMs = 60_000): boolean {
190
+ if (tokens.expiresAt === undefined) return false;
191
+ if (!Number.isFinite(tokens.expiresAt)) return true;
192
+ return Date.now() + skewMs >= tokens.expiresAt;
193
+ }
194
+
195
+ function parseExtras(raw: string | null): Readonly<Record<string, string>> {
196
+ if (!raw) return {};
197
+ try {
198
+ const parsed = JSON.parse(raw) as unknown;
199
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
200
+ const out: Record<string, string> = {};
201
+ for (const [k, v] of Object.entries(parsed)) {
202
+ if (typeof v === 'string') out[k] = v;
203
+ }
204
+ return out;
205
+ } catch {
206
+ return {};
207
+ }
208
+ }
@@ -0,0 +1,239 @@
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 { MoxxyError } from '@moxxy/sdk';
6
+ import { buildOauthGetTokenTool } from './tools.js';
7
+ import { storeTokenSet, readStoredCreds, type OAuthVault } from './storage.js';
8
+
9
+ // oauth_get_token's refresh path takes the same cross-process lockfile under
10
+ // `<moxxy home>/locks` that ensure-fresh.ts uses; point MOXXY_HOME at a temp
11
+ // 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-oauth-tools-'));
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 PROVIDER = 'test-rotating';
44
+ const META = { clientId: 'client-1', tokenUrl: 'https://idp.example/token' };
45
+
46
+ function tokenResponse(obj: unknown, status = 200): Response {
47
+ return {
48
+ ok: status >= 200 && status < 300,
49
+ status,
50
+ json: async () => obj,
51
+ text: async () => JSON.stringify(obj),
52
+ } as unknown as Response;
53
+ }
54
+
55
+ const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
56
+ const fakeCtx = { signal: new AbortController().signal, logger: noopLogger } as never;
57
+
58
+ async function seedExpired(vault: OAuthVault, refreshToken: string): Promise<void> {
59
+ await storeTokenSet(
60
+ vault,
61
+ PROVIDER,
62
+ { accessToken: 'old', refreshToken, expiresAt: Date.now() - 1000, tokenType: 'Bearer' },
63
+ META,
64
+ );
65
+ }
66
+
67
+ describe('oauth_get_token — per-credential refresh serialization (u91-1)', () => {
68
+ it('coalesces concurrent refreshes into one IdP call; all callers end with the SAME rotated token', async () => {
69
+ const vault = makeVault();
70
+ await seedExpired(vault, 'r-old');
71
+ let refreshCalls = 0;
72
+ // Token endpoint that ROTATES the refresh_token every call AND rejects a
73
+ // stale (already-spent) one with invalid_grant — i.e. exactly the
74
+ // single-use-rotating refresh_token the lock exists to protect.
75
+ vi.stubGlobal(
76
+ 'fetch',
77
+ vi.fn(async (_url: unknown, init?: RequestInit) => {
78
+ const rt = new URLSearchParams(String(init?.body ?? '')).get('refresh_token') ?? '';
79
+ if (rt !== 'r-old') {
80
+ return tokenResponse({ error: 'invalid_grant' }, 400);
81
+ }
82
+ refreshCalls++;
83
+ return tokenResponse({ access_token: 'new', refresh_token: 'r-new', expires_in: 3600 });
84
+ }),
85
+ );
86
+
87
+ const tool = buildOauthGetTokenTool({ vault: vault as never });
88
+ const [a, b] = await Promise.all([
89
+ tool.handler({ provider: PROVIDER }, fakeCtx),
90
+ tool.handler({ provider: PROVIDER }, fakeCtx),
91
+ ]);
92
+
93
+ // Exactly one network refresh; the loser re-read the winner's rotated token
94
+ // under the lock instead of burning the now-dead one.
95
+ expect(refreshCalls).toBe(1);
96
+ expect((a as Record<string, unknown>).accessToken).toBe('new');
97
+ expect((b as Record<string, unknown>).accessToken).toBe('new');
98
+ expect(vault.store.get(`oauth/${PROVIDER}/refresh_token`)).toBe('r-new');
99
+ });
100
+
101
+ it('recovers from invalid_grant by retrying once with the refresh_token another process rotated in', async () => {
102
+ const vault = makeVault();
103
+ await seedExpired(vault, 'r-dead');
104
+ const attempted: string[] = [];
105
+ vi.stubGlobal(
106
+ 'fetch',
107
+ vi.fn(async (_url: unknown, init?: RequestInit) => {
108
+ const rt = new URLSearchParams(String(init?.body ?? '')).get('refresh_token') ?? '';
109
+ attempted.push(rt);
110
+ if (rt === 'r-dead') {
111
+ // Another moxxy rotated the stored bundle after we read it.
112
+ vault.store.set(`oauth/${PROVIDER}/refresh_token`, 'r-fresh');
113
+ return tokenResponse({ error: 'invalid_grant' }, 400);
114
+ }
115
+ return tokenResponse({ access_token: 'new-2', refresh_token: 'r-next', expires_in: 3600 });
116
+ }),
117
+ );
118
+
119
+ const tool = buildOauthGetTokenTool({ vault: vault as never });
120
+ const res = await tool.handler({ provider: PROVIDER }, fakeCtx);
121
+
122
+ expect(attempted).toEqual(['r-dead', 'r-fresh']);
123
+ expect((res as Record<string, unknown>).accessToken).toBe('new-2');
124
+ expect(vault.store.get(`oauth/${PROVIDER}/refresh_token`)).toBe('r-next');
125
+ });
126
+
127
+ it('still returns a fresh cached token without any network call (valid existing flow)', async () => {
128
+ const vault = makeVault();
129
+ await storeTokenSet(
130
+ vault,
131
+ PROVIDER,
132
+ { accessToken: 'still-good', refreshToken: 'r1', expiresAt: Date.now() + 3_600_000, tokenType: 'Bearer' },
133
+ META,
134
+ );
135
+ const fetchSpy = vi.fn(async () => tokenResponse({ access_token: 'should-not-happen' }));
136
+ vi.stubGlobal('fetch', fetchSpy);
137
+
138
+ const tool = buildOauthGetTokenTool({ vault: vault as never });
139
+ const res = await tool.handler({ provider: PROVIDER }, fakeCtx);
140
+
141
+ expect(fetchSpy).not.toHaveBeenCalled();
142
+ expect((res as Record<string, unknown>).accessToken).toBe('still-good');
143
+ });
144
+
145
+ it('throws AUTH_NO_CREDENTIALS when nothing is stored', async () => {
146
+ const vault = makeVault();
147
+ const tool = buildOauthGetTokenTool({ vault: vault as never });
148
+ const err = await tool.handler({ provider: PROVIDER }, fakeCtx).catch((e) => e);
149
+ expect(MoxxyError.isMoxxyError(err)).toBe(true);
150
+ expect((err as MoxxyError).code).toBe('AUTH_NO_CREDENTIALS');
151
+ });
152
+
153
+ it('preserves stored extras (account_id) across a tool-driven refresh', async () => {
154
+ const vault = makeVault();
155
+ await storeTokenSet(
156
+ vault,
157
+ PROVIDER,
158
+ { accessToken: 'old', refreshToken: 'r-old', expiresAt: Date.now() - 1000, tokenType: 'Bearer' },
159
+ { ...META, extras: { account_id: 'acct-123' } },
160
+ );
161
+ vi.stubGlobal(
162
+ 'fetch',
163
+ vi.fn(async () => tokenResponse({ access_token: 'new', refresh_token: 'r-new', expires_in: 3600 })),
164
+ );
165
+
166
+ const tool = buildOauthGetTokenTool({ vault: vault as never });
167
+ await tool.handler({ provider: PROVIDER }, fakeCtx);
168
+
169
+ const reread = await readStoredCreds(vault, PROVIDER);
170
+ expect(reread?.extras.account_id).toBe('acct-123');
171
+ });
172
+ });
173
+
174
+ describe('storeTokenSet — clears stale optional fields on partial refresh (u91-2)', () => {
175
+ it('drops a stale expires_at / id_token / scope when the refreshed set omits them', async () => {
176
+ const vault = makeVault();
177
+ // First store: a full token set with expiry, id_token and scope.
178
+ await storeTokenSet(
179
+ vault,
180
+ PROVIDER,
181
+ {
182
+ accessToken: 'a1',
183
+ refreshToken: 'r1',
184
+ expiresAt: Date.now() + 3_600_000,
185
+ scope: 'openid email',
186
+ tokenType: 'Bearer',
187
+ idToken: 'old.id.token',
188
+ },
189
+ META,
190
+ );
191
+ expect(vault.store.has(`oauth/${PROVIDER}/expires_at`)).toBe(true);
192
+ expect(vault.store.has(`oauth/${PROVIDER}/id_token`)).toBe(true);
193
+ expect(vault.store.has(`oauth/${PROVIDER}/scope`)).toBe(true);
194
+
195
+ // Refresh response omits expires_in, id_token and scope (common: many IdPs
196
+ // don't re-issue an id_token on refresh). The caller preserves the prior
197
+ // refresh_token per RFC 6749 §6.
198
+ await storeTokenSet(
199
+ vault,
200
+ PROVIDER,
201
+ { accessToken: 'a2', refreshToken: 'r1', tokenType: 'Bearer' },
202
+ META,
203
+ );
204
+
205
+ // Stale keys must be gone — not left pointing at dead data.
206
+ expect(vault.store.has(`oauth/${PROVIDER}/expires_at`)).toBe(false);
207
+ expect(vault.store.has(`oauth/${PROVIDER}/id_token`)).toBe(false);
208
+ expect(vault.store.has(`oauth/${PROVIDER}/scope`)).toBe(false);
209
+
210
+ const reread = await readStoredCreds(vault, PROVIDER);
211
+ expect(reread?.tokenSet.accessToken).toBe('a2');
212
+ expect(reread?.tokenSet.expiresAt).toBeUndefined();
213
+ expect(reread?.tokenSet.idToken).toBeUndefined();
214
+ expect(reread?.tokenSet.scope).toBeUndefined();
215
+ // refresh_token preserved (the caller merged the prior one before storing).
216
+ expect(reread?.tokenSet.refreshToken).toBe('r1');
217
+ });
218
+
219
+ it('still writes optional fields when the new set provides them (valid full refresh)', async () => {
220
+ const vault = makeVault();
221
+ await storeTokenSet(
222
+ vault,
223
+ PROVIDER,
224
+ { accessToken: 'a1', refreshToken: 'r1', expiresAt: 111, scope: 'a', tokenType: 'Bearer', idToken: 'id1' },
225
+ META,
226
+ );
227
+ await storeTokenSet(
228
+ vault,
229
+ PROVIDER,
230
+ { accessToken: 'a2', refreshToken: 'r2', expiresAt: 222, scope: 'a b', tokenType: 'Bearer', idToken: 'id2' },
231
+ META,
232
+ );
233
+ const reread = await readStoredCreds(vault, PROVIDER);
234
+ expect(reread?.tokenSet.expiresAt).toBe(222);
235
+ expect(reread?.tokenSet.scope).toBe('a b');
236
+ expect(reread?.tokenSet.idToken).toBe('id2');
237
+ expect(reread?.tokenSet.refreshToken).toBe('r2');
238
+ });
239
+ });