mcp-google-multi 6.0.0-alpha.62 → 6.0.0-alpha.64
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/dist/mcp-token.d.ts +58 -17
- package/dist/mcp-token.js +395 -90
- package/dist/oauth-as.d.ts +6 -0
- package/dist/oauth-as.js +67 -9
- package/package.json +1 -1
package/dist/mcp-token.d.ts
CHANGED
|
@@ -35,6 +35,9 @@ export interface StatePayload {
|
|
|
35
35
|
/** alias_add only: an opaque value the minting caller chose, signed like
|
|
36
36
|
* tenantId and handed back to its binder (e.g. a server-side link record). */
|
|
37
37
|
nonce?: string;
|
|
38
|
+
/** owner_gate only: base64url sha256 of the browser-binding cookie the
|
|
39
|
+
* Google redirect set; /callback refuses a browser that lacks it. */
|
|
40
|
+
bind?: string;
|
|
38
41
|
}
|
|
39
42
|
export interface CodePayload {
|
|
40
43
|
redirect_uri: string;
|
|
@@ -77,40 +80,78 @@ export declare class ReplayGuard {
|
|
|
77
80
|
get size(): number;
|
|
78
81
|
private evictExpired;
|
|
79
82
|
}
|
|
83
|
+
/** An earlier release's record, keyed by its live token. */
|
|
80
84
|
export interface RefreshRecord {
|
|
81
85
|
sub: string;
|
|
82
86
|
issuedAt: number;
|
|
83
87
|
family: string;
|
|
84
88
|
}
|
|
85
89
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
* The family tag of a refresh token this `masterKey`'s store minted, or null
|
|
91
|
+
* for anything else. Derives the key once; the returned function does no I/O,
|
|
92
|
+
* so a host can key a pre-auth rate limit on it.
|
|
93
|
+
*/
|
|
94
|
+
export declare function refreshFamilyTagger(masterKey: string): (token: string) => string | null;
|
|
95
|
+
/** Boot check for a refresh store file: returns when it is absent or this
|
|
96
|
+
* release can read it; throws when it does not decrypt under `masterKey`
|
|
97
|
+
* (E_REFRESH_STORE_UNREADABLE) or a newer release wrote it
|
|
98
|
+
* (E_REFRESH_STORE_FORMAT). */
|
|
99
|
+
export declare function assertRefreshStoreReadable(file: string, masterKey: string): void;
|
|
100
|
+
export interface RefreshStoreOptions {
|
|
101
|
+
/** Absolute family lifetime in seconds, counted from the sign-in. Unset: unlimited. */
|
|
102
|
+
maxAgeSec?: number;
|
|
103
|
+
/** Idle lifetime in seconds, counted from the last issue or rotation. Unset: unlimited. */
|
|
104
|
+
idleSec?: number;
|
|
105
|
+
/** Server-side log for a revoked, dropped or expired family. Never given a token or a subject. */
|
|
106
|
+
log?: (line: string) => void;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
|
|
110
|
+
* use, and a token carries its family and generation under a MAC, so
|
|
111
|
+
* presenting any generation the family has rotated past is REUSE: the family
|
|
112
|
+
* is revoked for the rest of its life (#7 / C14, H4 F2). The store keeps one
|
|
113
|
+
* record per family, whatever its rotation count. Every read-modify-write goes
|
|
114
|
+
* through a file lock so concurrent stdio+HTTP processes can't lost-update or
|
|
115
|
+
* double-spend (#12). See docs/internals.md.
|
|
91
116
|
*/
|
|
92
117
|
export declare class RefreshStore {
|
|
93
118
|
private readonly path;
|
|
94
119
|
private readonly masterKey;
|
|
95
|
-
|
|
120
|
+
private readonly log;
|
|
121
|
+
private readonly maxAgeMs;
|
|
122
|
+
private readonly idleMs;
|
|
123
|
+
private macKey;
|
|
124
|
+
private legacyEmpty;
|
|
125
|
+
constructor(path: string, masterKey: string, options?: RefreshStoreOptions);
|
|
126
|
+
private get timed();
|
|
127
|
+
private live;
|
|
128
|
+
/** Drop expired sessions in both formats; the count of those dropped. */
|
|
129
|
+
private prune;
|
|
130
|
+
/** A session written while the clock ran ahead would otherwise outlive its
|
|
131
|
+
* limit by the size of the jump. */
|
|
132
|
+
private clamp;
|
|
133
|
+
private key;
|
|
134
|
+
/** With `nowMs` (every issue and rotation), also clamps and prunes. */
|
|
96
135
|
private load;
|
|
97
136
|
private save;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
*
|
|
137
|
+
private emit;
|
|
138
|
+
private newFamilyId;
|
|
139
|
+
issue(nowMs: number, sub: string): string;
|
|
140
|
+
/** Rotate a presented refresh token; null if it is unknown, malformed, or
|
|
141
|
+
* rotated away (reuse => the family is revoked). The record's sub is copied
|
|
142
|
+
* forward and returned so the caller can mint the matching access token
|
|
143
|
+
* without trusting anything client-supplied.
|
|
103
144
|
*
|
|
104
145
|
* `accept` (optional) is called inside the store lock, before any mutation,
|
|
105
|
-
* with the record's sub. false: every
|
|
106
|
-
*
|
|
107
|
-
*
|
|
146
|
+
* with the record's sub. false: every session of that sub is dropped and
|
|
147
|
+
* null returned. A throw: nothing is written and the error propagates, so
|
|
148
|
+
* the presented token stays valid. Absent: unchanged. */
|
|
108
149
|
rotate(oldToken: string, nowMs: number, accept?: (sub: string) => boolean): {
|
|
109
150
|
token: string;
|
|
110
151
|
sub: string;
|
|
111
152
|
} | null;
|
|
112
|
-
/** Drop every
|
|
113
|
-
* lock); the save then drops the spent entries of
|
|
114
|
-
*
|
|
153
|
+
/** Drop every session minted under `sub`, in both formats (linear scan under
|
|
154
|
+
* the store lock); the save then drops the earlier-release spent entries of
|
|
155
|
+
* the families that died with them. Returns the number of sessions dropped. */
|
|
115
156
|
purgeTenant(sub: string): number;
|
|
116
157
|
}
|
package/dist/mcp-token.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// B13 token model (oauth-authorization-server.md §Data model): the MCP access
|
|
2
2
|
// token (HS256 JWT, jose-only — AS==RS so no JWKS), the signed self-contained
|
|
3
3
|
// `state` and authorization code, the in-memory single-use replay guard, and
|
|
4
|
-
// the
|
|
4
|
+
// the rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY
|
|
5
5
|
// (B5), so tokens survive a restart as long as the key persists.
|
|
6
|
-
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { createHash, createHmac, hkdfSync, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { basename } from 'node:path';
|
|
8
9
|
import { SignJWT, jwtVerify } from 'jose';
|
|
9
10
|
import { deriveKey, encryptToken, decryptToken } from './token-store.js';
|
|
10
11
|
import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
|
|
@@ -20,6 +21,8 @@ function newJti() {
|
|
|
20
21
|
return randomBytes(16).toString('base64url');
|
|
21
22
|
}
|
|
22
23
|
export async function signAccessToken(p) {
|
|
24
|
+
if (typeof p.sub !== 'string' || p.sub === '')
|
|
25
|
+
throw new Error('signAccessToken: sub must be a non-empty string');
|
|
23
26
|
const ttl = p.ttlSec ?? ACCESS_TTL_DEFAULT;
|
|
24
27
|
return new SignJWT({ scope: 'mcp:use', purpose: 'mcp_access' })
|
|
25
28
|
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
@@ -36,7 +39,9 @@ export async function verifyAccessToken(token, base, secret) {
|
|
|
36
39
|
const { payload } = await jwtVerify(token, secret, { issuer: base, audience: `${base}/mcp` });
|
|
37
40
|
if (payload.purpose !== 'mcp_access')
|
|
38
41
|
throw new Error('wrong token purpose');
|
|
39
|
-
|
|
42
|
+
if (typeof payload.sub !== 'string' || payload.sub === '')
|
|
43
|
+
throw new Error('access token has no subject');
|
|
44
|
+
return { sub: payload.sub, scope: String(payload.scope ?? ''), jti: String(payload.jti ?? '') };
|
|
40
45
|
}
|
|
41
46
|
async function signArtifact(claims, purpose, base, secret, iat, ttlSec) {
|
|
42
47
|
return new SignJWT({ ...claims, purpose })
|
|
@@ -128,133 +133,433 @@ export class ReplayGuard {
|
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
135
|
}
|
|
131
|
-
|
|
136
|
+
/** Store files this release writes; an absent `format` is an earlier release's. */
|
|
137
|
+
const REFRESH_FORMAT = 2;
|
|
138
|
+
// r1 token: "r1." + base64url(fid 16 || gen uint32 BE || rand 16 || HMAC-SHA256
|
|
139
|
+
// of those 36 bytes). The dot is outside the base64url alphabet, so an r1
|
|
140
|
+
// token can never be read as an earlier release's 43-character token.
|
|
141
|
+
const R1_PREFIX = 'r1.';
|
|
142
|
+
const R1_LENGTH = 94;
|
|
143
|
+
const R1_BODY = 36;
|
|
144
|
+
const R1_BYTES = 68;
|
|
145
|
+
const R1_MAC_DOMAIN = Buffer.from('mcp-google-multi refresh r1\n');
|
|
146
|
+
const R1_KEY_INFO = 'mcp-google-multi:refresh-mac:v1';
|
|
147
|
+
// Every r1 token carries a MAC under a key from MASTER_KEY, so a passphrase
|
|
148
|
+
// key (deriveKey's bare-sha256 branch) would let one leaked token test
|
|
149
|
+
// guesses offline at hash speed. scrypt gives that branch a work factor.
|
|
150
|
+
const R1_SCRYPT_SALT = 'mcp-google-multi:refresh-mac:scrypt:v1';
|
|
151
|
+
const R1_SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
152
|
+
const GEN_MAX = 0xffffffff;
|
|
153
|
+
const LEGACY_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
154
|
+
/** With a lifetime set, a stored time further ahead than this is pulled back to the clock. */
|
|
155
|
+
const CLOCK_SKEW_MS = 5 * 60_000;
|
|
156
|
+
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
157
|
+
const sha256 = (b) => createHash('sha256').update(b).digest();
|
|
158
|
+
/** A non-secret tag for log lines and rate-limit keys: never the token. */
|
|
159
|
+
const familyTag = (family) => sha256(family).toString('hex').slice(0, 8);
|
|
160
|
+
// Keyed on the master key: scrypt runs once per process, not once per store.
|
|
161
|
+
const macKeyMemo = new Map();
|
|
162
|
+
function refreshMacKey(masterKey) {
|
|
163
|
+
const hit = macKeyMemo.get(masterKey);
|
|
164
|
+
if (hit)
|
|
165
|
+
return hit;
|
|
166
|
+
const raw = deriveKey(masterKey);
|
|
167
|
+
const ikm = Buffer.from(masterKey, 'base64').length === 32 ? raw : scryptSync(masterKey, R1_SCRYPT_SALT, 32, R1_SCRYPT);
|
|
168
|
+
const key = Buffer.from(hkdfSync('sha256', ikm, Buffer.alloc(0), R1_KEY_INFO, 32));
|
|
169
|
+
macKeyMemo.set(masterKey, key);
|
|
170
|
+
return key;
|
|
171
|
+
}
|
|
172
|
+
function r1Mac(key, body) {
|
|
173
|
+
return createHmac('sha256', key).update(R1_MAC_DOMAIN).update(body).digest();
|
|
174
|
+
}
|
|
175
|
+
/** A token this key minted, or null. Pure CPU: runs before the file lock. */
|
|
176
|
+
function parseR1(t, key) {
|
|
177
|
+
if (typeof t !== 'string' || t.length !== R1_LENGTH || !t.startsWith(R1_PREFIX))
|
|
178
|
+
return null;
|
|
179
|
+
const text = t.slice(R1_PREFIX.length);
|
|
180
|
+
const buf = Buffer.from(text, 'base64url');
|
|
181
|
+
// The decoder skips stray characters and ignores the last one's pad bits;
|
|
182
|
+
// re-encoding leaves one spelling per token.
|
|
183
|
+
if (buf.length !== R1_BYTES || buf.toString('base64url') !== text)
|
|
184
|
+
return null;
|
|
185
|
+
if (!timingSafeEqual(buf.subarray(R1_BODY), r1Mac(key, buf.subarray(0, R1_BODY))))
|
|
186
|
+
return null;
|
|
187
|
+
const fidBytes = buf.subarray(0, 16);
|
|
188
|
+
return { fidBytes, fid: fidBytes.toString('base64url'), gen: buf.readUInt32BE(16), digest: sha256(buf) };
|
|
189
|
+
}
|
|
190
|
+
function mintR1(key, fid, gen) {
|
|
191
|
+
const body = Buffer.alloc(R1_BODY);
|
|
192
|
+
fid.copy(body, 0);
|
|
193
|
+
body.writeUInt32BE(gen, 16);
|
|
194
|
+
randomBytes(16).copy(body, 20);
|
|
195
|
+
const buf = Buffer.concat([body, r1Mac(key, body)]);
|
|
196
|
+
return { token: R1_PREFIX + buf.toString('base64url'), cur: sha256(buf).toString('base64url') };
|
|
197
|
+
}
|
|
132
198
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
|
|
137
|
-
|
|
199
|
+
* The family tag of a refresh token this `masterKey`'s store minted, or null
|
|
200
|
+
* for anything else. Derives the key once; the returned function does no I/O,
|
|
201
|
+
* so a host can key a pre-auth rate limit on it.
|
|
202
|
+
*/
|
|
203
|
+
export function refreshFamilyTagger(masterKey) {
|
|
204
|
+
const key = refreshMacKey(masterKey);
|
|
205
|
+
return (token) => {
|
|
206
|
+
const p = parseR1(token, key);
|
|
207
|
+
return p ? familyTag(p.fidBytes) : null;
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function ownEntries(o) {
|
|
211
|
+
// An own `__proto__` key (JSON.parse makes one) would set the prototype when copied.
|
|
212
|
+
return o !== null && typeof o === 'object' && !Array.isArray(o) ? Object.entries(o).filter(([k]) => k !== '__proto__') : [];
|
|
213
|
+
}
|
|
214
|
+
function isLegacyRecord(r) {
|
|
215
|
+
if (r === null || typeof r !== 'object')
|
|
216
|
+
return false;
|
|
217
|
+
const x = r;
|
|
218
|
+
return typeof x.sub === 'string' && x.sub !== '' && typeof x.family === 'string' && typeof x.issuedAt === 'number' && Number.isFinite(x.issuedAt);
|
|
219
|
+
}
|
|
220
|
+
function isCanonicalB64(s, bytes) {
|
|
221
|
+
return B64URL.test(s) && Buffer.from(s, 'base64url').length === bytes && Buffer.from(s, 'base64url').toString('base64url') === s;
|
|
222
|
+
}
|
|
223
|
+
function isFamilyRecord(key, r) {
|
|
224
|
+
if (r === null || typeof r !== 'object')
|
|
225
|
+
return false;
|
|
226
|
+
const x = r;
|
|
227
|
+
return (isCanonicalB64(key, 16) &&
|
|
228
|
+
typeof x.sub === 'string' && x.sub !== '' &&
|
|
229
|
+
Number.isInteger(x.gen) && x.gen >= 0 && x.gen <= GEN_MAX &&
|
|
230
|
+
typeof x.cur === 'string' && x.cur.length === 43 && isCanonicalB64(x.cur, 32) &&
|
|
231
|
+
Number.isFinite(x.createdAt) && Number.isFinite(x.usedAt) &&
|
|
232
|
+
(x.legacy === undefined || typeof x.legacy === 'string'));
|
|
233
|
+
}
|
|
234
|
+
function formatError(file, format) {
|
|
235
|
+
const name = basename(file);
|
|
236
|
+
return new Error(`E_REFRESH_STORE_FORMAT: ${name} was written by a newer release (format ${JSON.stringify(format)}): upgrade, or delete ${name} to sign every MCP client out`);
|
|
237
|
+
}
|
|
238
|
+
/** The decrypted plaintext, or null when the file is absent. Throws when it
|
|
239
|
+
* does not decrypt; the caller decides what that means. */
|
|
240
|
+
function readPlain(file, masterKey) {
|
|
241
|
+
if (!existsSync(file))
|
|
242
|
+
return null;
|
|
243
|
+
return decryptToken(readFileSync(file, 'utf-8'), masterKey);
|
|
244
|
+
}
|
|
245
|
+
// Outside any decrypt catch: a file from a newer release loaded as empty
|
|
246
|
+
// would be erased by the next write.
|
|
247
|
+
function checkFormat(file, d) {
|
|
248
|
+
if (d !== null && typeof d === 'object' && Object.hasOwn(d, 'format') && d.format !== REFRESH_FORMAT) {
|
|
249
|
+
throw formatError(file, d.format);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Only well-formed own entries survive, so no lookup lands on an inherited
|
|
253
|
+
* key and no malformed record reaches a liveness test or the token codec.
|
|
254
|
+
* `dirty` says something was dropped. */
|
|
255
|
+
function decodeData(d) {
|
|
256
|
+
const data = { families: {}, active: {}, spent: {} };
|
|
257
|
+
const x = (d ?? {});
|
|
258
|
+
let dropped = 0;
|
|
259
|
+
for (const [k, f] of ownEntries(x.families)) {
|
|
260
|
+
if (isFamilyRecord(k, f)) {
|
|
261
|
+
data.families[k] = { sub: f.sub, gen: f.gen, cur: f.cur, createdAt: f.createdAt, usedAt: f.usedAt, ...(f.legacy === undefined ? {} : { legacy: f.legacy }) };
|
|
262
|
+
}
|
|
263
|
+
else
|
|
264
|
+
dropped += 1;
|
|
265
|
+
}
|
|
266
|
+
for (const [t, r] of ownEntries(x.active)) {
|
|
267
|
+
if (isLegacyRecord(r))
|
|
268
|
+
data.active[t] = { sub: r.sub, issuedAt: r.issuedAt, family: r.family };
|
|
269
|
+
else
|
|
270
|
+
dropped += 1;
|
|
271
|
+
}
|
|
272
|
+
for (const [t, fam] of ownEntries(x.spent)) {
|
|
273
|
+
if (typeof fam === 'string')
|
|
274
|
+
data.spent[t] = fam;
|
|
275
|
+
else
|
|
276
|
+
dropped += 1;
|
|
277
|
+
}
|
|
278
|
+
return { data, dirty: dropped > 0 };
|
|
279
|
+
}
|
|
280
|
+
/** Every session of `sub`, in both formats. */
|
|
281
|
+
function dropSub(data, sub) {
|
|
282
|
+
let n = 0;
|
|
283
|
+
for (const [k, f] of Object.entries(data.families)) {
|
|
284
|
+
if (f.sub === sub) {
|
|
285
|
+
delete data.families[k];
|
|
286
|
+
n += 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const [t, r] of Object.entries(data.active)) {
|
|
290
|
+
if (r.sub === sub) {
|
|
291
|
+
delete data.active[t];
|
|
292
|
+
n += 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return n;
|
|
296
|
+
}
|
|
297
|
+
/** Boot check for a refresh store file: returns when it is absent or this
|
|
298
|
+
* release can read it; throws when it does not decrypt under `masterKey`
|
|
299
|
+
* (E_REFRESH_STORE_UNREADABLE) or a newer release wrote it
|
|
300
|
+
* (E_REFRESH_STORE_FORMAT). */
|
|
301
|
+
export function assertRefreshStoreReadable(file, masterKey) {
|
|
302
|
+
let d;
|
|
303
|
+
try {
|
|
304
|
+
d = readPlain(file, masterKey);
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
throw new Error(`E_REFRESH_STORE_UNREADABLE: ${basename(file)} does not decrypt with this MASTER_KEY`, { cause: e });
|
|
308
|
+
}
|
|
309
|
+
checkFormat(file, d);
|
|
310
|
+
}
|
|
311
|
+
function lifetimeMs(name, v) {
|
|
312
|
+
if (v === undefined)
|
|
313
|
+
return undefined;
|
|
314
|
+
if (typeof v !== 'number' || !Number.isSafeInteger(v) || v <= 0)
|
|
315
|
+
throw new Error(`E_REFRESH_OPTION_INVALID: ${name} must be a positive whole number`);
|
|
316
|
+
return v * 1000;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
|
|
320
|
+
* use, and a token carries its family and generation under a MAC, so
|
|
321
|
+
* presenting any generation the family has rotated past is REUSE: the family
|
|
322
|
+
* is revoked for the rest of its life (#7 / C14, H4 F2). The store keeps one
|
|
323
|
+
* record per family, whatever its rotation count. Every read-modify-write goes
|
|
324
|
+
* through a file lock so concurrent stdio+HTTP processes can't lost-update or
|
|
325
|
+
* double-spend (#12). See docs/internals.md.
|
|
138
326
|
*/
|
|
139
327
|
export class RefreshStore {
|
|
140
328
|
path;
|
|
141
329
|
masterKey;
|
|
142
|
-
|
|
330
|
+
log;
|
|
331
|
+
maxAgeMs;
|
|
332
|
+
idleMs;
|
|
333
|
+
macKey;
|
|
334
|
+
// Set once a load finds no earlier-release record. Nothing here adds one,
|
|
335
|
+
// so from then on a legacy-shaped token is refused before the lock.
|
|
336
|
+
legacyEmpty = false;
|
|
337
|
+
constructor(path, masterKey, options = {}) {
|
|
143
338
|
this.path = path;
|
|
144
339
|
this.masterKey = masterKey;
|
|
340
|
+
this.maxAgeMs = lifetimeMs('maxAgeSec', options.maxAgeSec);
|
|
341
|
+
this.idleMs = lifetimeMs('idleSec', options.idleSec);
|
|
342
|
+
this.log = options.log ?? (() => undefined);
|
|
343
|
+
}
|
|
344
|
+
get timed() {
|
|
345
|
+
return this.maxAgeMs !== undefined || this.idleMs !== undefined;
|
|
346
|
+
}
|
|
347
|
+
// Positive form: a non-finite time can only make a session dead.
|
|
348
|
+
live(createdAt, usedAt, nowMs) {
|
|
349
|
+
return (this.maxAgeMs === undefined || nowMs < createdAt + this.maxAgeMs) && (this.idleMs === undefined || nowMs < usedAt + this.idleMs);
|
|
145
350
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
351
|
+
/** Drop expired sessions in both formats; the count of those dropped. */
|
|
352
|
+
prune(data, nowMs, lines) {
|
|
353
|
+
if (!this.timed)
|
|
354
|
+
return 0;
|
|
355
|
+
let n = 0;
|
|
356
|
+
for (const [k, f] of Object.entries(data.families)) {
|
|
357
|
+
if (!this.live(f.createdAt, f.usedAt, nowMs)) {
|
|
358
|
+
delete data.families[k];
|
|
359
|
+
n += 1;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
for (const [t, r] of Object.entries(data.active)) {
|
|
363
|
+
if (!this.live(r.issuedAt, r.issuedAt, nowMs)) {
|
|
364
|
+
delete data.active[t];
|
|
365
|
+
n += 1;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (n > 0) {
|
|
369
|
+
lines.push(`refresh families expired n=${n}`);
|
|
370
|
+
if (Object.keys(data.families).length === 0 && Object.keys(data.active).length === 0)
|
|
371
|
+
lines.push('refresh store: every session expired at once (check the host clock)');
|
|
372
|
+
}
|
|
373
|
+
return n;
|
|
374
|
+
}
|
|
375
|
+
/** A session written while the clock ran ahead would otherwise outlive its
|
|
376
|
+
* limit by the size of the jump. */
|
|
377
|
+
clamp(data, nowMs, lines) {
|
|
378
|
+
if (!this.timed)
|
|
379
|
+
return false;
|
|
380
|
+
const limit = nowMs + CLOCK_SKEW_MS;
|
|
381
|
+
let n = 0;
|
|
382
|
+
const pull = (t) => {
|
|
383
|
+
if (t <= limit)
|
|
384
|
+
return t;
|
|
385
|
+
n += 1;
|
|
386
|
+
return nowMs;
|
|
387
|
+
};
|
|
388
|
+
for (const f of Object.values(data.families)) {
|
|
389
|
+
f.createdAt = pull(f.createdAt);
|
|
390
|
+
f.usedAt = pull(f.usedAt);
|
|
391
|
+
}
|
|
392
|
+
for (const r of Object.values(data.active))
|
|
393
|
+
r.issuedAt = pull(r.issuedAt);
|
|
394
|
+
if (n > 0)
|
|
395
|
+
lines.push(`refresh store clock: n=${n} timestamps ahead of the clock were clamped`);
|
|
396
|
+
return n > 0;
|
|
397
|
+
}
|
|
398
|
+
key() {
|
|
399
|
+
return (this.macKey ??= refreshMacKey(this.masterKey));
|
|
400
|
+
}
|
|
401
|
+
/** With `nowMs` (every issue and rotation), also clamps and prunes. */
|
|
402
|
+
load(nowMs, lines = []) {
|
|
403
|
+
let d;
|
|
404
|
+
let readOk = true;
|
|
149
405
|
try {
|
|
150
|
-
|
|
151
|
-
return { active: d.active ?? {}, spent: d.spent ?? {} };
|
|
406
|
+
d = readPlain(this.path, this.masterKey);
|
|
152
407
|
}
|
|
153
408
|
catch {
|
|
154
|
-
|
|
409
|
+
d = null;
|
|
410
|
+
readOk = false;
|
|
155
411
|
}
|
|
412
|
+
checkFormat(this.path, d);
|
|
413
|
+
const loaded = decodeData(d);
|
|
414
|
+
if (nowMs !== undefined) {
|
|
415
|
+
if (this.clamp(loaded.data, nowMs, lines))
|
|
416
|
+
loaded.dirty = true;
|
|
417
|
+
if (this.prune(loaded.data, nowMs, lines) > 0)
|
|
418
|
+
loaded.dirty = true;
|
|
419
|
+
}
|
|
420
|
+
// A failed read is not evidence that the file holds no legacy record.
|
|
421
|
+
if (readOk && Object.keys(loaded.data.active).length === 0 && Object.keys(loaded.data.spent).length === 0)
|
|
422
|
+
this.legacyEmpty = true;
|
|
423
|
+
return loaded;
|
|
156
424
|
}
|
|
157
425
|
save(data) {
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
// push out its own history (docs/internals.md). Insertion order = rotation
|
|
162
|
-
// order.
|
|
163
|
-
const owner = new Map();
|
|
426
|
+
// Earlier-release evidence matters only while its family lives on, as a
|
|
427
|
+
// legacy record or as the family it migrated into.
|
|
428
|
+
const live = new Set();
|
|
164
429
|
for (const r of Object.values(data.active))
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (sub === undefined) {
|
|
430
|
+
live.add(r.family);
|
|
431
|
+
for (const f of Object.values(data.families))
|
|
432
|
+
if (f.legacy !== undefined)
|
|
433
|
+
live.add(f.legacy);
|
|
434
|
+
for (const [t, fam] of Object.entries(data.spent))
|
|
435
|
+
if (!live.has(fam))
|
|
172
436
|
delete data.spent[t];
|
|
173
|
-
|
|
437
|
+
atomicWriteFileSync(this.path, encryptToken({ format: REFRESH_FORMAT, families: data.families, active: data.active, spent: data.spent }, this.masterKey), 0o600);
|
|
438
|
+
}
|
|
439
|
+
// After the write, so a failed save or a throwing accept logs nothing.
|
|
440
|
+
emit(lines) {
|
|
441
|
+
for (const line of lines) {
|
|
442
|
+
try {
|
|
443
|
+
this.log(line);
|
|
174
444
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
h.at.push(i);
|
|
178
|
-
else
|
|
179
|
-
held.set(sub, { at: [i], head: 0 });
|
|
180
|
-
total += 1;
|
|
181
|
-
});
|
|
182
|
-
while (total > SPENT_CAP) {
|
|
183
|
-
let most = { at: [], head: 0 };
|
|
184
|
-
let mostN = 0;
|
|
185
|
-
for (const h of held.values()) {
|
|
186
|
-
const n = h.at.length - h.head;
|
|
187
|
-
if (n > mostN || (n === mostN && n > 0 && h.at[h.head] < most.at[most.head])) {
|
|
188
|
-
most = h;
|
|
189
|
-
mostN = n;
|
|
190
|
-
}
|
|
445
|
+
catch {
|
|
446
|
+
// The rotation is already on disk; a log sink must not lose its token.
|
|
191
447
|
}
|
|
192
|
-
delete data.spent[order[most.at[most.head++]]];
|
|
193
|
-
total -= 1;
|
|
194
448
|
}
|
|
195
|
-
atomicWriteFileSync(this.path, encryptToken(data, this.masterKey), 0o600);
|
|
196
449
|
}
|
|
197
|
-
|
|
450
|
+
newFamilyId(data) {
|
|
451
|
+
let fid;
|
|
452
|
+
do
|
|
453
|
+
fid = randomBytes(16);
|
|
454
|
+
while (Object.hasOwn(data.families, fid.toString('base64url')));
|
|
455
|
+
return fid;
|
|
456
|
+
}
|
|
457
|
+
issue(nowMs, sub) {
|
|
458
|
+
if (typeof sub !== 'string' || sub === '')
|
|
459
|
+
throw new Error('RefreshStore.issue: sub must be a non-empty string');
|
|
460
|
+
const key = this.key();
|
|
198
461
|
return withFileLock(this.path, () => {
|
|
199
|
-
const
|
|
200
|
-
const data = this.load();
|
|
201
|
-
|
|
462
|
+
const lines = [];
|
|
463
|
+
const { data } = this.load(nowMs, lines);
|
|
464
|
+
const fid = this.newFamilyId(data);
|
|
465
|
+
const { token, cur } = mintR1(key, fid, 0);
|
|
466
|
+
data.families[fid.toString('base64url')] = { sub, gen: 0, cur, createdAt: nowMs, usedAt: nowMs };
|
|
202
467
|
this.save(data);
|
|
468
|
+
this.emit(lines);
|
|
203
469
|
return token;
|
|
204
470
|
});
|
|
205
471
|
}
|
|
206
|
-
/** Rotate a presented refresh token; null if
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
472
|
+
/** Rotate a presented refresh token; null if it is unknown, malformed, or
|
|
473
|
+
* rotated away (reuse => the family is revoked). The record's sub is copied
|
|
474
|
+
* forward and returned so the caller can mint the matching access token
|
|
475
|
+
* without trusting anything client-supplied.
|
|
210
476
|
*
|
|
211
477
|
* `accept` (optional) is called inside the store lock, before any mutation,
|
|
212
|
-
* with the record's sub. false: every
|
|
213
|
-
*
|
|
214
|
-
*
|
|
478
|
+
* with the record's sub. false: every session of that sub is dropped and
|
|
479
|
+
* null returned. A throw: nothing is written and the error propagates, so
|
|
480
|
+
* the presented token stays valid. Absent: unchanged. */
|
|
215
481
|
rotate(oldToken, nowMs, accept) {
|
|
482
|
+
const key = this.key();
|
|
483
|
+
const r1 = parseR1(oldToken, key);
|
|
484
|
+
if (!r1 && !(typeof oldToken === 'string' && LEGACY_TOKEN.test(oldToken) && !this.legacyEmpty))
|
|
485
|
+
return null;
|
|
216
486
|
return withFileLock(this.path, () => {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (fam) {
|
|
223
|
-
for (const [t, r] of Object.entries(data.active))
|
|
224
|
-
if (r.family === fam)
|
|
225
|
-
delete data.active[t];
|
|
487
|
+
const lines = [];
|
|
488
|
+
// Expiry runs first: a dead session is refused without consulting accept.
|
|
489
|
+
const { data, dirty } = this.load(nowMs, lines);
|
|
490
|
+
const done = (result, write) => {
|
|
491
|
+
if (write)
|
|
226
492
|
this.save(data);
|
|
227
|
-
|
|
228
|
-
return
|
|
493
|
+
this.emit(lines);
|
|
494
|
+
return result;
|
|
495
|
+
};
|
|
496
|
+
const refuse = (sub) => {
|
|
497
|
+
lines.push(`refresh sessions dropped: accept n=${dropSub(data, sub)}`);
|
|
498
|
+
return done(null, true);
|
|
499
|
+
};
|
|
500
|
+
if (r1) {
|
|
501
|
+
const f = Object.hasOwn(data.families, r1.fid) ? data.families[r1.fid] : undefined;
|
|
502
|
+
if (!f)
|
|
503
|
+
return done(null, dirty);
|
|
504
|
+
const drop = (why) => {
|
|
505
|
+
delete data.families[r1.fid];
|
|
506
|
+
lines.push(`refresh family ${why === 'reuse' ? 'revoked' : 'dropped'}: ${why} tag=${familyTag(r1.fidBytes)}`);
|
|
507
|
+
return done(null, true);
|
|
508
|
+
};
|
|
509
|
+
// Older: a rotated-away token, and the MAC proves this server minted it.
|
|
510
|
+
if (r1.gen < f.gen)
|
|
511
|
+
return drop('reuse');
|
|
512
|
+
// Newer, or the same generation with another random part: the store
|
|
513
|
+
// lost a write the client saw. Not evidence of theft.
|
|
514
|
+
if (r1.gen > f.gen)
|
|
515
|
+
return drop('ahead');
|
|
516
|
+
if (!timingSafeEqual(r1.digest, Buffer.from(f.cur, 'base64url')))
|
|
517
|
+
return drop('mismatch');
|
|
518
|
+
if (accept && !accept(f.sub))
|
|
519
|
+
return refuse(f.sub);
|
|
520
|
+
if (f.gen === GEN_MAX)
|
|
521
|
+
return drop('overflow');
|
|
522
|
+
const next = mintR1(key, r1.fidBytes, f.gen + 1);
|
|
523
|
+
f.gen += 1;
|
|
524
|
+
f.cur = next.cur;
|
|
525
|
+
// now() is read before the lock wait, so a later writer can hold an earlier time.
|
|
526
|
+
f.usedAt = Math.max(f.usedAt, nowMs);
|
|
527
|
+
return done({ token: next.token, sub: f.sub }, true);
|
|
229
528
|
}
|
|
230
|
-
|
|
529
|
+
const rec = Object.hasOwn(data.active, oldToken) ? data.active[oldToken] : undefined;
|
|
530
|
+
if (!rec) {
|
|
531
|
+
const fam = Object.hasOwn(data.spent, oldToken) ? data.spent[oldToken] : undefined;
|
|
532
|
+
if (fam === undefined)
|
|
533
|
+
return done(null, dirty);
|
|
534
|
+
// Reuse of an earlier-release rotated-away token: revoke the family in
|
|
535
|
+
// both formats.
|
|
231
536
|
for (const [t, r] of Object.entries(data.active))
|
|
232
|
-
if (r.
|
|
537
|
+
if (r.family === fam)
|
|
233
538
|
delete data.active[t];
|
|
234
|
-
|
|
235
|
-
|
|
539
|
+
for (const [k, f] of Object.entries(data.families))
|
|
540
|
+
if (f.legacy === fam)
|
|
541
|
+
delete data.families[k];
|
|
542
|
+
lines.push(`refresh family revoked: reuse tag=${familyTag(fam)}`);
|
|
543
|
+
return done(null, true);
|
|
236
544
|
}
|
|
545
|
+
if (accept && !accept(rec.sub))
|
|
546
|
+
return refuse(rec.sub);
|
|
547
|
+
// Migrate: the presented token stays behind as reuse evidence.
|
|
237
548
|
delete data.active[oldToken];
|
|
238
549
|
data.spent[oldToken] = rec.family;
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return { token: next, sub: rec.sub };
|
|
550
|
+
const fid = this.newFamilyId(data);
|
|
551
|
+
const next = mintR1(key, fid, 0);
|
|
552
|
+
data.families[fid.toString('base64url')] = { sub: rec.sub, gen: 0, cur: next.cur, createdAt: rec.issuedAt, usedAt: Math.max(rec.issuedAt, nowMs), legacy: rec.family };
|
|
553
|
+
return done({ token: next.token, sub: rec.sub }, true);
|
|
243
554
|
});
|
|
244
555
|
}
|
|
245
|
-
/** Drop every
|
|
246
|
-
* lock); the save then drops the spent entries of
|
|
247
|
-
*
|
|
556
|
+
/** Drop every session minted under `sub`, in both formats (linear scan under
|
|
557
|
+
* the store lock); the save then drops the earlier-release spent entries of
|
|
558
|
+
* the families that died with them. Returns the number of sessions dropped. */
|
|
248
559
|
purgeTenant(sub) {
|
|
249
560
|
return withFileLock(this.path, () => {
|
|
250
|
-
const data = this.load();
|
|
251
|
-
|
|
252
|
-
for (const [t, r] of Object.entries(data.active)) {
|
|
253
|
-
if (r.sub === sub) {
|
|
254
|
-
delete data.active[t];
|
|
255
|
-
dropped += 1;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
561
|
+
const { data } = this.load();
|
|
562
|
+
const dropped = dropSub(data, sub);
|
|
258
563
|
if (dropped > 0)
|
|
259
564
|
this.save(data);
|
|
260
565
|
return dropped;
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface AuthServerConfig {
|
|
|
20
20
|
accessTtlSec?: number;
|
|
21
21
|
masterKey: string;
|
|
22
22
|
refreshStorePath: string;
|
|
23
|
+
/** `maxAgeSec` of the refresh store buildAuthServer creates: sessions end
|
|
24
|
+
* this long after their sign-in. Unset: unlimited. */
|
|
25
|
+
refreshMaxAgeSec?: number;
|
|
26
|
+
/** `idleSec` of that store: sessions end this long after their last
|
|
27
|
+
* refresh. Unset: unlimited. */
|
|
28
|
+
refreshIdleSec?: number;
|
|
23
29
|
/** Default true. The clientless `flow=alias_reauth` link names an alias with
|
|
24
30
|
* no tenant binding, so a multi-tenant deployment sets false: with several
|
|
25
31
|
* tenants an alias name alone is ambiguous and the branch becomes a
|
package/dist/oauth-as.js
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
// tokens (leg C, unchanged). Mounts into B12's HttpTransportHost as `routes` +
|
|
6
6
|
// an `authenticate` for POST /mcp. All Google/CIMD I/O is injectable so the
|
|
7
7
|
// whole surface is exercised by an offline harness.
|
|
8
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
8
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { isIPv4 } from 'node:net';
|
|
10
10
|
import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signReauthLink, verifyReauthLink, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, } from './mcp-token.js';
|
|
11
11
|
import { fetchCimdDocument, SsrfBlockedError } from './ssrf-guard.js';
|
|
12
12
|
const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
|
|
13
13
|
// DCR (/register) is public + unauthenticated, so its in-memory store must be
|
|
14
|
-
// bounded like every sibling store (ReplayGuard cap
|
|
15
|
-
//
|
|
14
|
+
// bounded like every sibling store (the ReplayGuard cap) or a request loop
|
|
15
|
+
// OOMs the process. Cap client count (FIFO-evict the oldest) and
|
|
16
16
|
// the per-request redirect_uris shape so one entry can't be arbitrarily large.
|
|
17
17
|
export const DCR_MAX_CLIENTS = 1000;
|
|
18
18
|
export const DCR_MAX_REDIRECT_URIS = 10;
|
|
@@ -61,8 +61,8 @@ class SubjectCheckUnavailable extends Error {
|
|
|
61
61
|
function subjectUnavailable(res) {
|
|
62
62
|
return json(res, 503, { error: 'temporarily_unavailable', message: 'the sign-in could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
|
|
63
63
|
}
|
|
64
|
-
function redirect(res, location) {
|
|
65
|
-
res.writeHead(302, { Location: location });
|
|
64
|
+
function redirect(res, location, extraHeaders = {}) {
|
|
65
|
+
res.writeHead(302, { Location: location, ...extraHeaders });
|
|
66
66
|
res.end();
|
|
67
67
|
return true;
|
|
68
68
|
}
|
|
@@ -102,6 +102,38 @@ export function redirectAllowed(redirectUri, docRedirectUris) {
|
|
|
102
102
|
return isLoopbackHostname(du.hostname) && du.protocol === r.protocol && du.pathname === r.pathname;
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
|
+
// The owner_gate browser binding (docs/internals.md). One cookie per flow, so
|
|
106
|
+
// two sign-ins started in one browser do not overwrite each other. __Host-
|
|
107
|
+
// requires Secure, which an http base (the loopback owner) cannot rely on in
|
|
108
|
+
// every browser, so an http base gets a plain host-only name instead.
|
|
109
|
+
const BIND_HASH = /^[A-Za-z0-9_-]{43}$/;
|
|
110
|
+
function bindCookieName(base, bind) {
|
|
111
|
+
return `${base.startsWith('https:') ? '__Host-' : ''}mgm-og-${bind.slice(0, 16)}`;
|
|
112
|
+
}
|
|
113
|
+
function bindCookieAttrs(base, maxAge) {
|
|
114
|
+
return `Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${base.startsWith('https:') ? '; Secure' : ''}`;
|
|
115
|
+
}
|
|
116
|
+
function readCookie(req, name) {
|
|
117
|
+
for (const part of (req.headers.cookie ?? '').split(';')) {
|
|
118
|
+
const i = part.indexOf('=');
|
|
119
|
+
if (i > 0 && part.slice(0, i).trim() === name)
|
|
120
|
+
return part.slice(i + 1).trim();
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
function sha256Url(value) {
|
|
125
|
+
return createHash('sha256').update(value).digest('base64url');
|
|
126
|
+
}
|
|
127
|
+
function browserBinding(req, base, bind) {
|
|
128
|
+
if (typeof bind !== 'string' || !BIND_HASH.test(bind))
|
|
129
|
+
return 'missing';
|
|
130
|
+
const value = readCookie(req, bindCookieName(base, bind));
|
|
131
|
+
if (!value)
|
|
132
|
+
return 'missing';
|
|
133
|
+
const got = createHash('sha256').update(value).digest();
|
|
134
|
+
const want = Buffer.from(bind, 'base64url');
|
|
135
|
+
return got.length === want.length && timingSafeEqual(got, want) ? 'ok' : 'mismatch';
|
|
136
|
+
}
|
|
105
137
|
function pkceS256(verifier) {
|
|
106
138
|
return createHash('sha256').update(verifier).digest('base64url');
|
|
107
139
|
}
|
|
@@ -148,10 +180,14 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
148
180
|
const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
|
|
149
181
|
const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
|
|
150
182
|
const replay = deps.replayGuard ?? new ReplayGuard();
|
|
151
|
-
const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey);
|
|
152
183
|
const registered = deps.registeredClients ?? new Map();
|
|
153
184
|
const now = deps.now ?? (() => Date.now());
|
|
154
185
|
const log = deps.log ?? (() => undefined);
|
|
186
|
+
// Silently dropping a lifetime the caller asked for would leave sessions unlimited.
|
|
187
|
+
if (deps.refreshStore && (config.refreshMaxAgeSec !== undefined || config.refreshIdleSec !== undefined)) {
|
|
188
|
+
throw new Error('E_REFRESH_OPTION_CONFLICT: refreshMaxAgeSec and refreshIdleSec configure the refresh store buildAuthServer creates; set them on the injected refreshStore instead');
|
|
189
|
+
}
|
|
190
|
+
const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey, { maxAgeSec: config.refreshMaxAgeSec, idleSec: config.refreshIdleSec, log });
|
|
155
191
|
const nowSec = () => Math.floor(now() / 1000);
|
|
156
192
|
const resolveSubject = deps.resolveSubject ?? ((email) => (config.ownerEmails.includes(email) ? { sub: 'owner' } : null));
|
|
157
193
|
const issHeader = { 'Cache-Control': 'no-store' };
|
|
@@ -234,11 +270,20 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
234
270
|
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
235
271
|
}
|
|
236
272
|
async function toGoogle(res, sp) {
|
|
273
|
+
// H4 F1: without this, a Google URL minted here and forwarded to someone
|
|
274
|
+
// else signs THEM in and hands their subject to the starting client.
|
|
275
|
+
const headers = {};
|
|
276
|
+
if (sp.flow === 'owner_gate') {
|
|
277
|
+
const value = randomBytes(32).toString('base64url');
|
|
278
|
+
const bind = sha256Url(value);
|
|
279
|
+
sp = { ...sp, bind };
|
|
280
|
+
headers['Set-Cookie'] = `${bindCookieName(base, bind)}=${value}; ${bindCookieAttrs(base, STATE_TTL_DEFAULT)}`;
|
|
281
|
+
}
|
|
237
282
|
const state = await signState(sp, base, secret, nowSec());
|
|
238
283
|
const authUrl = deps.buildGoogleAuthUrl
|
|
239
284
|
? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
|
|
240
285
|
: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
|
|
241
|
-
return redirect(res, authUrl);
|
|
286
|
+
return redirect(res, authUrl, headers);
|
|
242
287
|
}
|
|
243
288
|
// GET /authorize (validate + interstitial for DCR clients); POST /authorize
|
|
244
289
|
// (a DCR consent approval — verify the signed pending token, then proceed).
|
|
@@ -355,7 +400,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
355
400
|
// cannot forge, so there is nothing to spoof.
|
|
356
401
|
if (client.dcr) {
|
|
357
402
|
const pendingToken = await signPending(statePayload, base, secret, nowSec());
|
|
358
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
403
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'", 'X-Frame-Options': 'DENY' });
|
|
359
404
|
res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
|
|
360
405
|
`<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
|
|
361
406
|
`<h2>Authorize access?</h2>` +
|
|
@@ -382,6 +427,16 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
382
427
|
if (!replay.consume(st.jti, STATE_TTL_DEFAULT * 1000, now())) {
|
|
383
428
|
return errorPage(res, 400, 'E_STATE_INVALID', 'state has already been used (replay)');
|
|
384
429
|
}
|
|
430
|
+
// After the spend, so a refused callback URL cannot be replayed from the
|
|
431
|
+
// browser that holds the cookie; before the exchange and resolveSubject.
|
|
432
|
+
if (st.flow === 'owner_gate') {
|
|
433
|
+
const binding = browserBinding(req, base, st.bind);
|
|
434
|
+
if (binding !== 'ok') {
|
|
435
|
+
log(`callback refused flow=owner_gate: browser binding ${binding}`);
|
|
436
|
+
return errorPage(res, 400, 'E_BROWSER_MISMATCH', 'finish the sign-in in the browser that started it; start again from your app');
|
|
437
|
+
}
|
|
438
|
+
res.setHeader('Set-Cookie', `${bindCookieName(base, st.bind ?? '')}=; ${bindCookieAttrs(base, 0)}`);
|
|
439
|
+
}
|
|
385
440
|
if (!code)
|
|
386
441
|
return errorPage(res, 400, 'invalid_request', 'missing code');
|
|
387
442
|
let exchanged;
|
|
@@ -389,7 +444,10 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
389
444
|
exchanged = deps.exchangeCode ? await deps.exchangeCode(code, st.flow) : { tokens: {} };
|
|
390
445
|
}
|
|
391
446
|
catch (e) {
|
|
392
|
-
|
|
447
|
+
// The detail can name proxies and hosts; it goes to the log, not the browser.
|
|
448
|
+
const detail = e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection';
|
|
449
|
+
log(`callback exchange failed flow=${st.flow}: ${detail.replace(/[\r\n]+/g, ' ')}`);
|
|
450
|
+
return errorPage(res, 400, 'invalid_grant', 'Google could not complete the sign-in; start again from your app');
|
|
393
451
|
}
|
|
394
452
|
if (st.flow === 'alias_add') {
|
|
395
453
|
if (!st.alias || !st.tenantId)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
3
|
+
"version": "6.0.0-alpha.64",
|
|
4
4
|
"mcpName": "io.github.bakissation/mcp-google-multi",
|
|
5
5
|
"description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
|
|
6
6
|
"type": "module",
|