mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.3
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/accounts.d.ts +17 -1
- package/dist/accounts.js +52 -15
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +20 -15
- package/dist/arg-normalize.js +6 -5
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +34 -4
- package/dist/auth.d.ts +4 -3
- package/dist/auth.js +11 -13
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +38 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +117 -0
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +11 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +13 -0
- package/dist/config-file.js +37 -1
- package/dist/discover.js +3 -1
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +25 -2
- package/dist/doctor.d.ts +6 -2
- package/dist/doctor.js +119 -23
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +4 -2
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +44 -10
- package/dist/fs-atomic.d.ts +11 -0
- package/dist/fs-atomic.js +219 -28
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +64 -0
- package/dist/http-transport.d.ts +62 -4
- package/dist/http-transport.js +343 -24
- package/dist/identity.d.ts +4 -1
- package/dist/identity.js +25 -6
- package/dist/index.js +66 -138
- package/dist/master-key.js +13 -5
- package/dist/mcp-token.d.ts +136 -19
- package/dist/mcp-token.js +569 -72
- package/dist/migrate-config.js +2 -2
- package/dist/oauth-as.d.ts +103 -3
- package/dist/oauth-as.js +374 -79
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +16 -0
- package/dist/outbound-allowlist.js +5 -2
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +12 -8
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +65 -5
- package/dist/registry.d.ts +18 -1
- package/dist/registry.js +71 -15
- package/dist/scope-catalog.d.ts +11 -0
- package/dist/scope-catalog.js +23 -6
- package/dist/scope-observability.d.ts +8 -6
- package/dist/scope-observability.js +29 -13
- package/dist/services.d.ts +15 -10
- package/dist/services.js +59 -15
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +101 -62
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +67 -0
- package/dist/token-store.d.ts +20 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_errors.d.ts +3 -0
- package/dist/tools/_errors.js +53 -24
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +18 -0
- package/dist/tools/account-wizard.d.ts +10 -1
- package/dist/tools/account-wizard.js +74 -17
- package/dist/tools/accounts-tool.d.ts +7 -0
- package/dist/tools/accounts-tool.js +34 -18
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +15 -11
- package/dist/tools/analytics.d.ts +10 -1
- package/dist/tools/analytics.js +13 -8
- package/dist/tools/calendar.d.ts +2 -1
- package/dist/tools/calendar.js +18 -14
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +13 -8
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +17 -13
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +34 -30
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +65 -50
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +19 -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.d.ts +2 -1
- package/dist/tools/gmail.js +45 -33
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +30 -16
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +13 -9
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +17 -13
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +36 -32
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +17 -10
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +19 -15
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +20 -0
- package/dist/usage-metrics.d.ts +23 -0
- package/dist/usage-metrics.js +65 -47
- package/dist/write-control.js +10 -2
- package/package.json +27 -1
package/dist/oauth-as.js
CHANGED
|
@@ -5,31 +5,80 @@
|
|
|
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
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { decodeJwt } from 'jose';
|
|
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';
|
|
12
|
+
import { fetchCimdDocument } from './ssrf-guard.js';
|
|
13
|
+
import { CimdClientCache } from './cimd-cache.js';
|
|
14
|
+
import { logSafe } from './trim.js';
|
|
12
15
|
const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
|
|
13
16
|
// DCR (/register) is public + unauthenticated, so its in-memory store must be
|
|
14
|
-
// bounded like every sibling store (ReplayGuard cap
|
|
15
|
-
//
|
|
17
|
+
// bounded like every sibling store (the ReplayGuard cap) or a request loop
|
|
18
|
+
// OOMs the process. Cap client count (FIFO-evict the oldest) and
|
|
16
19
|
// the per-request redirect_uris shape so one entry can't be arbitrarily large.
|
|
17
20
|
export const DCR_MAX_CLIENTS = 1000;
|
|
18
21
|
export const DCR_MAX_REDIRECT_URIS = 10;
|
|
19
22
|
const DCR_MAX_URI_LEN = 2048;
|
|
23
|
+
const OIDC_SUB = /^[\x21-\x7e]{1,255}$/;
|
|
24
|
+
const HOSTED_DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i;
|
|
25
|
+
// RFC 6749's error_description charset: %x20-21 / %x23-5B / %x5D-7E (printable
|
|
26
|
+
// ASCII minus the backslash and double-quote that would need escaping in the
|
|
27
|
+
// query string). A resolveSubject denial outside this set is dropped rather
|
|
28
|
+
// than forwarded unvalidated (H3 PR1).
|
|
29
|
+
const ERROR_DESCRIPTION_RE = /^[\x20-\x21\x23-\x5b\x5d-\x7e]{1,200}$/;
|
|
30
|
+
/** Only trust the id_token identity when Google marks the email verified (#8).
|
|
31
|
+
* The token comes straight from Google's token endpoint, so its claims are
|
|
32
|
+
* read unsigned. A malformed sub or hd is dropped, never passed on. */
|
|
33
|
+
export function verifiedIdentityFromIdToken(idToken) {
|
|
34
|
+
if (!idToken)
|
|
35
|
+
return undefined;
|
|
36
|
+
try {
|
|
37
|
+
const [, payload] = idToken.split('.');
|
|
38
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
|
|
39
|
+
const verified = claims.email_verified === true || claims.email_verified === 'true';
|
|
40
|
+
if (!verified)
|
|
41
|
+
return undefined;
|
|
42
|
+
const identity = { email: claims.email };
|
|
43
|
+
if (typeof claims.sub === 'string' && OIDC_SUB.test(claims.sub))
|
|
44
|
+
identity.sub = claims.sub;
|
|
45
|
+
if (typeof claims.hd === 'string' && claims.hd.length <= 253 && HOSTED_DOMAIN.test(claims.hd))
|
|
46
|
+
identity.hd = claims.hd;
|
|
47
|
+
return identity;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function verifiedEmailFromIdToken(idToken) {
|
|
54
|
+
return verifiedIdentityFromIdToken(idToken)?.email;
|
|
55
|
+
}
|
|
20
56
|
// --- helpers ----------------------------------------------------------------
|
|
21
57
|
function json(res, status, body, extraHeaders = {}) {
|
|
22
58
|
res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
|
|
23
59
|
res.end(JSON.stringify(body));
|
|
24
60
|
return true;
|
|
25
61
|
}
|
|
26
|
-
function errorPage(res, status, slug, message) {
|
|
27
|
-
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
|
62
|
+
function errorPage(res, status, slug, message, extraHeaders = {}) {
|
|
63
|
+
res.writeHead(status, { 'Content-Type': 'text/plain', ...extraHeaders });
|
|
28
64
|
res.end(`${slug}: ${message}`);
|
|
29
65
|
return true;
|
|
30
66
|
}
|
|
31
|
-
|
|
32
|
-
|
|
67
|
+
class SubjectCheckUnavailable extends Error {
|
|
68
|
+
}
|
|
69
|
+
function subjectUnavailable(res) {
|
|
70
|
+
return json(res, 503, { error: 'temporarily_unavailable', message: 'the sign-in could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
|
|
71
|
+
}
|
|
72
|
+
// Not invalid_client: clients and people read that as a permanent misconfiguration.
|
|
73
|
+
function clientUnavailable(res) {
|
|
74
|
+
return json(res, 503, { error: 'temporarily_unavailable', message: 'the client could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
|
|
75
|
+
}
|
|
76
|
+
// The replay guard is full: every slot holds a jti that is still live.
|
|
77
|
+
function signInBusy(res) {
|
|
78
|
+
return errorPage(res, 503, 'E_SIGNIN_BUSY', 'sign-in is busy right now; retry in a minute', { 'Retry-After': '60' });
|
|
79
|
+
}
|
|
80
|
+
function redirect(res, location, extraHeaders = {}) {
|
|
81
|
+
res.writeHead(302, { Location: location, ...extraHeaders });
|
|
33
82
|
res.end();
|
|
34
83
|
return true;
|
|
35
84
|
}
|
|
@@ -69,6 +118,38 @@ export function redirectAllowed(redirectUri, docRedirectUris) {
|
|
|
69
118
|
return isLoopbackHostname(du.hostname) && du.protocol === r.protocol && du.pathname === r.pathname;
|
|
70
119
|
});
|
|
71
120
|
}
|
|
121
|
+
// The owner_gate browser binding (docs/internals.md). One cookie per flow, so
|
|
122
|
+
// two sign-ins started in one browser do not overwrite each other. __Host-
|
|
123
|
+
// requires Secure, which an http base (the loopback owner) cannot rely on in
|
|
124
|
+
// every browser, so an http base gets a plain host-only name instead.
|
|
125
|
+
const BIND_HASH = /^[A-Za-z0-9_-]{43}$/;
|
|
126
|
+
function bindCookieName(base, bind) {
|
|
127
|
+
return `${base.startsWith('https:') ? '__Host-' : ''}mgm-og-${bind.slice(0, 16)}`;
|
|
128
|
+
}
|
|
129
|
+
function bindCookieAttrs(base, maxAge) {
|
|
130
|
+
return `Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${base.startsWith('https:') ? '; Secure' : ''}`;
|
|
131
|
+
}
|
|
132
|
+
function readCookie(req, name) {
|
|
133
|
+
for (const part of (req.headers.cookie ?? '').split(';')) {
|
|
134
|
+
const i = part.indexOf('=');
|
|
135
|
+
if (i > 0 && part.slice(0, i).trim() === name)
|
|
136
|
+
return part.slice(i + 1).trim();
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
function sha256Url(value) {
|
|
141
|
+
return createHash('sha256').update(value).digest('base64url');
|
|
142
|
+
}
|
|
143
|
+
function browserBinding(req, base, bind) {
|
|
144
|
+
if (typeof bind !== 'string' || !BIND_HASH.test(bind))
|
|
145
|
+
return 'missing';
|
|
146
|
+
const value = readCookie(req, bindCookieName(base, bind));
|
|
147
|
+
if (!value)
|
|
148
|
+
return 'missing';
|
|
149
|
+
const got = createHash('sha256').update(value).digest();
|
|
150
|
+
const want = Buffer.from(bind, 'base64url');
|
|
151
|
+
return got.length === want.length && timingSafeEqual(got, want) ? 'ok' : 'mismatch';
|
|
152
|
+
}
|
|
72
153
|
function pkceS256(verifier) {
|
|
73
154
|
return createHash('sha256').update(verifier).digest('base64url');
|
|
74
155
|
}
|
|
@@ -112,14 +193,44 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
112
193
|
const base = config.base;
|
|
113
194
|
const resource = config.resourceUri;
|
|
114
195
|
const secret = config.secret;
|
|
196
|
+
// States, approvals and codes live minutes, and their single use rests on an
|
|
197
|
+
// in-memory replay guard that a restart empties. So they are signed with a
|
|
198
|
+
// key only this AuthServer holds: a restart invalidates every one in flight,
|
|
199
|
+
// instead of letting a spent or unreserved one pass the new, empty guard.
|
|
200
|
+
const flowSecret = new Uint8Array(randomBytes(32));
|
|
115
201
|
const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
|
|
116
202
|
const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
|
|
117
203
|
const replay = deps.replayGuard ?? new ReplayGuard();
|
|
118
|
-
const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey);
|
|
119
204
|
const registered = deps.registeredClients ?? new Map();
|
|
120
205
|
const now = deps.now ?? (() => Date.now());
|
|
121
206
|
const log = deps.log ?? (() => undefined);
|
|
207
|
+
// Silently dropping a lifetime the caller asked for would leave sessions unlimited.
|
|
208
|
+
if (deps.refreshStore && (config.refreshMaxAgeSec !== undefined || config.refreshIdleSec !== undefined || config.refreshMaxFamiliesPerSub !== undefined)) {
|
|
209
|
+
throw new Error('E_REFRESH_OPTION_CONFLICT: refreshMaxAgeSec, refreshIdleSec and refreshMaxFamiliesPerSub configure the refresh store buildAuthServer creates; set them on the injected refreshStore instead');
|
|
210
|
+
}
|
|
211
|
+
const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey, { maxAgeSec: config.refreshMaxAgeSec, idleSec: config.refreshIdleSec, maxFamiliesPerSub: config.refreshMaxFamiliesPerSub, log });
|
|
122
212
|
const nowSec = () => Math.floor(now() / 1000);
|
|
213
|
+
// A full guard is refused at whatever rate the caller likes, so its line is
|
|
214
|
+
// written at most once a minute (or at once after the clock stepped back).
|
|
215
|
+
let fullLoggedAt = -Infinity;
|
|
216
|
+
let fullSuppressed = 0;
|
|
217
|
+
const noteFull = (at) => {
|
|
218
|
+
if (at >= fullLoggedAt && at - fullLoggedAt < 60_000) {
|
|
219
|
+
fullSuppressed++;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
log(`replay guard full: sign-in refused${fullSuppressed > 0 ? ` (n=${fullSuppressed} suppressed)` : ''}`);
|
|
223
|
+
fullLoggedAt = at;
|
|
224
|
+
fullSuppressed = 0;
|
|
225
|
+
};
|
|
226
|
+
const spend = (artifact, ttlSec) => {
|
|
227
|
+
const at = now();
|
|
228
|
+
const r = replay.spend(artifact.jti, ttlSec * 1000, at, artifact.exp * 1000);
|
|
229
|
+
if (r === 'full')
|
|
230
|
+
noteFull(at);
|
|
231
|
+
return r;
|
|
232
|
+
};
|
|
233
|
+
const resolveSubject = deps.resolveSubject ?? ((email) => (config.ownerEmails.includes(email) ? { sub: 'owner' } : null));
|
|
123
234
|
const issHeader = { 'Cache-Control': 'no-store' };
|
|
124
235
|
const prm = {
|
|
125
236
|
resource,
|
|
@@ -140,8 +251,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
140
251
|
authorization_response_iss_parameter_supported: true,
|
|
141
252
|
scopes_supported: ['mcp:use'],
|
|
142
253
|
};
|
|
143
|
-
const
|
|
144
|
-
const CIMD_CACHE_TTL_MS = 5 * 60_000;
|
|
254
|
+
const cimdClients = new CimdClientCache({ fetch: cimd, now, log });
|
|
145
255
|
async function validateClient(clientId) {
|
|
146
256
|
const reg = registered.get(clientId);
|
|
147
257
|
if (reg)
|
|
@@ -159,52 +269,59 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
159
269
|
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
160
270
|
}
|
|
161
271
|
if (scheme !== 'https:' || !config.cimdIssuers.includes(issuerHost)) {
|
|
162
|
-
|
|
272
|
+
// The issuer and a length, never the path: an attacker picks the path freely.
|
|
273
|
+
log(`CIMD rejected: issuer=${logSafe(`${scheme}//${issuerHost}`, 80)} not allowlisted client_id_len=${clientId.length}`);
|
|
163
274
|
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
164
275
|
}
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// oracle); log server-side, return a generic message.
|
|
177
|
-
log(`CIMD fetch failed for ${clientId}: ${e.message}`);
|
|
178
|
-
const slug = e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID';
|
|
179
|
-
return { error: slug, message: 'invalid client' };
|
|
180
|
-
}
|
|
181
|
-
cimdCache.set(clientId, { doc, exp: now() + CIMD_CACHE_TTL_MS }); // #15
|
|
182
|
-
}
|
|
183
|
-
if (doc.client_id !== clientId)
|
|
184
|
-
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
185
|
-
const uris = Array.isArray(doc.redirect_uris) ? doc.redirect_uris : [];
|
|
186
|
-
if (uris.length === 0)
|
|
187
|
-
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
188
|
-
// C6 #14: warn on a localhost-only redirect set (loopback impersonation).
|
|
189
|
-
if (uris.every((u) => { try {
|
|
276
|
+
// #15: cached only once it validates; the cache logs a failed fetch.
|
|
277
|
+
const got = await cimdClients.resolve(clientId, (doc) => doc.client_id === clientId && Array.isArray(doc.redirect_uris) && doc.redirect_uris.length > 0);
|
|
278
|
+
if ('busy' in got)
|
|
279
|
+
return got;
|
|
280
|
+
// #13: never echo internal fetch details to the caller (blind-SSRF oracle).
|
|
281
|
+
if ('error' in got)
|
|
282
|
+
return { error: got.error, message: 'invalid client' };
|
|
283
|
+
const uris = got.doc.redirect_uris;
|
|
284
|
+
// C6 #14: warn on a localhost-only redirect set (loopback impersonation),
|
|
285
|
+
// once per fetch rather than on every sign-in the cache serves.
|
|
286
|
+
if (got.fresh && uris.every((u) => { try {
|
|
190
287
|
return isLoopbackHostname(new URL(u).hostname);
|
|
191
288
|
}
|
|
192
289
|
catch {
|
|
193
290
|
return false;
|
|
194
291
|
} })) {
|
|
195
|
-
log(`client ${clientId} advertises only loopback redirect_uris`);
|
|
292
|
+
log(`client ${logSafe(clientId, 128)} advertises only loopback redirect_uris`);
|
|
196
293
|
}
|
|
197
294
|
return { redirect_uris: uris, dcr: false };
|
|
198
295
|
}
|
|
199
296
|
function escapeHtml(s) {
|
|
200
297
|
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
201
298
|
}
|
|
202
|
-
|
|
203
|
-
|
|
299
|
+
/** `incoming`: the artifact spent to get here. If no slot is left for the
|
|
300
|
+
* new state it is unspent, so retrying it works once a slot frees. */
|
|
301
|
+
async function toGoogle(res, sp, incoming) {
|
|
302
|
+
// H4 F1: without this, a Google URL minted here and forwarded to someone
|
|
303
|
+
// else signs THEM in and hands their subject to the starting client.
|
|
304
|
+
const headers = {};
|
|
305
|
+
if (sp.flow === 'owner_gate') {
|
|
306
|
+
const value = randomBytes(32).toString('base64url');
|
|
307
|
+
const bind = sha256Url(value);
|
|
308
|
+
sp = { ...sp, bind };
|
|
309
|
+
headers['Set-Cookie'] = `${bindCookieName(base, bind)}=${value}; ${bindCookieAttrs(base, STATE_TTL_DEFAULT)}`;
|
|
310
|
+
}
|
|
311
|
+
const state = await signState(sp, base, flowSecret, nowSec());
|
|
312
|
+
// Its /callback must find a slot even while a flood fills the guard, or a
|
|
313
|
+
// refusal there (the browser binding) could not be recorded.
|
|
314
|
+
const at = now();
|
|
315
|
+
if (replay.reserve(String(decodeJwt(state).jti), STATE_TTL_DEFAULT * 1000, at) === 'full') {
|
|
316
|
+
if (incoming)
|
|
317
|
+
replay.unspend(incoming);
|
|
318
|
+
noteFull(at);
|
|
319
|
+
return signInBusy(res);
|
|
320
|
+
}
|
|
204
321
|
const authUrl = deps.buildGoogleAuthUrl
|
|
205
|
-
? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state })
|
|
322
|
+
? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
|
|
206
323
|
: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
|
|
207
|
-
return redirect(res, authUrl);
|
|
324
|
+
return redirect(res, authUrl, headers);
|
|
208
325
|
}
|
|
209
326
|
// GET /authorize (validate + interstitial for DCR clients); POST /authorize
|
|
210
327
|
// (a DCR consent approval — verify the signed pending token, then proceed).
|
|
@@ -219,17 +336,25 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
219
336
|
}
|
|
220
337
|
let pending;
|
|
221
338
|
try {
|
|
222
|
-
pending = await verifyPending(form.pending ?? '', base,
|
|
339
|
+
pending = await verifyPending(form.pending ?? '', base, flowSecret, nowSec());
|
|
223
340
|
}
|
|
224
341
|
catch {
|
|
225
342
|
return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
|
|
226
343
|
}
|
|
227
|
-
|
|
228
|
-
|
|
344
|
+
// Only the client leg signs pendings, and it is always the owner sign-in.
|
|
345
|
+
// Defence in depth: a pending from an earlier release, which could carry
|
|
346
|
+
// flow=alias_reauth, was signed with MCP_JWT_KEY and no longer verifies.
|
|
347
|
+
if (pending.flow !== 'owner_gate') {
|
|
348
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
|
|
229
349
|
}
|
|
350
|
+
const spent = spend(pending, STATE_TTL_DEFAULT);
|
|
351
|
+
if (spent === 'full')
|
|
352
|
+
return signInBusy(res);
|
|
353
|
+
if (spent === 'replay')
|
|
354
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'this approval was already used');
|
|
230
355
|
const { jti: _drop, ...sp } = pending;
|
|
231
356
|
void _drop;
|
|
232
|
-
return toGoogle(res, sp);
|
|
357
|
+
return toGoogle(res, sp, pending.jti);
|
|
233
358
|
}
|
|
234
359
|
const q = url.searchParams;
|
|
235
360
|
const clientId = q.get('client_id') ?? '';
|
|
@@ -239,20 +364,53 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
239
364
|
const resourceParam = q.get('resource') ?? '';
|
|
240
365
|
const clientState = q.get('state') ?? undefined;
|
|
241
366
|
const aliasParam = q.get('alias') ?? undefined;
|
|
367
|
+
// Server-minted alias_add link: the whole request identity (tenant, alias,
|
|
368
|
+
// bundles) lives inside the SIGNED state — verify, spend it (single-use),
|
|
369
|
+
// then re-sign fresh for the Google leg. A tampered tenantId breaks the
|
|
370
|
+
// signature; a caller cannot supply one at all.
|
|
371
|
+
if (q.get('flow') === 'alias_add' && !clientId) {
|
|
372
|
+
let minted;
|
|
373
|
+
try {
|
|
374
|
+
minted = await verifyState(q.get('state') ?? '', base, flowSecret, nowSec());
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is invalid, expired, or tampered');
|
|
378
|
+
}
|
|
379
|
+
if (minted.flow !== 'alias_add' || !minted.alias || !minted.tenantId) {
|
|
380
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is malformed');
|
|
381
|
+
}
|
|
382
|
+
const spent = spend(minted, STATE_TTL_DEFAULT);
|
|
383
|
+
if (spent === 'full')
|
|
384
|
+
return signInBusy(res);
|
|
385
|
+
if (spent === 'replay')
|
|
386
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'this add-account link was already used');
|
|
387
|
+
const { jti: _spent, ...sp } = minted;
|
|
388
|
+
void _spent;
|
|
389
|
+
return toGoogle(res, sp, minted.jti);
|
|
390
|
+
}
|
|
242
391
|
// In-call re-auth (BV-1): a user-clicked `flow=alias_reauth` link has NO
|
|
243
392
|
// client leg — no code is delivered to any client, the refreshed token is
|
|
244
393
|
// written server-side for the alias — so it skips the client_id/redirect/
|
|
245
|
-
// PKCE requirements.
|
|
246
|
-
// identity to the alias's
|
|
247
|
-
if (q.get('flow') === 'alias_reauth' &&
|
|
248
|
-
if (
|
|
249
|
-
|
|
394
|
+
// PKCE requirements. The link must be one this server signed (reauthLink),
|
|
395
|
+
// and /callback still binds the completing identity to the alias's email.
|
|
396
|
+
if (q.get('flow') === 'alias_reauth' && !clientId) {
|
|
397
|
+
if (config.legacyAliasReauth === false) {
|
|
398
|
+
// Alias-only, tenant-ambiguous: disabled on multi-tenant deployments.
|
|
399
|
+
return errorPage(res, 403, 'E_LEGACY_REAUTH_DISABLED', 'alias re-auth links are disabled on this deployment; ask the server for a fresh account link');
|
|
250
400
|
}
|
|
251
|
-
|
|
401
|
+
const alias = verifyReauthLink(base, secret, { alias: aliasParam ?? '', exp: q.get('exp') ?? '', sig: q.get('sig') ?? '' }, nowSec());
|
|
402
|
+
// One answer for an unsigned, forged, expired or stale link, so a
|
|
403
|
+
// request the server did not issue learns nothing about its aliases.
|
|
404
|
+
if (!alias || !deps.aliasEmail?.(alias)) {
|
|
405
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'this re-auth link is invalid or expired; ask the server for a fresh one');
|
|
406
|
+
}
|
|
407
|
+
return toGoogle(res, { flow: 'alias_reauth', client_id: '', redirect_uri: '', code_challenge: '', resource, alias });
|
|
252
408
|
}
|
|
253
409
|
if (!clientId)
|
|
254
410
|
return json(res, 400, { error: 'invalid_request', message: 'client_id required', iss: base }, issHeader);
|
|
255
411
|
const client = await validateClient(clientId);
|
|
412
|
+
if ('busy' in client)
|
|
413
|
+
return clientUnavailable(res);
|
|
256
414
|
if ('error' in client)
|
|
257
415
|
return json(res, 400, { error: 'invalid_client', message: client.message, iss: base }, issHeader);
|
|
258
416
|
// (2) exact redirect_uri match BEFORE minting anything (open-redirect defense)
|
|
@@ -268,14 +426,16 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
268
426
|
return json(res, 400, { error: 'invalid_request', message: `resource must be ${resource}`, iss: base }, issHeader);
|
|
269
427
|
}
|
|
270
428
|
// (5) only after full validation do we build the request artifact.
|
|
429
|
+
// A client leg is always the owner sign-in. Re-auth has its own signed
|
|
430
|
+
// link: accepting flow=alias_reauth here let any client (a self-registered
|
|
431
|
+
// one, or a public CIMD client_id) start it and read the alias's scopes.
|
|
271
432
|
const statePayload = {
|
|
272
|
-
flow:
|
|
433
|
+
flow: 'owner_gate',
|
|
273
434
|
client_id: clientId,
|
|
274
435
|
redirect_uri: redirectUri,
|
|
275
436
|
code_challenge: codeChallenge,
|
|
276
437
|
client_state: clientState,
|
|
277
438
|
resource,
|
|
278
|
-
alias: q.get('alias') ?? undefined,
|
|
279
439
|
};
|
|
280
440
|
// Confused-deputy defense (#3): a self-registered DCR client can pick an
|
|
281
441
|
// arbitrary redirect_uri, so before we send the owner to Google (whose
|
|
@@ -284,8 +444,8 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
284
444
|
// it — their redirect_uris come from an issuer-allowlisted document a rogue
|
|
285
445
|
// cannot forge, so there is nothing to spoof.
|
|
286
446
|
if (client.dcr) {
|
|
287
|
-
const pendingToken = await signPending(statePayload, base,
|
|
288
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
447
|
+
const pendingToken = await signPending(statePayload, base, flowSecret, nowSec());
|
|
448
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'", 'X-Frame-Options': 'DENY' });
|
|
289
449
|
res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
|
|
290
450
|
`<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
|
|
291
451
|
`<h2>Authorize access?</h2>` +
|
|
@@ -304,13 +464,25 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
304
464
|
const stateToken = url.searchParams.get('state') ?? '';
|
|
305
465
|
let st;
|
|
306
466
|
try {
|
|
307
|
-
st = await verifyState(stateToken, base,
|
|
467
|
+
st = await verifyState(stateToken, base, flowSecret, nowSec());
|
|
308
468
|
}
|
|
309
469
|
catch {
|
|
310
470
|
return errorPage(res, 400, 'E_STATE_INVALID', 'state is invalid, expired, or tampered');
|
|
311
471
|
}
|
|
312
|
-
|
|
472
|
+
const spent = spend(st, STATE_TTL_DEFAULT);
|
|
473
|
+
if (spent === 'full')
|
|
474
|
+
return signInBusy(res);
|
|
475
|
+
if (spent === 'replay')
|
|
313
476
|
return errorPage(res, 400, 'E_STATE_INVALID', 'state has already been used (replay)');
|
|
477
|
+
// After the spend, so a refused callback URL cannot be replayed from the
|
|
478
|
+
// browser that holds the cookie; before the exchange and resolveSubject.
|
|
479
|
+
if (st.flow === 'owner_gate') {
|
|
480
|
+
const binding = browserBinding(req, base, st.bind);
|
|
481
|
+
if (binding !== 'ok') {
|
|
482
|
+
log(`callback refused flow=owner_gate: browser binding ${binding}`);
|
|
483
|
+
return errorPage(res, 400, 'E_BROWSER_MISMATCH', 'finish the sign-in in the browser that started it; start again from your app');
|
|
484
|
+
}
|
|
485
|
+
res.setHeader('Set-Cookie', `${bindCookieName(base, st.bind ?? '')}=; ${bindCookieAttrs(base, 0)}`);
|
|
314
486
|
}
|
|
315
487
|
if (!code)
|
|
316
488
|
return errorPage(res, 400, 'invalid_request', 'missing code');
|
|
@@ -319,7 +491,40 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
319
491
|
exchanged = deps.exchangeCode ? await deps.exchangeCode(code, st.flow) : { tokens: {} };
|
|
320
492
|
}
|
|
321
493
|
catch (e) {
|
|
322
|
-
|
|
494
|
+
// The detail can name proxies and hosts; it goes to the log, not the browser.
|
|
495
|
+
const detail = e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection';
|
|
496
|
+
log(`callback exchange failed flow=${st.flow}: ${logSafe(detail, 300)}`);
|
|
497
|
+
return errorPage(res, 400, 'invalid_grant', 'Google could not complete the sign-in; start again from your app');
|
|
498
|
+
}
|
|
499
|
+
if (st.flow === 'alias_add') {
|
|
500
|
+
if (!st.alias || !st.tenantId)
|
|
501
|
+
return errorPage(res, 400, 'invalid_request', 'alias_add without an alias/tenant binding');
|
|
502
|
+
if (!deps.bindTenantAlias)
|
|
503
|
+
return errorPage(res, 500, 'E_ALIAS_ADD_UNAVAILABLE', 'account linking is not enabled on this server');
|
|
504
|
+
const boundEmail = (exchanged.email ?? '').toLowerCase();
|
|
505
|
+
if (!boundEmail)
|
|
506
|
+
return errorPage(res, 403, 'access_denied', 'Google did not return an email for the signed-in account');
|
|
507
|
+
let refusal;
|
|
508
|
+
try {
|
|
509
|
+
const out = await deps.bindTenantAlias({ tenantId: st.tenantId, alias: st.alias, email: boundEmail, bundles: st.bundles, nonce: st.nonce, tokens: exchanged.tokens, sub: exchanged.sub });
|
|
510
|
+
refusal = out?.refused;
|
|
511
|
+
}
|
|
512
|
+
catch (e) {
|
|
513
|
+
// Any rejection reason, even none, must still reach this answer.
|
|
514
|
+
log(`alias_add bind failed for "${logSafe(st.alias, 64)}": ${logSafe(e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection', 200)}`);
|
|
515
|
+
return errorPage(res, 500, 'E_ALIAS_ADD_FAILED', 'the account could not be saved; try the link again or ask for a fresh one');
|
|
516
|
+
}
|
|
517
|
+
if (refusal) {
|
|
518
|
+
// The slug lands in a text/plain body; keep it a bare identifier.
|
|
519
|
+
const slug = typeof refusal.slug === 'string' && /^[A-Za-z0-9_]+$/.test(refusal.slug) ? refusal.slug : 'access_denied';
|
|
520
|
+
const message = typeof refusal.message === 'string' && refusal.message ? refusal.message : 'the account could not be linked';
|
|
521
|
+
log(`alias_add refused for "${logSafe(st.alias, 64)}": ${logSafe(slug, 64)}`);
|
|
522
|
+
return errorPage(res, 403, slug, message);
|
|
523
|
+
}
|
|
524
|
+
log(`callback ok flow=alias_add alias=${logSafe(st.alias, 64)}`);
|
|
525
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
526
|
+
res.end(`<!doctype html><meta charset=utf-8><p>Connected "${escapeHtml(st.alias)}". You can close this window and retry your request.</p>`);
|
|
527
|
+
return true;
|
|
323
528
|
}
|
|
324
529
|
if (st.flow === 'alias_reauth') {
|
|
325
530
|
if (!st.alias)
|
|
@@ -332,24 +537,54 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
332
537
|
const gotEmail = (exchanged.email ?? '').toLowerCase();
|
|
333
538
|
const expected = (deps.aliasEmail?.(st.alias) ?? '').toLowerCase();
|
|
334
539
|
if (!expected || !gotEmail || gotEmail !== expected) {
|
|
335
|
-
log(`alias_reauth identity mismatch for "${st.alias}" (got ${gotEmail || 'none'})`);
|
|
540
|
+
log(`alias_reauth identity mismatch for "${logSafe(st.alias, 64)}" (got ${logSafe(gotEmail || 'none', 254)})`);
|
|
336
541
|
return errorPage(res, 403, 'access_denied', 'the Google account you signed in with is not the one configured for this alias');
|
|
337
542
|
}
|
|
543
|
+
// A completion without a refresh token (a Google URL built without
|
|
544
|
+
// offline access, say) would replace a working credential with one that
|
|
545
|
+
// dies within the hour. Keep the stored token instead.
|
|
546
|
+
const refreshToken = exchanged.tokens.refresh_token;
|
|
547
|
+
if (typeof refreshToken !== 'string' || refreshToken === '') {
|
|
548
|
+
log(`alias_reauth for "${logSafe(st.alias, 64)}" returned no refresh token; stored token kept`);
|
|
549
|
+
return errorPage(res, 400, 'E_REAUTH_INCOMPLETE', 'Google did not return a long-lived token, so the stored one was kept. Open the re-auth link again and approve access.');
|
|
550
|
+
}
|
|
551
|
+
const granted = typeof exchanged.tokens.scope === 'string' ? exchanged.tokens.scope : undefined;
|
|
552
|
+
const missing = deps.missingScopes?.(st.alias, granted) ?? [];
|
|
553
|
+
const listed = missing.map((m) => `<code>${escapeHtml(m)}</code>`).join(', ');
|
|
554
|
+
// A narrower grant replaces nothing that works. The link can be reused
|
|
555
|
+
// for an hour and the Google URL's scope is not signed, so a narrower
|
|
556
|
+
// grant is not necessarily the account holder's choice.
|
|
557
|
+
if (missing.length > 0 && deps.hasToken?.(st.alias)) {
|
|
558
|
+
log(`alias_reauth for "${logSafe(st.alias, 64)}" granted ${missing.length} fewer scope(s); stored token kept`);
|
|
559
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
560
|
+
res.end(`<!doctype html><meta charset=utf-8><p>E_SCOPE_NOT_GRANTED: Google did not grant ${missing.length} requested scope(s): ${listed}, so the stored access for "${escapeHtml(st.alias)}" was kept. Ask for a fresh re-auth link and leave every box ticked.</p>`);
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
338
563
|
deps.writeToken?.(st.alias, exchanged.tokens);
|
|
339
|
-
log(`callback ok flow=alias_reauth alias=${st.alias}`);
|
|
564
|
+
log(`callback ok flow=alias_reauth alias=${logSafe(st.alias, 64)}`);
|
|
565
|
+
// With no stored token a partial grant still beats none; say what is missing.
|
|
566
|
+
const gap = missing.length
|
|
567
|
+
? `<p>Google did not grant ${missing.length} requested scope(s): ${listed}. Ask for a fresh re-auth link and leave every box ticked to restore them.</p>`
|
|
568
|
+
: '';
|
|
340
569
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
341
|
-
res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${st.alias}". You can close this window.</p
|
|
570
|
+
res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p>${gap}`);
|
|
342
571
|
return true;
|
|
343
572
|
}
|
|
344
|
-
// owner_gate:
|
|
573
|
+
// owner_gate: resolve the verified email to its subject (default = the
|
|
574
|
+
// owner allowlist -> 'owner'), discard Google tokens. The resolved sub is
|
|
575
|
+
// signed into the authz code and flows into every token minted from it.
|
|
345
576
|
const email = (exchanged.email ?? '').toLowerCase();
|
|
346
577
|
const iss = encodeURIComponent(base);
|
|
347
|
-
|
|
578
|
+
const resolved = email ? resolveSubject(email, { sub: exchanged.sub, hd: exchanged.hd }) : null;
|
|
579
|
+
if (!resolved || 'denied' in resolved) {
|
|
348
580
|
const sep = st.redirect_uri.includes('?') ? '&' : '?';
|
|
349
581
|
const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
|
|
350
|
-
|
|
582
|
+
const desc = resolved && 'denied' in resolved ? resolved.denied.description : undefined;
|
|
583
|
+
const d = desc !== undefined && ERROR_DESCRIPTION_RE.test(desc) ? `&error_description=${encodeURIComponent(desc)}` : '';
|
|
584
|
+
return redirect(res, `${st.redirect_uri}${sep}error=access_denied${d}${s}&iss=${iss}`);
|
|
351
585
|
}
|
|
352
|
-
const
|
|
586
|
+
const subject = resolved;
|
|
587
|
+
const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: subject.sub }, base, flowSecret, nowSec());
|
|
353
588
|
log('callback ok flow=owner_gate');
|
|
354
589
|
const sep = st.redirect_uri.includes('?') ? '&' : '?';
|
|
355
590
|
const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
|
|
@@ -368,14 +603,16 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
368
603
|
if (grant === 'authorization_code') {
|
|
369
604
|
let code;
|
|
370
605
|
try {
|
|
371
|
-
code = await verifyAuthzCode(form.code ?? '', base,
|
|
606
|
+
code = await verifyAuthzCode(form.code ?? '', base, flowSecret, nowSec());
|
|
372
607
|
}
|
|
373
608
|
catch {
|
|
374
609
|
return json(res, 400, { error: 'invalid_grant', message: 'authorization code invalid or expired' });
|
|
375
610
|
}
|
|
376
|
-
|
|
611
|
+
const spent = spend(code, CODE_TTL_DEFAULT);
|
|
612
|
+
if (spent === 'full')
|
|
613
|
+
return subjectUnavailable(res);
|
|
614
|
+
if (spent === 'replay')
|
|
377
615
|
return json(res, 400, { error: 'invalid_grant', message: 'authorization code already redeemed' });
|
|
378
|
-
}
|
|
379
616
|
if (form.redirect_uri !== code.redirect_uri) {
|
|
380
617
|
return json(res, 400, { error: 'invalid_grant', message: 'redirect_uri mismatch' });
|
|
381
618
|
}
|
|
@@ -385,18 +622,58 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
385
622
|
if (!form.code_verifier || pkceS256(form.code_verifier) !== code.code_challenge) {
|
|
386
623
|
return json(res, 400, { error: 'invalid_grant', message: 'PKCE verification failed' });
|
|
387
624
|
}
|
|
388
|
-
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
|
|
389
|
-
|
|
625
|
+
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl, sub: code.sub });
|
|
626
|
+
if (deps.subjectActive) {
|
|
627
|
+
let active;
|
|
628
|
+
try {
|
|
629
|
+
active = deps.subjectActive(code.sub);
|
|
630
|
+
}
|
|
631
|
+
catch (e) {
|
|
632
|
+
log(`token deferred grant=authorization_code: subject check failed: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
|
|
633
|
+
// Nothing was issued and PKCE passed, so the retry the 503 asks for may redeem it.
|
|
634
|
+
replay.unspend(code.jti);
|
|
635
|
+
return subjectUnavailable(res);
|
|
636
|
+
}
|
|
637
|
+
if (!active) {
|
|
638
|
+
log('token refused grant=authorization_code: subject inactive');
|
|
639
|
+
return json(res, 400, { error: 'invalid_grant', message: 'the subject is no longer provisioned' });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const refreshToken = refresh.issue(now(), code.sub);
|
|
390
643
|
log('token issued grant=authorization_code');
|
|
391
644
|
return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: refreshToken, scope: 'mcp:use' });
|
|
392
645
|
}
|
|
393
646
|
if (grant === 'refresh_token') {
|
|
394
|
-
const
|
|
647
|
+
const subjectActive = deps.subjectActive;
|
|
648
|
+
const accept = subjectActive
|
|
649
|
+
? (sub) => {
|
|
650
|
+
let active;
|
|
651
|
+
try {
|
|
652
|
+
active = subjectActive(sub);
|
|
653
|
+
}
|
|
654
|
+
catch (e) {
|
|
655
|
+
throw new SubjectCheckUnavailable('subject check failed', { cause: e });
|
|
656
|
+
}
|
|
657
|
+
if (!active)
|
|
658
|
+
log('token refused grant=refresh_token: subject inactive');
|
|
659
|
+
return active;
|
|
660
|
+
}
|
|
661
|
+
: undefined;
|
|
662
|
+
let next;
|
|
663
|
+
try {
|
|
664
|
+
next = refresh.rotate(form.refresh_token ?? '', now(), accept);
|
|
665
|
+
}
|
|
666
|
+
catch (e) {
|
|
667
|
+
if (!(e instanceof SubjectCheckUnavailable))
|
|
668
|
+
throw e;
|
|
669
|
+
log(`token deferred grant=refresh_token: subject check failed: ${e.cause instanceof Error ? logSafe(e.cause.message, 200) : 'non-Error throw'}`);
|
|
670
|
+
return subjectUnavailable(res);
|
|
671
|
+
}
|
|
395
672
|
if (!next)
|
|
396
673
|
return json(res, 400, { error: 'invalid_grant', message: 'unknown or rotated refresh token' });
|
|
397
|
-
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
|
|
674
|
+
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl, sub: next.sub });
|
|
398
675
|
log('token issued grant=refresh_token');
|
|
399
|
-
return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: next, scope: 'mcp:use' });
|
|
676
|
+
return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: next.token, scope: 'mcp:use' });
|
|
400
677
|
}
|
|
401
678
|
return json(res, 400, { error: 'unsupported_grant_type', message: 'authorization_code or refresh_token only' });
|
|
402
679
|
};
|
|
@@ -410,9 +687,18 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
410
687
|
catch {
|
|
411
688
|
return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
|
|
412
689
|
}
|
|
413
|
-
|
|
690
|
+
// Shape first: a throw here is an unauthenticated 500 per request, and a
|
|
691
|
+
// `null` body has no properties while String() throws on {"toString":1}.
|
|
692
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
693
|
+
return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
|
|
694
|
+
}
|
|
695
|
+
const raw = parsed.redirect_uris;
|
|
696
|
+
const redirectUris = Array.isArray(raw) ? raw : [];
|
|
414
697
|
if (redirectUris.length === 0)
|
|
415
698
|
return json(res, 400, { error: 'invalid_client_metadata', message: 'redirect_uris required' });
|
|
699
|
+
if (!redirectUris.every((u) => typeof u === 'string')) {
|
|
700
|
+
return json(res, 400, { error: 'invalid_client_metadata', message: 'every redirect_uri must be a string' });
|
|
701
|
+
}
|
|
416
702
|
if (redirectUris.length > DCR_MAX_REDIRECT_URIS || redirectUris.some((u) => u.length > DCR_MAX_URI_LEN)) {
|
|
417
703
|
return json(res, 400, { error: 'invalid_client_metadata', message: 'too many or oversized redirect_uris' });
|
|
418
704
|
}
|
|
@@ -446,12 +732,21 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
446
732
|
return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'missing bearer token' }) };
|
|
447
733
|
}
|
|
448
734
|
try {
|
|
449
|
-
await verifyAccessToken(header.slice(7), base, secret);
|
|
450
|
-
return { ok: true };
|
|
735
|
+
const claims = await verifyAccessToken(header.slice(7), base, secret);
|
|
736
|
+
return { ok: true, sub: claims.sub };
|
|
451
737
|
}
|
|
452
738
|
catch {
|
|
453
739
|
return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'invalid or expired token' }) };
|
|
454
740
|
}
|
|
455
741
|
};
|
|
456
|
-
|
|
742
|
+
const mintFlowState = async ({ flow, alias, tenantId, bundles, nonce }) => {
|
|
743
|
+
// /authorize has a signed branch for alias_add only; anything else minted
|
|
744
|
+
// here would be a state no route verifies the way its flow needs.
|
|
745
|
+
if (flow !== 'alias_add')
|
|
746
|
+
throw new Error(`mintFlowState supports alias_add only, not ${String(flow)}`);
|
|
747
|
+
const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, flowSecret, nowSec());
|
|
748
|
+
return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
|
|
749
|
+
};
|
|
750
|
+
const reauthLink = (alias) => `${base}/authorize?flow=alias_reauth&${signReauthLink(base, secret, alias, nowSec())}`;
|
|
751
|
+
return { routes, authenticate, mintFlowState, reauthLink };
|
|
457
752
|
}
|