mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.4
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/README.md +5 -5
- package/dist/accounts.d.ts +20 -4
- package/dist/accounts.js +68 -44
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +20 -15
- package/dist/arg-normalize.js +19 -38
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +38 -28
- package/dist/auth.d.ts +5 -4
- package/dist/auth.js +16 -23
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +36 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +111 -0
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +8 -14
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +18 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +13 -0
- package/dist/config-file.js +41 -10
- package/dist/discover.js +7 -10
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +36 -32
- package/dist/doctor.d.ts +9 -5
- package/dist/doctor.js +119 -29
- package/dist/env-load.js +2 -5
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +6 -6
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +47 -22
- package/dist/fs-atomic.d.ts +8 -0
- package/dist/fs-atomic.js +216 -29
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +58 -0
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +3 -7
- package/dist/http-transport.d.ts +67 -9
- package/dist/http-transport.js +318 -52
- package/dist/identity.d.ts +6 -3
- package/dist/identity.js +24 -8
- package/dist/index.js +76 -160
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +16 -19
- package/dist/mcp-token.d.ts +126 -19
- package/dist/mcp-token.js +557 -76
- package/dist/metrics-cli.d.ts +1 -1
- package/dist/metrics-cli.js +1 -1
- package/dist/metrics-tap.js +2 -5
- package/dist/migrate-config.d.ts +1 -2
- package/dist/migrate-config.js +5 -7
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +104 -4
- package/dist/oauth-as.js +368 -114
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +20 -10
- package/dist/outbound-allowlist.js +9 -11
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +11 -10
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +63 -11
- package/dist/registry.d.ts +13 -4
- package/dist/registry.js +100 -72
- package/dist/scope-catalog.d.ts +16 -0
- package/dist/scope-catalog.js +40 -13
- package/dist/scope-observability.d.ts +10 -9
- package/dist/scope-observability.js +34 -24
- package/dist/services.d.ts +18 -10
- package/dist/services.js +61 -22
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +106 -78
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +65 -0
- package/dist/token-store.d.ts +20 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +4 -4
- package/dist/tools/_errors.d.ts +3 -0
- package/dist/tools/_errors.js +72 -60
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +22 -8
- package/dist/tools/account-wizard.d.ts +13 -3
- package/dist/tools/account-wizard.js +107 -56
- package/dist/tools/accounts-tool.d.ts +8 -1
- package/dist/tools/accounts-tool.js +35 -19
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +14 -11
- package/dist/tools/analytics.d.ts +10 -1
- package/dist/tools/analytics.js +14 -10
- package/dist/tools/calendar.d.ts +2 -1
- package/dist/tools/calendar.js +19 -18
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +12 -8
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +22 -19
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +35 -36
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +80 -74
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +18 -12
- package/dist/tools/generated/_shared.d.ts +2 -1
- package/dist/tools/generated/_shared.js +7 -3
- package/dist/tools/generated/admin.d.ts +2 -1
- package/dist/tools/generated/admin.js +265 -265
- package/dist/tools/generated/analytics.d.ts +2 -1
- package/dist/tools/generated/analytics.js +125 -125
- package/dist/tools/generated/appsmarket.d.ts +2 -1
- package/dist/tools/generated/appsmarket.js +5 -5
- package/dist/tools/generated/calendar.d.ts +2 -1
- package/dist/tools/generated/calendar.js +55 -55
- package/dist/tools/generated/chat.d.ts +2 -1
- package/dist/tools/generated/chat.js +95 -95
- package/dist/tools/generated/classroom.d.ts +2 -1
- package/dist/tools/generated/classroom.js +209 -209
- package/dist/tools/generated/cloudidentity.d.ts +2 -1
- package/dist/tools/generated/cloudidentity.js +133 -133
- package/dist/tools/generated/cloudsearch.d.ts +2 -1
- package/dist/tools/generated/cloudsearch.js +99 -99
- package/dist/tools/generated/contacts.d.ts +2 -1
- package/dist/tools/generated/contacts.js +29 -29
- package/dist/tools/generated/drive.d.ts +2 -1
- package/dist/tools/generated/drive.js +71 -71
- package/dist/tools/generated/driveactivity.d.ts +2 -1
- package/dist/tools/generated/driveactivity.js +3 -3
- package/dist/tools/generated/drivelabels.d.ts +2 -1
- package/dist/tools/generated/drivelabels.js +53 -53
- package/dist/tools/generated/forms.d.ts +2 -1
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.d.ts +2 -1
- package/dist/tools/generated/gmail.js +117 -117
- package/dist/tools/generated/groupsmigration.d.ts +2 -1
- package/dist/tools/generated/groupsmigration.js +3 -3
- package/dist/tools/generated/groupssettings.d.ts +2 -1
- package/dist/tools/generated/groupssettings.js +7 -7
- package/dist/tools/generated/index.d.ts +2 -1
- package/dist/tools/generated/keep.d.ts +2 -1
- package/dist/tools/generated/keep.js +15 -15
- package/dist/tools/generated/licensing.d.ts +2 -1
- package/dist/tools/generated/licensing.js +15 -15
- package/dist/tools/generated/meet.d.ts +2 -1
- package/dist/tools/generated/meet.js +27 -27
- package/dist/tools/generated/postmaster.d.ts +2 -1
- package/dist/tools/generated/postmaster.js +9 -9
- package/dist/tools/generated/reseller.d.ts +2 -1
- package/dist/tools/generated/reseller.js +35 -35
- package/dist/tools/generated/script.d.ts +2 -1
- package/dist/tools/generated/script.js +33 -33
- package/dist/tools/generated/searchconsole.d.ts +2 -1
- package/dist/tools/generated/searchconsole.js +3 -3
- package/dist/tools/generated/sheets.d.ts +2 -1
- package/dist/tools/generated/sheets.js +15 -15
- package/dist/tools/generated/tasks.d.ts +2 -1
- package/dist/tools/generated/tasks.js +5 -5
- package/dist/tools/generated/vault.d.ts +2 -1
- package/dist/tools/generated/vault.js +67 -67
- package/dist/tools/generated/workspaceevents.d.ts +2 -1
- package/dist/tools/generated/workspaceevents.js +31 -31
- package/dist/tools/gmail-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +7 -12
- package/dist/tools/gmail.d.ts +4 -3
- package/dist/tools/gmail.js +67 -69
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +34 -22
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +12 -9
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +16 -13
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +35 -32
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +16 -10
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +18 -15
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +19 -0
- package/dist/usage-metrics.d.ts +23 -0
- package/dist/usage-metrics.js +67 -56
- package/dist/write-control.d.ts +4 -8
- package/dist/write-control.js +13 -6
- package/package.json +47 -21
package/dist/mcp-token.js
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
// the opaque rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY
|
|
5
|
-
// (B5), so tokens survive a restart as long as the key persists.
|
|
6
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
// Token model: the MCP access token (HS256 JWT, jose-only), signed state and
|
|
2
|
+
// authorization code, the replay guard, and the rotated refresh-token store.
|
|
3
|
+
import { createHash, createHmac, hkdfSync, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
7
4
|
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { basename } from 'node:path';
|
|
8
6
|
import { SignJWT, jwtVerify } from 'jose';
|
|
9
7
|
import { deriveKey, encryptToken, decryptToken } from './token-store.js';
|
|
10
8
|
import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
|
|
11
9
|
export const ACCESS_TTL_DEFAULT = 600; // seconds
|
|
12
10
|
export const STATE_TTL_DEFAULT = 600;
|
|
13
11
|
export const CODE_TTL_DEFAULT = 60;
|
|
14
|
-
export const REPLAY_CAP_DEFAULT =
|
|
12
|
+
export const REPLAY_CAP_DEFAULT = 100_000;
|
|
15
13
|
/** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
|
|
16
14
|
export function jwtSecretFrom(jwtKey) {
|
|
17
15
|
return new Uint8Array(deriveKey(jwtKey));
|
|
@@ -20,11 +18,13 @@ function newJti() {
|
|
|
20
18
|
return randomBytes(16).toString('base64url');
|
|
21
19
|
}
|
|
22
20
|
export async function signAccessToken(p) {
|
|
21
|
+
if (typeof p.sub !== 'string' || p.sub === '')
|
|
22
|
+
throw new Error('signAccessToken: sub must be a non-empty string');
|
|
23
23
|
const ttl = p.ttlSec ?? ACCESS_TTL_DEFAULT;
|
|
24
24
|
return new SignJWT({ scope: 'mcp:use', purpose: 'mcp_access' })
|
|
25
25
|
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
26
26
|
.setIssuer(p.base)
|
|
27
|
-
.setSubject(
|
|
27
|
+
.setSubject(p.sub)
|
|
28
28
|
.setAudience(`${p.base}/mcp`)
|
|
29
29
|
.setIssuedAt(p.iat)
|
|
30
30
|
.setExpirationTime(p.iat + ttl)
|
|
@@ -36,7 +36,9 @@ export async function verifyAccessToken(token, base, secret) {
|
|
|
36
36
|
const { payload } = await jwtVerify(token, secret, { issuer: base, audience: `${base}/mcp` });
|
|
37
37
|
if (payload.purpose !== 'mcp_access')
|
|
38
38
|
throw new Error('wrong token purpose');
|
|
39
|
-
|
|
39
|
+
if (typeof payload.sub !== 'string' || payload.sub === '')
|
|
40
|
+
throw new Error('access token has no subject');
|
|
41
|
+
return { sub: payload.sub, scope: String(payload.scope ?? ''), jti: String(payload.jti ?? '') };
|
|
40
42
|
}
|
|
41
43
|
async function signArtifact(claims, purpose, base, secret, iat, ttlSec) {
|
|
42
44
|
return new SignJWT({ ...claims, purpose })
|
|
@@ -47,130 +49,609 @@ async function signArtifact(claims, purpose, base, secret, iat, ttlSec) {
|
|
|
47
49
|
.setJti(newJti())
|
|
48
50
|
.sign(secret);
|
|
49
51
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
// `nowSec`: the caller's clock, so expiry is judged by the same clock as the
|
|
53
|
+
// replay guard that records the artifact's spend.
|
|
54
|
+
async function verifyArtifact(token, purpose, base, secret, nowSec) {
|
|
55
|
+
const { payload } = await jwtVerify(token, secret, { issuer: base, ...(nowSec !== undefined ? { currentDate: new Date(nowSec * 1000) } : {}) });
|
|
52
56
|
if (payload.purpose !== purpose)
|
|
53
57
|
throw new Error(`wrong artifact purpose (${String(payload.purpose)})`);
|
|
54
|
-
return { ...payload, jti: String(payload.jti ?? '') };
|
|
58
|
+
return { ...payload, jti: String(payload.jti ?? ''), exp: Number(payload.exp ?? 0) };
|
|
55
59
|
}
|
|
56
60
|
export function signState(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
|
|
57
61
|
return signArtifact(payload, 'mcp_state', base, secret, iat, ttlSec);
|
|
58
62
|
}
|
|
59
|
-
export async function verifyState(token, base, secret) {
|
|
60
|
-
return (await verifyArtifact(token, 'mcp_state', base, secret));
|
|
63
|
+
export async function verifyState(token, base, secret, nowSec) {
|
|
64
|
+
return (await verifyArtifact(token, 'mcp_state', base, secret, nowSec));
|
|
65
|
+
}
|
|
66
|
+
// --- alias_reauth link -------------------------------------------------------
|
|
67
|
+
/** A re-auth link is handed out in a tool error, so it has to survive until
|
|
68
|
+
* the user clicks it; an hour, not the 10-minute state TTL. */
|
|
69
|
+
export const REAUTH_LINK_TTL_SEC = 3600;
|
|
70
|
+
function reauthMac(base, secret, alias, exp) {
|
|
71
|
+
// The newline-separated input can never be a JWT signing input (two
|
|
72
|
+
// base64url segments and one dot), so the shared key stays unambiguous.
|
|
73
|
+
return createHmac('sha256', secret).update(`alias_reauth\n${base}\n${alias}\n${exp}`).digest();
|
|
74
|
+
}
|
|
75
|
+
/** Query string of a server-issued alias_reauth link: the alias, an expiry and
|
|
76
|
+
* an HMAC over both. Synchronous, so the error hints that carry it stay so. */
|
|
77
|
+
export function signReauthLink(base, secret, alias, nowSec) {
|
|
78
|
+
const exp = nowSec + REAUTH_LINK_TTL_SEC;
|
|
79
|
+
return `alias=${encodeURIComponent(alias)}&exp=${exp}&sig=${reauthMac(base, secret, alias, exp).toString('base64url')}`;
|
|
80
|
+
}
|
|
81
|
+
/** The alias a link was issued for, or null when it is missing, forged or expired. */
|
|
82
|
+
export function verifyReauthLink(base, secret, params, nowSec) {
|
|
83
|
+
const exp = Number(params.exp);
|
|
84
|
+
if (!params.alias || !/^\d+$/.test(params.exp) || exp < nowSec)
|
|
85
|
+
return null;
|
|
86
|
+
const want = reauthMac(base, secret, params.alias, exp);
|
|
87
|
+
const got = Buffer.from(params.sig, 'base64url');
|
|
88
|
+
return got.length === want.length && timingSafeEqual(got, want) ? params.alias : null;
|
|
61
89
|
}
|
|
62
90
|
/** A pending-authorization artifact for the DCR consent interstitial (same
|
|
63
91
|
* shape as `state`, distinct purpose so the two can't be confused). */
|
|
64
92
|
export function signPending(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
|
|
65
93
|
return signArtifact(payload, 'mcp_pending', base, secret, iat, ttlSec);
|
|
66
94
|
}
|
|
67
|
-
export async function verifyPending(token, base, secret) {
|
|
68
|
-
return (await verifyArtifact(token, 'mcp_pending', base, secret));
|
|
95
|
+
export async function verifyPending(token, base, secret, nowSec) {
|
|
96
|
+
return (await verifyArtifact(token, 'mcp_pending', base, secret, nowSec));
|
|
69
97
|
}
|
|
70
98
|
export function signAuthzCode(payload, base, secret, iat, ttlSec = CODE_TTL_DEFAULT) {
|
|
71
99
|
return signArtifact(payload, 'mcp_code', base, secret, iat, ttlSec);
|
|
72
100
|
}
|
|
73
|
-
export async function verifyAuthzCode(token, base, secret) {
|
|
74
|
-
return (await verifyArtifact(token, 'mcp_code', base, secret));
|
|
101
|
+
export async function verifyAuthzCode(token, base, secret, nowSec) {
|
|
102
|
+
return (await verifyArtifact(token, 'mcp_code', base, secret, nowSec));
|
|
75
103
|
}
|
|
76
|
-
|
|
104
|
+
/** A full guard refuses rather than sweeping again sooner than this. */
|
|
105
|
+
const FULL_SWEEP_EVERY_MS = 1000;
|
|
77
106
|
export class ReplayGuard {
|
|
78
107
|
cap;
|
|
79
|
-
seen = new Map();
|
|
108
|
+
seen = new Map();
|
|
109
|
+
// A lower bound on every entry's expiry: below it nothing can have expired.
|
|
110
|
+
earliest = Infinity;
|
|
111
|
+
sweptAt = -Infinity;
|
|
80
112
|
constructor(cap = REPLAY_CAP_DEFAULT) {
|
|
81
113
|
this.cap = cap;
|
|
82
114
|
}
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
115
|
+
/** Hold a slot for an artifact about to be handed out, so its spend can
|
|
116
|
+
* never meet a full guard. The flood then lands where the artifact is
|
|
117
|
+
* minted, and refusing there loses nothing. */
|
|
118
|
+
reserve(jti, ttlMs, nowMs) {
|
|
119
|
+
if (!this.room(nowMs))
|
|
120
|
+
return 'full';
|
|
121
|
+
this.record(jti, nowMs + ttlMs, false);
|
|
122
|
+
return 'ok';
|
|
123
|
+
}
|
|
124
|
+
/** Record a jti as spent. A full guard refuses instead of evicting a live
|
|
125
|
+
* jti: a flood of spends would otherwise make a spent artifact usable again,
|
|
126
|
+
* such as a callback the browser binding refused. `artifactExpMs`
|
|
127
|
+
* keeps the record alive as long as the artifact itself, even when the clock
|
|
128
|
+
* stepped back between its signing and this spend. */
|
|
129
|
+
spend(jti, ttlMs, nowMs, artifactExpMs = 0) {
|
|
130
|
+
const exp = Math.max(nowMs + ttlMs, artifactExpMs);
|
|
131
|
+
const held = this.seen.get(jti);
|
|
132
|
+
if (held && held.exp > nowMs) {
|
|
133
|
+
if (held.spent)
|
|
134
|
+
return 'replay';
|
|
135
|
+
held.spent = true;
|
|
136
|
+
held.exp = Math.max(held.exp, exp);
|
|
137
|
+
return 'ok';
|
|
93
138
|
}
|
|
94
|
-
|
|
95
|
-
|
|
139
|
+
if (held)
|
|
140
|
+
this.seen.delete(jti);
|
|
141
|
+
if (!this.room(nowMs))
|
|
142
|
+
return 'full';
|
|
143
|
+
this.record(jti, exp, true);
|
|
144
|
+
return 'ok';
|
|
145
|
+
}
|
|
146
|
+
/** Undo a spend whose next step could not go ahead. The slot stays held, so
|
|
147
|
+
* nothing is forgotten, and the artifact can be spent once more. */
|
|
148
|
+
unspend(jti) {
|
|
149
|
+
const held = this.seen.get(jti);
|
|
150
|
+
if (held)
|
|
151
|
+
held.spent = false;
|
|
152
|
+
}
|
|
153
|
+
/** `spend` as a boolean: false for a replay and for a full guard alike. */
|
|
154
|
+
consume(jti, ttlMs, nowMs) {
|
|
155
|
+
return this.spend(jti, ttlMs, nowMs) === 'ok';
|
|
96
156
|
}
|
|
97
157
|
get size() {
|
|
98
158
|
return this.seen.size;
|
|
99
159
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
160
|
+
record(jti, exp, spent) {
|
|
161
|
+
this.seen.set(jti, { exp, spent });
|
|
162
|
+
if (exp < this.earliest)
|
|
163
|
+
this.earliest = exp;
|
|
164
|
+
}
|
|
165
|
+
// Swept only when at the cap (a refusal otherwise costs O(1)), and at most
|
|
166
|
+
// once a second: a Map iterator steps over every deleted slot until rebuilt.
|
|
167
|
+
room(nowMs) {
|
|
168
|
+
if (this.seen.size < this.cap)
|
|
169
|
+
return true;
|
|
170
|
+
if (nowMs < this.earliest)
|
|
171
|
+
return false;
|
|
172
|
+
if (nowMs >= this.sweptAt && nowMs - this.sweptAt < FULL_SWEEP_EVERY_MS)
|
|
173
|
+
return false;
|
|
174
|
+
this.sweptAt = nowMs;
|
|
175
|
+
let earliest = Infinity;
|
|
176
|
+
for (const [jti, e] of this.seen) {
|
|
177
|
+
if (e.exp <= nowMs)
|
|
103
178
|
this.seen.delete(jti);
|
|
179
|
+
else if (e.exp < earliest)
|
|
180
|
+
earliest = e.exp;
|
|
104
181
|
}
|
|
182
|
+
this.earliest = earliest;
|
|
183
|
+
return this.seen.size < this.cap;
|
|
105
184
|
}
|
|
106
185
|
}
|
|
107
|
-
|
|
186
|
+
/** Store files this release writes; an absent `format` is an earlier release's. */
|
|
187
|
+
const REFRESH_FORMAT = 2;
|
|
188
|
+
// r1 token: "r1." + base64url(fid 16 || gen uint32 BE || rand 16 || HMAC-SHA256).
|
|
189
|
+
// The dot is outside the base64url alphabet, so it can't be read as a legacy 43-char token.
|
|
190
|
+
const R1_PREFIX = 'r1.';
|
|
191
|
+
const R1_LENGTH = 94;
|
|
192
|
+
const R1_BODY = 36;
|
|
193
|
+
const R1_BYTES = 68;
|
|
194
|
+
const R1_MAC_DOMAIN = Buffer.from('mcp-google-multi refresh r1\n');
|
|
195
|
+
const R1_KEY_INFO = 'mcp-google-multi:refresh-mac:v1';
|
|
196
|
+
// A passphrase key (deriveKey's bare-sha256 branch) would otherwise let one
|
|
197
|
+
// leaked r1 token test guesses offline at hash speed; scrypt adds a work factor.
|
|
198
|
+
const R1_SCRYPT_SALT = 'mcp-google-multi:refresh-mac:scrypt:v1';
|
|
199
|
+
const R1_SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
200
|
+
const GEN_MAX = 0xffffffff;
|
|
201
|
+
const LEGACY_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
202
|
+
/** With a lifetime set, a stored time further ahead than this is pulled back to the clock. */
|
|
203
|
+
const CLOCK_SKEW_MS = 5 * 60_000;
|
|
204
|
+
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
205
|
+
const sha256 = (b) => createHash('sha256').update(b).digest();
|
|
206
|
+
/** A non-secret tag for log lines and rate-limit keys: never the token. */
|
|
207
|
+
const familyTag = (family) => sha256(family).toString('hex').slice(0, 8);
|
|
208
|
+
// Keyed on the master key: scrypt runs once per process, not once per store.
|
|
209
|
+
const macKeyMemo = new Map();
|
|
210
|
+
function refreshMacKey(masterKey) {
|
|
211
|
+
const hit = macKeyMemo.get(masterKey);
|
|
212
|
+
if (hit)
|
|
213
|
+
return hit;
|
|
214
|
+
const raw = deriveKey(masterKey);
|
|
215
|
+
const ikm = Buffer.from(masterKey, 'base64').length === 32 ? raw : scryptSync(masterKey, R1_SCRYPT_SALT, 32, R1_SCRYPT);
|
|
216
|
+
const key = Buffer.from(hkdfSync('sha256', ikm, Buffer.alloc(0), R1_KEY_INFO, 32));
|
|
217
|
+
macKeyMemo.set(masterKey, key);
|
|
218
|
+
return key;
|
|
219
|
+
}
|
|
220
|
+
function r1Mac(key, body) {
|
|
221
|
+
return createHmac('sha256', key).update(R1_MAC_DOMAIN).update(body).digest();
|
|
222
|
+
}
|
|
223
|
+
/** A token this key minted, or null. Pure CPU: runs before the file lock. */
|
|
224
|
+
function parseR1(t, key) {
|
|
225
|
+
if (typeof t !== 'string' || t.length !== R1_LENGTH || !t.startsWith(R1_PREFIX))
|
|
226
|
+
return null;
|
|
227
|
+
const text = t.slice(R1_PREFIX.length);
|
|
228
|
+
const buf = Buffer.from(text, 'base64url');
|
|
229
|
+
// The decoder skips stray characters and ignores the last one's pad bits;
|
|
230
|
+
// re-encoding leaves one spelling per token.
|
|
231
|
+
if (buf.length !== R1_BYTES || buf.toString('base64url') !== text)
|
|
232
|
+
return null;
|
|
233
|
+
if (!timingSafeEqual(buf.subarray(R1_BODY), r1Mac(key, buf.subarray(0, R1_BODY))))
|
|
234
|
+
return null;
|
|
235
|
+
const fidBytes = buf.subarray(0, 16);
|
|
236
|
+
return { fidBytes, fid: fidBytes.toString('base64url'), gen: buf.readUInt32BE(16), digest: sha256(buf) };
|
|
237
|
+
}
|
|
238
|
+
function mintR1(key, fid, gen) {
|
|
239
|
+
const body = Buffer.alloc(R1_BODY);
|
|
240
|
+
fid.copy(body, 0);
|
|
241
|
+
body.writeUInt32BE(gen, 16);
|
|
242
|
+
randomBytes(16).copy(body, 20);
|
|
243
|
+
const buf = Buffer.concat([body, r1Mac(key, body)]);
|
|
244
|
+
return { token: R1_PREFIX + buf.toString('base64url'), cur: sha256(buf).toString('base64url') };
|
|
245
|
+
}
|
|
108
246
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
247
|
+
* The family tag of a refresh token this `masterKey`'s store minted, or null
|
|
248
|
+
* for anything else. Derives the key once; the returned function does no I/O,
|
|
249
|
+
* so a host can key a pre-auth rate limit on it.
|
|
250
|
+
*/
|
|
251
|
+
export function refreshFamilyTagger(masterKey) {
|
|
252
|
+
const key = refreshMacKey(masterKey);
|
|
253
|
+
return (token) => {
|
|
254
|
+
const p = parseR1(token, key);
|
|
255
|
+
return p ? familyTag(p.fidBytes) : null;
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function ownEntries(o) {
|
|
259
|
+
// An own `__proto__` key (JSON.parse makes one) would set the prototype when copied.
|
|
260
|
+
return o !== null && typeof o === 'object' && !Array.isArray(o) ? Object.entries(o).filter(([k]) => k !== '__proto__') : [];
|
|
261
|
+
}
|
|
262
|
+
function isLegacyRecord(r) {
|
|
263
|
+
if (r === null || typeof r !== 'object')
|
|
264
|
+
return false;
|
|
265
|
+
const x = r;
|
|
266
|
+
return typeof x.sub === 'string' && x.sub !== '' && typeof x.family === 'string' && typeof x.issuedAt === 'number' && Number.isFinite(x.issuedAt);
|
|
267
|
+
}
|
|
268
|
+
function isCanonicalB64(s, bytes) {
|
|
269
|
+
return B64URL.test(s) && Buffer.from(s, 'base64url').length === bytes && Buffer.from(s, 'base64url').toString('base64url') === s;
|
|
270
|
+
}
|
|
271
|
+
function isFamilyRecord(key, r) {
|
|
272
|
+
if (r === null || typeof r !== 'object')
|
|
273
|
+
return false;
|
|
274
|
+
const x = r;
|
|
275
|
+
return (isCanonicalB64(key, 16) &&
|
|
276
|
+
typeof x.sub === 'string' && x.sub !== '' &&
|
|
277
|
+
Number.isInteger(x.gen) && x.gen >= 0 && x.gen <= GEN_MAX &&
|
|
278
|
+
typeof x.cur === 'string' && x.cur.length === 43 && isCanonicalB64(x.cur, 32) &&
|
|
279
|
+
Number.isFinite(x.createdAt) && Number.isFinite(x.usedAt) &&
|
|
280
|
+
(x.legacy === undefined || typeof x.legacy === 'string'));
|
|
281
|
+
}
|
|
282
|
+
function formatError(file, format) {
|
|
283
|
+
const name = basename(file);
|
|
284
|
+
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`);
|
|
285
|
+
}
|
|
286
|
+
/** The decrypted plaintext, or null when the file is absent. Throws when it
|
|
287
|
+
* does not decrypt; the caller decides what that means. */
|
|
288
|
+
function readPlain(file, masterKey) {
|
|
289
|
+
if (!existsSync(file))
|
|
290
|
+
return null;
|
|
291
|
+
return decryptToken(readFileSync(file, 'utf-8'), masterKey);
|
|
292
|
+
}
|
|
293
|
+
// Outside any decrypt catch: a file from a newer release loaded as empty
|
|
294
|
+
// would be erased by the next write.
|
|
295
|
+
function checkFormat(file, d) {
|
|
296
|
+
if (d !== null && typeof d === 'object' && Object.hasOwn(d, 'format') && d.format !== REFRESH_FORMAT) {
|
|
297
|
+
throw formatError(file, d.format);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Only well-formed own entries survive, so no malformed record reaches a
|
|
301
|
+
// liveness test or the token codec; `dirty` says something was dropped.
|
|
302
|
+
function decodeData(d) {
|
|
303
|
+
const data = { families: {}, active: {}, spent: {} };
|
|
304
|
+
const x = (d ?? {});
|
|
305
|
+
let dropped = 0;
|
|
306
|
+
for (const [k, f] of ownEntries(x.families)) {
|
|
307
|
+
if (isFamilyRecord(k, f)) {
|
|
308
|
+
data.families[k] = { sub: f.sub, gen: f.gen, cur: f.cur, createdAt: f.createdAt, usedAt: f.usedAt, ...(f.legacy === undefined ? {} : { legacy: f.legacy }) };
|
|
309
|
+
}
|
|
310
|
+
else
|
|
311
|
+
dropped += 1;
|
|
312
|
+
}
|
|
313
|
+
for (const [t, r] of ownEntries(x.active)) {
|
|
314
|
+
if (isLegacyRecord(r))
|
|
315
|
+
data.active[t] = { sub: r.sub, issuedAt: r.issuedAt, family: r.family };
|
|
316
|
+
else
|
|
317
|
+
dropped += 1;
|
|
318
|
+
}
|
|
319
|
+
for (const [t, fam] of ownEntries(x.spent)) {
|
|
320
|
+
if (typeof fam === 'string')
|
|
321
|
+
data.spent[t] = fam;
|
|
322
|
+
else
|
|
323
|
+
dropped += 1;
|
|
324
|
+
}
|
|
325
|
+
return { data, dirty: dropped > 0 };
|
|
326
|
+
}
|
|
327
|
+
/** Every session of `sub`, in both formats. */
|
|
328
|
+
function dropSub(data, sub) {
|
|
329
|
+
let n = 0;
|
|
330
|
+
for (const [k, f] of Object.entries(data.families)) {
|
|
331
|
+
if (f.sub === sub) {
|
|
332
|
+
delete data.families[k];
|
|
333
|
+
n += 1;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const [t, r] of Object.entries(data.active)) {
|
|
337
|
+
if (r.sub === sub) {
|
|
338
|
+
delete data.active[t];
|
|
339
|
+
n += 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return n;
|
|
343
|
+
}
|
|
344
|
+
/** Boot check for a refresh store file: returns when it is absent or this
|
|
345
|
+
* release can read it; throws when it does not decrypt under `masterKey`
|
|
346
|
+
* (E_REFRESH_STORE_UNREADABLE) or a newer release wrote it
|
|
347
|
+
* (E_REFRESH_STORE_FORMAT). */
|
|
348
|
+
export function assertRefreshStoreReadable(file, masterKey) {
|
|
349
|
+
let d;
|
|
350
|
+
try {
|
|
351
|
+
d = readPlain(file, masterKey);
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
throw new Error(`E_REFRESH_STORE_UNREADABLE: ${basename(file)} does not decrypt with this MASTER_KEY`, { cause: e });
|
|
355
|
+
}
|
|
356
|
+
checkFormat(file, d);
|
|
357
|
+
}
|
|
358
|
+
function positiveInt(name, v) {
|
|
359
|
+
if (v === undefined)
|
|
360
|
+
return undefined;
|
|
361
|
+
if (typeof v !== 'number' || !Number.isSafeInteger(v) || v <= 0)
|
|
362
|
+
throw new Error(`E_REFRESH_OPTION_INVALID: ${name} must be a positive whole number`);
|
|
363
|
+
return v;
|
|
364
|
+
}
|
|
365
|
+
function lifetimeMs(name, v) {
|
|
366
|
+
const n = positiveInt(name, v);
|
|
367
|
+
return n === undefined ? undefined : n * 1000;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
|
|
371
|
+
* use, and a token carries its family and generation under a MAC, so
|
|
372
|
+
* presenting any generation the family has rotated past is REUSE: the family
|
|
373
|
+
* is revoked for the rest of its life. The store keeps one
|
|
374
|
+
* record per family, whatever its rotation count. Every read-modify-write goes
|
|
375
|
+
* through a file lock so concurrent stdio+HTTP processes can't lost-update or
|
|
376
|
+
* double-spend. See docs/internals.md.
|
|
114
377
|
*/
|
|
115
378
|
export class RefreshStore {
|
|
116
379
|
path;
|
|
117
380
|
masterKey;
|
|
118
|
-
|
|
381
|
+
log;
|
|
382
|
+
maxAgeMs;
|
|
383
|
+
idleMs;
|
|
384
|
+
maxFamiliesPerSub;
|
|
385
|
+
macKey;
|
|
386
|
+
// Set once a load finds no earlier-release record. Nothing here adds one,
|
|
387
|
+
// so from then on a legacy-shaped token is refused before the lock.
|
|
388
|
+
legacyEmpty = false;
|
|
389
|
+
constructor(path, masterKey, options = {}) {
|
|
119
390
|
this.path = path;
|
|
120
391
|
this.masterKey = masterKey;
|
|
392
|
+
this.maxAgeMs = lifetimeMs('maxAgeSec', options.maxAgeSec);
|
|
393
|
+
this.idleMs = lifetimeMs('idleSec', options.idleSec);
|
|
394
|
+
this.maxFamiliesPerSub = positiveInt('maxFamiliesPerSub', options.maxFamiliesPerSub);
|
|
395
|
+
this.log = options.log ?? (() => undefined);
|
|
396
|
+
}
|
|
397
|
+
get timed() {
|
|
398
|
+
return this.maxAgeMs !== undefined || this.idleMs !== undefined;
|
|
399
|
+
}
|
|
400
|
+
// Positive form: a non-finite time can only make a session dead.
|
|
401
|
+
live(createdAt, usedAt, nowMs) {
|
|
402
|
+
return (this.maxAgeMs === undefined || nowMs < createdAt + this.maxAgeMs) && (this.idleMs === undefined || nowMs < usedAt + this.idleMs);
|
|
403
|
+
}
|
|
404
|
+
/** Drop expired sessions in both formats; the count of those dropped. */
|
|
405
|
+
prune(data, nowMs, lines) {
|
|
406
|
+
if (!this.timed)
|
|
407
|
+
return 0;
|
|
408
|
+
let n = 0;
|
|
409
|
+
for (const [k, f] of Object.entries(data.families)) {
|
|
410
|
+
if (!this.live(f.createdAt, f.usedAt, nowMs)) {
|
|
411
|
+
delete data.families[k];
|
|
412
|
+
n += 1;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
for (const [t, r] of Object.entries(data.active)) {
|
|
416
|
+
if (!this.live(r.issuedAt, r.issuedAt, nowMs)) {
|
|
417
|
+
delete data.active[t];
|
|
418
|
+
n += 1;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (n > 0) {
|
|
422
|
+
lines.push(`refresh families expired n=${n}`);
|
|
423
|
+
if (Object.keys(data.families).length === 0 && Object.keys(data.active).length === 0)
|
|
424
|
+
lines.push('refresh store: every session expired at once (check the host clock)');
|
|
425
|
+
}
|
|
426
|
+
return n;
|
|
427
|
+
}
|
|
428
|
+
/** A session written while the clock ran ahead would otherwise outlive its
|
|
429
|
+
* limit by the size of the jump. */
|
|
430
|
+
clamp(data, nowMs, lines) {
|
|
431
|
+
if (!this.timed)
|
|
432
|
+
return false;
|
|
433
|
+
const limit = nowMs + CLOCK_SKEW_MS;
|
|
434
|
+
let n = 0;
|
|
435
|
+
const pull = (t) => {
|
|
436
|
+
if (t <= limit)
|
|
437
|
+
return t;
|
|
438
|
+
n += 1;
|
|
439
|
+
return nowMs;
|
|
440
|
+
};
|
|
441
|
+
for (const f of Object.values(data.families)) {
|
|
442
|
+
f.createdAt = pull(f.createdAt);
|
|
443
|
+
f.usedAt = pull(f.usedAt);
|
|
444
|
+
}
|
|
445
|
+
for (const r of Object.values(data.active))
|
|
446
|
+
r.issuedAt = pull(r.issuedAt);
|
|
447
|
+
if (n > 0)
|
|
448
|
+
lines.push(`refresh store clock: n=${n} timestamps ahead of the clock were clamped`);
|
|
449
|
+
return n > 0;
|
|
450
|
+
}
|
|
451
|
+
key() {
|
|
452
|
+
return (this.macKey ??= refreshMacKey(this.masterKey));
|
|
121
453
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
454
|
+
/** With `nowMs` (every issue and rotation), also clamps and prunes. */
|
|
455
|
+
load(nowMs, lines = []) {
|
|
456
|
+
let d;
|
|
457
|
+
let readOk = true;
|
|
125
458
|
try {
|
|
126
|
-
|
|
127
|
-
return { active: d.active ?? {}, spent: d.spent ?? {} };
|
|
459
|
+
d = readPlain(this.path, this.masterKey);
|
|
128
460
|
}
|
|
129
461
|
catch {
|
|
130
|
-
|
|
462
|
+
d = null;
|
|
463
|
+
readOk = false;
|
|
464
|
+
}
|
|
465
|
+
checkFormat(this.path, d);
|
|
466
|
+
const loaded = decodeData(d);
|
|
467
|
+
if (nowMs !== undefined) {
|
|
468
|
+
if (this.clamp(loaded.data, nowMs, lines))
|
|
469
|
+
loaded.dirty = true;
|
|
470
|
+
if (this.prune(loaded.data, nowMs, lines) > 0)
|
|
471
|
+
loaded.dirty = true;
|
|
131
472
|
}
|
|
473
|
+
// A failed read is not evidence that the file holds no legacy record.
|
|
474
|
+
if (readOk && Object.keys(loaded.data.active).length === 0 && Object.keys(loaded.data.spent).length === 0)
|
|
475
|
+
this.legacyEmpty = true;
|
|
476
|
+
return loaded;
|
|
132
477
|
}
|
|
133
478
|
save(data) {
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
479
|
+
// Earlier-release evidence matters only while its family lives on, as a
|
|
480
|
+
// legacy record or as the family it migrated into.
|
|
481
|
+
const live = new Set();
|
|
482
|
+
for (const r of Object.values(data.active))
|
|
483
|
+
live.add(r.family);
|
|
484
|
+
for (const f of Object.values(data.families))
|
|
485
|
+
if (f.legacy !== undefined)
|
|
486
|
+
live.add(f.legacy);
|
|
487
|
+
for (const [t, fam] of Object.entries(data.spent))
|
|
488
|
+
if (!live.has(fam))
|
|
489
|
+
delete data.spent[t];
|
|
490
|
+
atomicWriteFileSync(this.path, encryptToken({ format: REFRESH_FORMAT, families: data.families, active: data.active, spent: data.spent }, this.masterKey), 0o600);
|
|
491
|
+
}
|
|
492
|
+
// After the write, so a failed save or a throwing accept logs nothing.
|
|
493
|
+
emit(lines) {
|
|
494
|
+
for (const line of lines) {
|
|
495
|
+
try {
|
|
496
|
+
this.log(line);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
// log sink failure ignored
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Evicts sub's own least-recently-used sessions down to `limit`; keepKey (the
|
|
504
|
+
// family just issued) is excluded from ranking. See docs/internals.md.
|
|
505
|
+
enforceFamilyCap(data, sub, limit, lines, keepKey) {
|
|
506
|
+
const entries = [];
|
|
507
|
+
for (const [k, f] of Object.entries(data.families)) {
|
|
508
|
+
if (f.sub !== sub || k === keepKey)
|
|
509
|
+
continue;
|
|
510
|
+
entries.push({
|
|
511
|
+
at: f.usedAt,
|
|
512
|
+
drop: () => {
|
|
513
|
+
delete data.families[k];
|
|
514
|
+
lines.push(`refresh family dropped: cap tag=${familyTag(Buffer.from(k, 'base64url'))}`);
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
for (const [t, r] of Object.entries(data.active)) {
|
|
519
|
+
if (r.sub !== sub)
|
|
520
|
+
continue;
|
|
521
|
+
entries.push({
|
|
522
|
+
at: r.issuedAt,
|
|
523
|
+
drop: () => {
|
|
524
|
+
delete data.active[t];
|
|
525
|
+
lines.push(`refresh family dropped: cap tag=${familyTag(r.family)}`);
|
|
526
|
+
},
|
|
527
|
+
});
|
|
139
528
|
}
|
|
140
|
-
|
|
529
|
+
// +1 accounts for the kept family, which stays live but is never ranked.
|
|
530
|
+
if (entries.length + 1 <= limit)
|
|
531
|
+
return;
|
|
532
|
+
entries.sort((a, b) => a.at - b.at);
|
|
533
|
+
for (const e of entries.slice(0, entries.length + 1 - limit))
|
|
534
|
+
e.drop();
|
|
141
535
|
}
|
|
142
|
-
|
|
536
|
+
newFamilyId(data) {
|
|
537
|
+
let fid;
|
|
538
|
+
do
|
|
539
|
+
fid = randomBytes(16);
|
|
540
|
+
while (Object.hasOwn(data.families, fid.toString('base64url')));
|
|
541
|
+
return fid;
|
|
542
|
+
}
|
|
543
|
+
issue(nowMs, sub) {
|
|
544
|
+
if (typeof sub !== 'string' || sub === '')
|
|
545
|
+
throw new Error('RefreshStore.issue: sub must be a non-empty string');
|
|
546
|
+
const key = this.key();
|
|
143
547
|
return withFileLock(this.path, () => {
|
|
144
|
-
const
|
|
145
|
-
const data = this.load();
|
|
146
|
-
|
|
548
|
+
const lines = [];
|
|
549
|
+
const { data } = this.load(nowMs, lines);
|
|
550
|
+
const fid = this.newFamilyId(data);
|
|
551
|
+
const fidKey = fid.toString('base64url');
|
|
552
|
+
const { token, cur } = mintR1(key, fid, 0);
|
|
553
|
+
data.families[fidKey] = { sub, gen: 0, cur, createdAt: nowMs, usedAt: nowMs };
|
|
554
|
+
if (this.maxFamiliesPerSub !== undefined)
|
|
555
|
+
this.enforceFamilyCap(data, sub, this.maxFamiliesPerSub, lines, fidKey);
|
|
147
556
|
this.save(data);
|
|
557
|
+
this.emit(lines);
|
|
148
558
|
return token;
|
|
149
559
|
});
|
|
150
560
|
}
|
|
151
|
-
/** Rotate a presented refresh token; null if
|
|
152
|
-
*
|
|
153
|
-
|
|
561
|
+
/** Rotate a presented refresh token; null if it is unknown, malformed, or
|
|
562
|
+
* rotated away (reuse => the family is revoked). The record's sub is copied
|
|
563
|
+
* forward and returned so the caller can mint the matching access token
|
|
564
|
+
* without trusting anything client-supplied.
|
|
565
|
+
*
|
|
566
|
+
* `accept` (optional) is called inside the store lock, before any mutation,
|
|
567
|
+
* with the record's sub. false: every session of that sub is dropped and
|
|
568
|
+
* null returned. A throw: nothing is written and the error propagates, so
|
|
569
|
+
* the presented token stays valid. Absent: unchanged. */
|
|
570
|
+
rotate(oldToken, nowMs, accept) {
|
|
571
|
+
const key = this.key();
|
|
572
|
+
const r1 = parseR1(oldToken, key);
|
|
573
|
+
if (!r1 && !(typeof oldToken === 'string' && LEGACY_TOKEN.test(oldToken) && !this.legacyEmpty))
|
|
574
|
+
return null;
|
|
154
575
|
return withFileLock(this.path, () => {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (fam) {
|
|
161
|
-
for (const [t, r] of Object.entries(data.active))
|
|
162
|
-
if (r.family === fam)
|
|
163
|
-
delete data.active[t];
|
|
576
|
+
const lines = [];
|
|
577
|
+
// Expiry runs first: a dead session is refused without consulting accept.
|
|
578
|
+
const { data, dirty } = this.load(nowMs, lines);
|
|
579
|
+
const done = (result, write) => {
|
|
580
|
+
if (write)
|
|
164
581
|
this.save(data);
|
|
165
|
-
|
|
166
|
-
return
|
|
582
|
+
this.emit(lines);
|
|
583
|
+
return result;
|
|
584
|
+
};
|
|
585
|
+
const refuse = (sub) => {
|
|
586
|
+
lines.push(`refresh sessions dropped: accept n=${dropSub(data, sub)}`);
|
|
587
|
+
return done(null, true);
|
|
588
|
+
};
|
|
589
|
+
if (r1) {
|
|
590
|
+
const f = Object.hasOwn(data.families, r1.fid) ? data.families[r1.fid] : undefined;
|
|
591
|
+
if (!f)
|
|
592
|
+
return done(null, dirty);
|
|
593
|
+
const drop = (why) => {
|
|
594
|
+
delete data.families[r1.fid];
|
|
595
|
+
lines.push(`refresh family ${why === 'reuse' ? 'revoked' : 'dropped'}: ${why} tag=${familyTag(r1.fidBytes)}`);
|
|
596
|
+
return done(null, true);
|
|
597
|
+
};
|
|
598
|
+
// Older: a rotated-away token, and the MAC proves this server minted it.
|
|
599
|
+
if (r1.gen < f.gen)
|
|
600
|
+
return drop('reuse');
|
|
601
|
+
// Newer, or the same generation with another random part: the store
|
|
602
|
+
// lost a write the client saw. Not evidence of theft.
|
|
603
|
+
if (r1.gen > f.gen)
|
|
604
|
+
return drop('ahead');
|
|
605
|
+
if (!timingSafeEqual(r1.digest, Buffer.from(f.cur, 'base64url')))
|
|
606
|
+
return drop('mismatch');
|
|
607
|
+
if (accept && !accept(f.sub))
|
|
608
|
+
return refuse(f.sub);
|
|
609
|
+
if (f.gen === GEN_MAX)
|
|
610
|
+
return drop('overflow');
|
|
611
|
+
const next = mintR1(key, r1.fidBytes, f.gen + 1);
|
|
612
|
+
f.gen += 1;
|
|
613
|
+
f.cur = next.cur;
|
|
614
|
+
// now() is read before the lock wait, so a later writer can hold an earlier time.
|
|
615
|
+
f.usedAt = Math.max(f.usedAt, nowMs);
|
|
616
|
+
return done({ token: next.token, sub: f.sub }, true);
|
|
617
|
+
}
|
|
618
|
+
const rec = Object.hasOwn(data.active, oldToken) ? data.active[oldToken] : undefined;
|
|
619
|
+
if (!rec) {
|
|
620
|
+
const fam = Object.hasOwn(data.spent, oldToken) ? data.spent[oldToken] : undefined;
|
|
621
|
+
if (fam === undefined)
|
|
622
|
+
return done(null, dirty);
|
|
623
|
+
// Reuse of an earlier-release rotated-away token: revoke the family in
|
|
624
|
+
// both formats.
|
|
625
|
+
for (const [t, r] of Object.entries(data.active))
|
|
626
|
+
if (r.family === fam)
|
|
627
|
+
delete data.active[t];
|
|
628
|
+
for (const [k, f] of Object.entries(data.families))
|
|
629
|
+
if (f.legacy === fam)
|
|
630
|
+
delete data.families[k];
|
|
631
|
+
lines.push(`refresh family revoked: reuse tag=${familyTag(fam)}`);
|
|
632
|
+
return done(null, true);
|
|
167
633
|
}
|
|
634
|
+
if (accept && !accept(rec.sub))
|
|
635
|
+
return refuse(rec.sub);
|
|
636
|
+
// Migrate: the presented token stays behind as reuse evidence.
|
|
168
637
|
delete data.active[oldToken];
|
|
169
638
|
data.spent[oldToken] = rec.family;
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return next;
|
|
639
|
+
const fid = this.newFamilyId(data);
|
|
640
|
+
const next = mintR1(key, fid, 0);
|
|
641
|
+
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 };
|
|
642
|
+
return done({ token: next.token, sub: rec.sub }, true);
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
/** Drop every session minted under `sub`, in both formats (linear scan under
|
|
646
|
+
* the store lock); the save then drops the earlier-release spent entries of
|
|
647
|
+
* the families that died with them. Returns the number of sessions dropped. */
|
|
648
|
+
purgeTenant(sub) {
|
|
649
|
+
return withFileLock(this.path, () => {
|
|
650
|
+
const { data } = this.load();
|
|
651
|
+
const dropped = dropSub(data, sub);
|
|
652
|
+
if (dropped > 0)
|
|
653
|
+
this.save(data);
|
|
654
|
+
return dropped;
|
|
174
655
|
});
|
|
175
656
|
}
|
|
176
657
|
}
|