mcp-google-multi 6.0.0-alpha.65 → 6.0.0-alpha.66
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/cimd-cache.js +1 -1
- package/dist/mcp-token.d.ts +27 -6
- package/dist/mcp-token.js +82 -27
- package/dist/oauth-as.d.ts +3 -1
- package/dist/oauth-as.js +75 -23
- package/package.json +1 -1
package/dist/cimd-cache.js
CHANGED
|
@@ -100,7 +100,7 @@ export class CimdClientCache {
|
|
|
100
100
|
// Over-cap requests arrive at whatever rate the caller likes.
|
|
101
101
|
noteBusy() {
|
|
102
102
|
const now = this.deps.now();
|
|
103
|
-
if (now - this.busyLoggedAt < BUSY_LOG_EVERY_MS) {
|
|
103
|
+
if (now >= this.busyLoggedAt && now - this.busyLoggedAt < BUSY_LOG_EVERY_MS) {
|
|
104
104
|
this.busySuppressed++;
|
|
105
105
|
return;
|
|
106
106
|
}
|
package/dist/mcp-token.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare const ACCESS_TTL_DEFAULT = 600;
|
|
2
2
|
export declare const STATE_TTL_DEFAULT = 600;
|
|
3
3
|
export declare const CODE_TTL_DEFAULT = 60;
|
|
4
|
-
export declare const REPLAY_CAP_DEFAULT =
|
|
4
|
+
export declare const REPLAY_CAP_DEFAULT = 100000;
|
|
5
5
|
/** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
|
|
6
6
|
export declare function jwtSecretFrom(jwtKey: string): Uint8Array;
|
|
7
7
|
export interface AccessTokenParams {
|
|
@@ -46,8 +46,9 @@ export interface CodePayload {
|
|
|
46
46
|
sub: string;
|
|
47
47
|
}
|
|
48
48
|
export declare function signState(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
|
|
49
|
-
export declare function verifyState(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
|
|
49
|
+
export declare function verifyState(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<StatePayload & {
|
|
50
50
|
jti: string;
|
|
51
|
+
exp: number;
|
|
51
52
|
}>;
|
|
52
53
|
/** A re-auth link is handed out in a tool error, so it has to survive until
|
|
53
54
|
* the user clicks it; an hour, not the 10-minute state TTL. */
|
|
@@ -64,21 +65,41 @@ export declare function verifyReauthLink(base: string, secret: Uint8Array, param
|
|
|
64
65
|
/** A pending-authorization artifact for the DCR consent interstitial (same
|
|
65
66
|
* shape as `state`, distinct purpose so the two can't be confused). */
|
|
66
67
|
export declare function signPending(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
|
|
67
|
-
export declare function verifyPending(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
|
|
68
|
+
export declare function verifyPending(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<StatePayload & {
|
|
68
69
|
jti: string;
|
|
70
|
+
exp: number;
|
|
69
71
|
}>;
|
|
70
72
|
export declare function signAuthzCode(payload: CodePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
|
|
71
|
-
export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array): Promise<CodePayload & {
|
|
73
|
+
export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<CodePayload & {
|
|
72
74
|
jti: string;
|
|
75
|
+
exp: number;
|
|
73
76
|
}>;
|
|
77
|
+
/** `full`: there is no room without forgetting a jti that is still live. */
|
|
78
|
+
export type ReplaySpend = 'ok' | 'replay' | 'full';
|
|
74
79
|
export declare class ReplayGuard {
|
|
75
80
|
private readonly cap;
|
|
76
81
|
private readonly seen;
|
|
82
|
+
private earliest;
|
|
83
|
+
private sweptAt;
|
|
77
84
|
constructor(cap?: number);
|
|
78
|
-
/**
|
|
85
|
+
/** Hold a slot for an artifact about to be handed out, so its spend can
|
|
86
|
+
* never meet a full guard. The flood then lands where the artifact is
|
|
87
|
+
* minted, and refusing there loses nothing. */
|
|
88
|
+
reserve(jti: string, ttlMs: number, nowMs: number): 'ok' | 'full';
|
|
89
|
+
/** Record a jti as spent. A full guard refuses instead of evicting a live
|
|
90
|
+
* jti: a flood of spends would otherwise make a spent artifact usable again,
|
|
91
|
+
* such as a callback the browser binding refused (H4 F1). `artifactExpMs`
|
|
92
|
+
* keeps the record alive as long as the artifact itself, even when the clock
|
|
93
|
+
* stepped back between its signing and this spend. */
|
|
94
|
+
spend(jti: string, ttlMs: number, nowMs: number, artifactExpMs?: number): ReplaySpend;
|
|
95
|
+
/** Undo a spend whose next step could not go ahead. The slot stays held, so
|
|
96
|
+
* nothing is forgotten, and the artifact can be spent once more. */
|
|
97
|
+
unspend(jti: string): void;
|
|
98
|
+
/** `spend` as a boolean: false for a replay and for a full guard alike. */
|
|
79
99
|
consume(jti: string, ttlMs: number, nowMs: number): boolean;
|
|
80
100
|
get size(): number;
|
|
81
|
-
private
|
|
101
|
+
private record;
|
|
102
|
+
private room;
|
|
82
103
|
}
|
|
83
104
|
/** An earlier release's record, keyed by its live token. */
|
|
84
105
|
export interface RefreshRecord {
|
package/dist/mcp-token.js
CHANGED
|
@@ -12,7 +12,7 @@ import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
|
|
|
12
12
|
export const ACCESS_TTL_DEFAULT = 600; // seconds
|
|
13
13
|
export const STATE_TTL_DEFAULT = 600;
|
|
14
14
|
export const CODE_TTL_DEFAULT = 60;
|
|
15
|
-
export const REPLAY_CAP_DEFAULT =
|
|
15
|
+
export const REPLAY_CAP_DEFAULT = 100_000;
|
|
16
16
|
/** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
|
|
17
17
|
export function jwtSecretFrom(jwtKey) {
|
|
18
18
|
return new Uint8Array(deriveKey(jwtKey));
|
|
@@ -52,17 +52,19 @@ async function signArtifact(claims, purpose, base, secret, iat, ttlSec) {
|
|
|
52
52
|
.setJti(newJti())
|
|
53
53
|
.sign(secret);
|
|
54
54
|
}
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// `nowSec`: the caller's clock, so expiry is judged by the same clock as the
|
|
56
|
+
// replay guard that records the artifact's spend.
|
|
57
|
+
async function verifyArtifact(token, purpose, base, secret, nowSec) {
|
|
58
|
+
const { payload } = await jwtVerify(token, secret, { issuer: base, ...(nowSec !== undefined ? { currentDate: new Date(nowSec * 1000) } : {}) });
|
|
57
59
|
if (payload.purpose !== purpose)
|
|
58
60
|
throw new Error(`wrong artifact purpose (${String(payload.purpose)})`);
|
|
59
|
-
return { ...payload, jti: String(payload.jti ?? '') };
|
|
61
|
+
return { ...payload, jti: String(payload.jti ?? ''), exp: Number(payload.exp ?? 0) };
|
|
60
62
|
}
|
|
61
63
|
export function signState(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
|
|
62
64
|
return signArtifact(payload, 'mcp_state', base, secret, iat, ttlSec);
|
|
63
65
|
}
|
|
64
|
-
export async function verifyState(token, base, secret) {
|
|
65
|
-
return (await verifyArtifact(token, 'mcp_state', base, secret));
|
|
66
|
+
export async function verifyState(token, base, secret, nowSec) {
|
|
67
|
+
return (await verifyArtifact(token, 'mcp_state', base, secret, nowSec));
|
|
66
68
|
}
|
|
67
69
|
// --- alias_reauth link -------------------------------------------------------
|
|
68
70
|
/** A re-auth link is handed out in a tool error, so it has to survive until
|
|
@@ -93,44 +95,97 @@ export function verifyReauthLink(base, secret, params, nowSec) {
|
|
|
93
95
|
export function signPending(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
|
|
94
96
|
return signArtifact(payload, 'mcp_pending', base, secret, iat, ttlSec);
|
|
95
97
|
}
|
|
96
|
-
export async function verifyPending(token, base, secret) {
|
|
97
|
-
return (await verifyArtifact(token, 'mcp_pending', base, secret));
|
|
98
|
+
export async function verifyPending(token, base, secret, nowSec) {
|
|
99
|
+
return (await verifyArtifact(token, 'mcp_pending', base, secret, nowSec));
|
|
98
100
|
}
|
|
99
101
|
export function signAuthzCode(payload, base, secret, iat, ttlSec = CODE_TTL_DEFAULT) {
|
|
100
102
|
return signArtifact(payload, 'mcp_code', base, secret, iat, ttlSec);
|
|
101
103
|
}
|
|
102
|
-
export async function verifyAuthzCode(token, base, secret) {
|
|
103
|
-
return (await verifyArtifact(token, 'mcp_code', base, secret));
|
|
104
|
+
export async function verifyAuthzCode(token, base, secret, nowSec) {
|
|
105
|
+
return (await verifyArtifact(token, 'mcp_code', base, secret, nowSec));
|
|
104
106
|
}
|
|
105
|
-
|
|
107
|
+
/** A full guard refuses rather than sweeping again sooner than this. */
|
|
108
|
+
const FULL_SWEEP_EVERY_MS = 1000;
|
|
106
109
|
export class ReplayGuard {
|
|
107
110
|
cap;
|
|
108
|
-
seen = new Map();
|
|
111
|
+
seen = new Map();
|
|
112
|
+
// A lower bound on every entry's expiry: below it nothing can have expired.
|
|
113
|
+
earliest = Infinity;
|
|
114
|
+
sweptAt = -Infinity;
|
|
109
115
|
constructor(cap = REPLAY_CAP_DEFAULT) {
|
|
110
116
|
this.cap = cap;
|
|
111
117
|
}
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
/** Hold a slot for an artifact about to be handed out, so its spend can
|
|
119
|
+
* never meet a full guard. The flood then lands where the artifact is
|
|
120
|
+
* minted, and refusing there loses nothing. */
|
|
121
|
+
reserve(jti, ttlMs, nowMs) {
|
|
122
|
+
if (!this.room(nowMs))
|
|
123
|
+
return 'full';
|
|
124
|
+
this.record(jti, nowMs + ttlMs, false);
|
|
125
|
+
return 'ok';
|
|
126
|
+
}
|
|
127
|
+
/** Record a jti as spent. A full guard refuses instead of evicting a live
|
|
128
|
+
* jti: a flood of spends would otherwise make a spent artifact usable again,
|
|
129
|
+
* such as a callback the browser binding refused (H4 F1). `artifactExpMs`
|
|
130
|
+
* keeps the record alive as long as the artifact itself, even when the clock
|
|
131
|
+
* stepped back between its signing and this spend. */
|
|
132
|
+
spend(jti, ttlMs, nowMs, artifactExpMs = 0) {
|
|
133
|
+
const exp = Math.max(nowMs + ttlMs, artifactExpMs);
|
|
134
|
+
const held = this.seen.get(jti);
|
|
135
|
+
if (held && held.exp > nowMs) {
|
|
136
|
+
if (held.spent)
|
|
137
|
+
return 'replay';
|
|
138
|
+
held.spent = true;
|
|
139
|
+
held.exp = Math.max(held.exp, exp);
|
|
140
|
+
return 'ok';
|
|
122
141
|
}
|
|
123
|
-
|
|
124
|
-
|
|
142
|
+
if (held)
|
|
143
|
+
this.seen.delete(jti);
|
|
144
|
+
if (!this.room(nowMs))
|
|
145
|
+
return 'full';
|
|
146
|
+
this.record(jti, exp, true);
|
|
147
|
+
return 'ok';
|
|
148
|
+
}
|
|
149
|
+
/** Undo a spend whose next step could not go ahead. The slot stays held, so
|
|
150
|
+
* nothing is forgotten, and the artifact can be spent once more. */
|
|
151
|
+
unspend(jti) {
|
|
152
|
+
const held = this.seen.get(jti);
|
|
153
|
+
if (held)
|
|
154
|
+
held.spent = false;
|
|
155
|
+
}
|
|
156
|
+
/** `spend` as a boolean: false for a replay and for a full guard alike. */
|
|
157
|
+
consume(jti, ttlMs, nowMs) {
|
|
158
|
+
return this.spend(jti, ttlMs, nowMs) === 'ok';
|
|
125
159
|
}
|
|
126
160
|
get size() {
|
|
127
161
|
return this.seen.size;
|
|
128
162
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
163
|
+
record(jti, exp, spent) {
|
|
164
|
+
this.seen.set(jti, { exp, spent });
|
|
165
|
+
if (exp < this.earliest)
|
|
166
|
+
this.earliest = exp;
|
|
167
|
+
}
|
|
168
|
+
// Expired entries are dropped only here, when the map is at the cap: a refusal
|
|
169
|
+
// costs O(1), and a sweep runs only once something can have expired, at most
|
|
170
|
+
// once a second. Walking the map from its head on every call is not cheap: a
|
|
171
|
+
// Map iterator steps over every deleted slot until the table is rebuilt.
|
|
172
|
+
room(nowMs) {
|
|
173
|
+
if (this.seen.size < this.cap)
|
|
174
|
+
return true;
|
|
175
|
+
if (nowMs < this.earliest)
|
|
176
|
+
return false;
|
|
177
|
+
if (nowMs >= this.sweptAt && nowMs - this.sweptAt < FULL_SWEEP_EVERY_MS)
|
|
178
|
+
return false;
|
|
179
|
+
this.sweptAt = nowMs;
|
|
180
|
+
let earliest = Infinity;
|
|
181
|
+
for (const [jti, e] of this.seen) {
|
|
182
|
+
if (e.exp <= nowMs)
|
|
132
183
|
this.seen.delete(jti);
|
|
184
|
+
else if (e.exp < earliest)
|
|
185
|
+
earliest = e.exp;
|
|
133
186
|
}
|
|
187
|
+
this.earliest = earliest;
|
|
188
|
+
return this.seen.size < this.cap;
|
|
134
189
|
}
|
|
135
190
|
}
|
|
136
191
|
/** Store files this release writes; an absent `format` is an earlier release's. */
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -122,7 +122,9 @@ export interface AuthServer {
|
|
|
122
122
|
/** Mint a signed, single-use, TTL-bound clientless flow URL
|
|
123
123
|
* (`${base}/authorize?flow=...&state=<signed>`). The tenantId/bundles ride
|
|
124
124
|
* INSIDE the signed state, so nothing at /authorize or /callback trusts a
|
|
125
|
-
* caller-supplied identity.
|
|
125
|
+
* caller-supplied identity. The URL works only on this AuthServer, and only
|
|
126
|
+
* until its process restarts: sign-in artifacts are signed with a key each
|
|
127
|
+
* buildAuthServer call generates. Inert unless something calls it. */
|
|
126
128
|
mintFlowState: (opts: {
|
|
127
129
|
flow: 'alias_add';
|
|
128
130
|
alias: string;
|
package/dist/oauth-as.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// whole surface is exercised by an offline harness.
|
|
8
8
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { isIPv4 } from 'node:net';
|
|
10
|
+
import { decodeJwt } from 'jose';
|
|
10
11
|
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
12
|
import { fetchCimdDocument } from './ssrf-guard.js';
|
|
12
13
|
import { CimdClientCache } from './cimd-cache.js';
|
|
@@ -53,8 +54,8 @@ function json(res, status, body, extraHeaders = {}) {
|
|
|
53
54
|
res.end(JSON.stringify(body));
|
|
54
55
|
return true;
|
|
55
56
|
}
|
|
56
|
-
function errorPage(res, status, slug, message) {
|
|
57
|
-
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
|
57
|
+
function errorPage(res, status, slug, message, extraHeaders = {}) {
|
|
58
|
+
res.writeHead(status, { 'Content-Type': 'text/plain', ...extraHeaders });
|
|
58
59
|
res.end(`${slug}: ${message}`);
|
|
59
60
|
return true;
|
|
60
61
|
}
|
|
@@ -67,6 +68,10 @@ function subjectUnavailable(res) {
|
|
|
67
68
|
function clientUnavailable(res) {
|
|
68
69
|
return json(res, 503, { error: 'temporarily_unavailable', message: 'the client could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
|
|
69
70
|
}
|
|
71
|
+
// The replay guard is full: every slot holds a jti that is still live.
|
|
72
|
+
function signInBusy(res) {
|
|
73
|
+
return errorPage(res, 503, 'E_SIGNIN_BUSY', 'sign-in is busy right now; retry in a minute', { 'Retry-After': '60' });
|
|
74
|
+
}
|
|
70
75
|
function redirect(res, location, extraHeaders = {}) {
|
|
71
76
|
res.writeHead(302, { Location: location, ...extraHeaders });
|
|
72
77
|
res.end();
|
|
@@ -183,6 +188,11 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
183
188
|
const base = config.base;
|
|
184
189
|
const resource = config.resourceUri;
|
|
185
190
|
const secret = config.secret;
|
|
191
|
+
// States, approvals and codes live minutes, and their single use rests on an
|
|
192
|
+
// in-memory replay guard that a restart empties. So they are signed with a
|
|
193
|
+
// key only this AuthServer holds: a restart invalidates every one in flight,
|
|
194
|
+
// instead of letting a spent or unreserved one pass the new, empty guard.
|
|
195
|
+
const flowSecret = new Uint8Array(randomBytes(32));
|
|
186
196
|
const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
|
|
187
197
|
const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
|
|
188
198
|
const replay = deps.replayGuard ?? new ReplayGuard();
|
|
@@ -195,6 +205,26 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
195
205
|
}
|
|
196
206
|
const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey, { maxAgeSec: config.refreshMaxAgeSec, idleSec: config.refreshIdleSec, log });
|
|
197
207
|
const nowSec = () => Math.floor(now() / 1000);
|
|
208
|
+
// A full guard is refused at whatever rate the caller likes, so its line is
|
|
209
|
+
// written at most once a minute (or at once after the clock stepped back).
|
|
210
|
+
let fullLoggedAt = -Infinity;
|
|
211
|
+
let fullSuppressed = 0;
|
|
212
|
+
const noteFull = (at) => {
|
|
213
|
+
if (at >= fullLoggedAt && at - fullLoggedAt < 60_000) {
|
|
214
|
+
fullSuppressed++;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
log(`replay guard full: sign-in refused${fullSuppressed > 0 ? ` (n=${fullSuppressed} suppressed)` : ''}`);
|
|
218
|
+
fullLoggedAt = at;
|
|
219
|
+
fullSuppressed = 0;
|
|
220
|
+
};
|
|
221
|
+
const spend = (artifact, ttlSec) => {
|
|
222
|
+
const at = now();
|
|
223
|
+
const r = replay.spend(artifact.jti, ttlSec * 1000, at, artifact.exp * 1000);
|
|
224
|
+
if (r === 'full')
|
|
225
|
+
noteFull(at);
|
|
226
|
+
return r;
|
|
227
|
+
};
|
|
198
228
|
const resolveSubject = deps.resolveSubject ?? ((email) => (config.ownerEmails.includes(email) ? { sub: 'owner' } : null));
|
|
199
229
|
const issHeader = { 'Cache-Control': 'no-store' };
|
|
200
230
|
const prm = {
|
|
@@ -261,7 +291,9 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
261
291
|
function escapeHtml(s) {
|
|
262
292
|
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
263
293
|
}
|
|
264
|
-
|
|
294
|
+
/** `incoming`: the artifact spent to get here. If no slot is left for the
|
|
295
|
+
* new state it is unspent, so retrying it works once a slot frees. */
|
|
296
|
+
async function toGoogle(res, sp, incoming) {
|
|
265
297
|
// H4 F1: without this, a Google URL minted here and forwarded to someone
|
|
266
298
|
// else signs THEM in and hands their subject to the starting client.
|
|
267
299
|
const headers = {};
|
|
@@ -271,7 +303,16 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
271
303
|
sp = { ...sp, bind };
|
|
272
304
|
headers['Set-Cookie'] = `${bindCookieName(base, bind)}=${value}; ${bindCookieAttrs(base, STATE_TTL_DEFAULT)}`;
|
|
273
305
|
}
|
|
274
|
-
const state = await signState(sp, base,
|
|
306
|
+
const state = await signState(sp, base, flowSecret, nowSec());
|
|
307
|
+
// Its /callback must find a slot even while a flood fills the guard, or a
|
|
308
|
+
// refusal there (the browser binding) could not be recorded.
|
|
309
|
+
const at = now();
|
|
310
|
+
if (replay.reserve(String(decodeJwt(state).jti), STATE_TTL_DEFAULT * 1000, at) === 'full') {
|
|
311
|
+
if (incoming)
|
|
312
|
+
replay.unspend(incoming);
|
|
313
|
+
noteFull(at);
|
|
314
|
+
return signInBusy(res);
|
|
315
|
+
}
|
|
275
316
|
const authUrl = deps.buildGoogleAuthUrl
|
|
276
317
|
? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
|
|
277
318
|
: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
|
|
@@ -290,22 +331,25 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
290
331
|
}
|
|
291
332
|
let pending;
|
|
292
333
|
try {
|
|
293
|
-
pending = await verifyPending(form.pending ?? '', base,
|
|
334
|
+
pending = await verifyPending(form.pending ?? '', base, flowSecret, nowSec());
|
|
294
335
|
}
|
|
295
336
|
catch {
|
|
296
337
|
return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
|
|
297
338
|
}
|
|
298
|
-
// Only the client leg signs pendings, and it is always the owner sign-in
|
|
299
|
-
// a pending
|
|
339
|
+
// Only the client leg signs pendings, and it is always the owner sign-in.
|
|
340
|
+
// Defence in depth: a pending from an earlier release, which could carry
|
|
341
|
+
// flow=alias_reauth, was signed with MCP_JWT_KEY and no longer verifies.
|
|
300
342
|
if (pending.flow !== 'owner_gate') {
|
|
301
343
|
return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
|
|
302
344
|
}
|
|
303
|
-
|
|
345
|
+
const spent = spend(pending, STATE_TTL_DEFAULT);
|
|
346
|
+
if (spent === 'full')
|
|
347
|
+
return signInBusy(res);
|
|
348
|
+
if (spent === 'replay')
|
|
304
349
|
return errorPage(res, 400, 'E_STATE_INVALID', 'this approval was already used');
|
|
305
|
-
}
|
|
306
350
|
const { jti: _drop, ...sp } = pending;
|
|
307
351
|
void _drop;
|
|
308
|
-
return toGoogle(res, sp);
|
|
352
|
+
return toGoogle(res, sp, pending.jti);
|
|
309
353
|
}
|
|
310
354
|
const q = url.searchParams;
|
|
311
355
|
const clientId = q.get('client_id') ?? '';
|
|
@@ -322,7 +366,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
322
366
|
if (q.get('flow') === 'alias_add' && !clientId) {
|
|
323
367
|
let minted;
|
|
324
368
|
try {
|
|
325
|
-
minted = await verifyState(q.get('state') ?? '', base,
|
|
369
|
+
minted = await verifyState(q.get('state') ?? '', base, flowSecret, nowSec());
|
|
326
370
|
}
|
|
327
371
|
catch {
|
|
328
372
|
return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is invalid, expired, or tampered');
|
|
@@ -330,12 +374,14 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
330
374
|
if (minted.flow !== 'alias_add' || !minted.alias || !minted.tenantId) {
|
|
331
375
|
return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is malformed');
|
|
332
376
|
}
|
|
333
|
-
|
|
377
|
+
const spent = spend(minted, STATE_TTL_DEFAULT);
|
|
378
|
+
if (spent === 'full')
|
|
379
|
+
return signInBusy(res);
|
|
380
|
+
if (spent === 'replay')
|
|
334
381
|
return errorPage(res, 400, 'E_STATE_INVALID', 'this add-account link was already used');
|
|
335
|
-
}
|
|
336
382
|
const { jti: _spent, ...sp } = minted;
|
|
337
383
|
void _spent;
|
|
338
|
-
return toGoogle(res, sp);
|
|
384
|
+
return toGoogle(res, sp, minted.jti);
|
|
339
385
|
}
|
|
340
386
|
// In-call re-auth (BV-1): a user-clicked `flow=alias_reauth` link has NO
|
|
341
387
|
// client leg — no code is delivered to any client, the refreshed token is
|
|
@@ -393,7 +439,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
393
439
|
// it — their redirect_uris come from an issuer-allowlisted document a rogue
|
|
394
440
|
// cannot forge, so there is nothing to spoof.
|
|
395
441
|
if (client.dcr) {
|
|
396
|
-
const pendingToken = await signPending(statePayload, base,
|
|
442
|
+
const pendingToken = await signPending(statePayload, base, flowSecret, nowSec());
|
|
397
443
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'", 'X-Frame-Options': 'DENY' });
|
|
398
444
|
res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
|
|
399
445
|
`<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
|
|
@@ -413,14 +459,16 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
413
459
|
const stateToken = url.searchParams.get('state') ?? '';
|
|
414
460
|
let st;
|
|
415
461
|
try {
|
|
416
|
-
st = await verifyState(stateToken, base,
|
|
462
|
+
st = await verifyState(stateToken, base, flowSecret, nowSec());
|
|
417
463
|
}
|
|
418
464
|
catch {
|
|
419
465
|
return errorPage(res, 400, 'E_STATE_INVALID', 'state is invalid, expired, or tampered');
|
|
420
466
|
}
|
|
421
|
-
|
|
467
|
+
const spent = spend(st, STATE_TTL_DEFAULT);
|
|
468
|
+
if (spent === 'full')
|
|
469
|
+
return signInBusy(res);
|
|
470
|
+
if (spent === 'replay')
|
|
422
471
|
return errorPage(res, 400, 'E_STATE_INVALID', 'state has already been used (replay)');
|
|
423
|
-
}
|
|
424
472
|
// After the spend, so a refused callback URL cannot be replayed from the
|
|
425
473
|
// browser that holds the cookie; before the exchange and resolveSubject.
|
|
426
474
|
if (st.flow === 'owner_gate') {
|
|
@@ -528,7 +576,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
528
576
|
const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
|
|
529
577
|
return redirect(res, `${st.redirect_uri}${sep}error=access_denied${s}&iss=${iss}`);
|
|
530
578
|
}
|
|
531
|
-
const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: subject.sub }, base,
|
|
579
|
+
const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: subject.sub }, base, flowSecret, nowSec());
|
|
532
580
|
log('callback ok flow=owner_gate');
|
|
533
581
|
const sep = st.redirect_uri.includes('?') ? '&' : '?';
|
|
534
582
|
const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
|
|
@@ -547,14 +595,16 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
547
595
|
if (grant === 'authorization_code') {
|
|
548
596
|
let code;
|
|
549
597
|
try {
|
|
550
|
-
code = await verifyAuthzCode(form.code ?? '', base,
|
|
598
|
+
code = await verifyAuthzCode(form.code ?? '', base, flowSecret, nowSec());
|
|
551
599
|
}
|
|
552
600
|
catch {
|
|
553
601
|
return json(res, 400, { error: 'invalid_grant', message: 'authorization code invalid or expired' });
|
|
554
602
|
}
|
|
555
|
-
|
|
603
|
+
const spent = spend(code, CODE_TTL_DEFAULT);
|
|
604
|
+
if (spent === 'full')
|
|
605
|
+
return subjectUnavailable(res);
|
|
606
|
+
if (spent === 'replay')
|
|
556
607
|
return json(res, 400, { error: 'invalid_grant', message: 'authorization code already redeemed' });
|
|
557
|
-
}
|
|
558
608
|
if (form.redirect_uri !== code.redirect_uri) {
|
|
559
609
|
return json(res, 400, { error: 'invalid_grant', message: 'redirect_uri mismatch' });
|
|
560
610
|
}
|
|
@@ -572,6 +622,8 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
572
622
|
}
|
|
573
623
|
catch (e) {
|
|
574
624
|
log(`token deferred grant=authorization_code: subject check failed: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
|
|
625
|
+
// Nothing was issued and PKCE passed, so the retry the 503 asks for may redeem it.
|
|
626
|
+
replay.unspend(code.jti);
|
|
575
627
|
return subjectUnavailable(res);
|
|
576
628
|
}
|
|
577
629
|
if (!active) {
|
|
@@ -684,7 +736,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
684
736
|
// here would be a state no route verifies the way its flow needs.
|
|
685
737
|
if (flow !== 'alias_add')
|
|
686
738
|
throw new Error(`mintFlowState supports alias_add only, not ${String(flow)}`);
|
|
687
|
-
const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base,
|
|
739
|
+
const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, flowSecret, nowSec());
|
|
688
740
|
return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
|
|
689
741
|
};
|
|
690
742
|
const reauthLink = (alias) => `${base}/authorize?flow=alias_reauth&${signReauthLink(base, secret, alias, nowSec())}`;
|
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.66",
|
|
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",
|