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/oauth-as.js
CHANGED
|
@@ -1,35 +1,75 @@
|
|
|
1
|
-
//
|
|
2
|
-
// (
|
|
3
|
-
|
|
4
|
-
// Google login (leg B, tokens discarded), and separately holds per-alias Google
|
|
5
|
-
// tokens (leg C, unchanged). Mounts into B12's HttpTransportHost as `routes` +
|
|
6
|
-
// an `authenticate` for POST /mcp. All Google/CIMD I/O is injectable so the
|
|
7
|
-
// whole surface is exercised by an offline harness.
|
|
8
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
1
|
+
// The minimal OAuth 2.1 Authorization Server + Resource Server: federate-and-hold
|
|
2
|
+
// (own token / discarded owner login / held per-alias tokens). See docs/internals.md.
|
|
3
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
4
|
import { isIPv4 } from 'node:net';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
5
|
+
import { decodeJwt } from 'jose';
|
|
6
|
+
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';
|
|
7
|
+
import { fetchCimdDocument } from './ssrf-guard.js';
|
|
8
|
+
import { CimdClientCache } from './cimd-cache.js';
|
|
9
|
+
import { logSafe } from './trim.js';
|
|
12
10
|
const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// a request loop OOMs the process. Cap client count (FIFO-evict the oldest) and
|
|
16
|
-
// the per-request redirect_uris shape so one entry can't be arbitrarily large.
|
|
11
|
+
// /register is public and unauthenticated, so its store must stay bounded
|
|
12
|
+
// (FIFO-evict the oldest) like every sibling store, or a request loop OOMs.
|
|
17
13
|
export const DCR_MAX_CLIENTS = 1000;
|
|
18
14
|
export const DCR_MAX_REDIRECT_URIS = 10;
|
|
19
15
|
const DCR_MAX_URI_LEN = 2048;
|
|
16
|
+
const OIDC_SUB = /^[\x21-\x7e]{1,255}$/;
|
|
17
|
+
const HOSTED_DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i;
|
|
18
|
+
// RFC 6749 error_description charset (%x20-21/%x23-5B/%x5D-7E); a denial
|
|
19
|
+
// description outside it is dropped rather than forwarded unvalidated.
|
|
20
|
+
const ERROR_DESCRIPTION_RE = /^[\x20-\x21\x23-\x5b\x5d-\x7e]{1,200}$/;
|
|
21
|
+
/** Only trust the id_token identity when Google marks the email verified.
|
|
22
|
+
* The token comes straight from Google's token endpoint, so its claims are
|
|
23
|
+
* read unsigned. A malformed sub or hd is dropped, never passed on. */
|
|
24
|
+
export function verifiedIdentityFromIdToken(idToken) {
|
|
25
|
+
if (!idToken)
|
|
26
|
+
return undefined;
|
|
27
|
+
try {
|
|
28
|
+
const [, payload] = idToken.split('.');
|
|
29
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
|
|
30
|
+
const verified = claims.email_verified === true || claims.email_verified === 'true';
|
|
31
|
+
if (!verified)
|
|
32
|
+
return undefined;
|
|
33
|
+
const identity = { email: claims.email };
|
|
34
|
+
if (typeof claims.sub === 'string' && OIDC_SUB.test(claims.sub))
|
|
35
|
+
identity.sub = claims.sub;
|
|
36
|
+
if (typeof claims.hd === 'string' && claims.hd.length <= 253 && HOSTED_DOMAIN.test(claims.hd))
|
|
37
|
+
identity.hd = claims.hd;
|
|
38
|
+
return identity;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function verifiedEmailFromIdToken(idToken) {
|
|
45
|
+
return verifiedIdentityFromIdToken(idToken)?.email;
|
|
46
|
+
}
|
|
20
47
|
// --- helpers ----------------------------------------------------------------
|
|
21
48
|
function json(res, status, body, extraHeaders = {}) {
|
|
22
49
|
res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
|
|
23
50
|
res.end(JSON.stringify(body));
|
|
24
51
|
return true;
|
|
25
52
|
}
|
|
26
|
-
function errorPage(res, status, slug, message) {
|
|
27
|
-
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
|
53
|
+
function errorPage(res, status, slug, message, extraHeaders = {}) {
|
|
54
|
+
res.writeHead(status, { 'Content-Type': 'text/plain', ...extraHeaders });
|
|
28
55
|
res.end(`${slug}: ${message}`);
|
|
29
56
|
return true;
|
|
30
57
|
}
|
|
31
|
-
|
|
32
|
-
|
|
58
|
+
class SubjectCheckUnavailable extends Error {
|
|
59
|
+
}
|
|
60
|
+
function subjectUnavailable(res) {
|
|
61
|
+
return json(res, 503, { error: 'temporarily_unavailable', message: 'the sign-in could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
|
|
62
|
+
}
|
|
63
|
+
// Not invalid_client: clients and people read that as a permanent misconfiguration.
|
|
64
|
+
function clientUnavailable(res) {
|
|
65
|
+
return json(res, 503, { error: 'temporarily_unavailable', message: 'the client could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
|
|
66
|
+
}
|
|
67
|
+
// The replay guard is full: every slot holds a jti that is still live.
|
|
68
|
+
function signInBusy(res) {
|
|
69
|
+
return errorPage(res, 503, 'E_SIGNIN_BUSY', 'sign-in is busy right now; retry in a minute', { 'Retry-After': '60' });
|
|
70
|
+
}
|
|
71
|
+
function redirect(res, location, extraHeaders = {}) {
|
|
72
|
+
res.writeHead(302, { Location: location, ...extraHeaders });
|
|
33
73
|
res.end();
|
|
34
74
|
return true;
|
|
35
75
|
}
|
|
@@ -41,7 +81,7 @@ function isLoopbackHostname(h) {
|
|
|
41
81
|
// prefix (which would let "127.evil.com" masquerade as loopback).
|
|
42
82
|
return isIPv4(host) && host.split('.')[0] === '127';
|
|
43
83
|
}
|
|
44
|
-
/**
|
|
84
|
+
/** Exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
|
|
45
85
|
* the fixed claude.ai callback. */
|
|
46
86
|
export function redirectAllowed(redirectUri, docRedirectUris) {
|
|
47
87
|
if (redirectUri === CLAUDE_AI_FIXED_CALLBACK)
|
|
@@ -69,6 +109,35 @@ export function redirectAllowed(redirectUri, docRedirectUris) {
|
|
|
69
109
|
return isLoopbackHostname(du.hostname) && du.protocol === r.protocol && du.pathname === r.pathname;
|
|
70
110
|
});
|
|
71
111
|
}
|
|
112
|
+
// Owner-gate browser binding: one cookie per flow; see docs/internals.md.
|
|
113
|
+
const BIND_HASH = /^[A-Za-z0-9_-]{43}$/;
|
|
114
|
+
function bindCookieName(base, bind) {
|
|
115
|
+
return `${base.startsWith('https:') ? '__Host-' : ''}mgm-og-${bind.slice(0, 16)}`;
|
|
116
|
+
}
|
|
117
|
+
function bindCookieAttrs(base, maxAge) {
|
|
118
|
+
return `Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${base.startsWith('https:') ? '; Secure' : ''}`;
|
|
119
|
+
}
|
|
120
|
+
function readCookie(req, name) {
|
|
121
|
+
for (const part of (req.headers.cookie ?? '').split(';')) {
|
|
122
|
+
const i = part.indexOf('=');
|
|
123
|
+
if (i > 0 && part.slice(0, i).trim() === name)
|
|
124
|
+
return part.slice(i + 1).trim();
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
function sha256Url(value) {
|
|
129
|
+
return createHash('sha256').update(value).digest('base64url');
|
|
130
|
+
}
|
|
131
|
+
function browserBinding(req, base, bind) {
|
|
132
|
+
if (typeof bind !== 'string' || !BIND_HASH.test(bind))
|
|
133
|
+
return 'missing';
|
|
134
|
+
const value = readCookie(req, bindCookieName(base, bind));
|
|
135
|
+
if (!value)
|
|
136
|
+
return 'missing';
|
|
137
|
+
const got = createHash('sha256').update(value).digest();
|
|
138
|
+
const want = Buffer.from(bind, 'base64url');
|
|
139
|
+
return got.length === want.length && timingSafeEqual(got, want) ? 'ok' : 'mismatch';
|
|
140
|
+
}
|
|
72
141
|
function pkceS256(verifier) {
|
|
73
142
|
return createHash('sha256').update(verifier).digest('base64url');
|
|
74
143
|
}
|
|
@@ -112,14 +181,41 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
112
181
|
const base = config.base;
|
|
113
182
|
const resource = config.resourceUri;
|
|
114
183
|
const secret = config.secret;
|
|
184
|
+
// States/approvals/codes are signed with a per-process secret; see docs/internals.md.
|
|
185
|
+
const flowSecret = new Uint8Array(randomBytes(32));
|
|
115
186
|
const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
|
|
116
187
|
const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
|
|
117
188
|
const replay = deps.replayGuard ?? new ReplayGuard();
|
|
118
|
-
const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey);
|
|
119
189
|
const registered = deps.registeredClients ?? new Map();
|
|
120
190
|
const now = deps.now ?? (() => Date.now());
|
|
121
191
|
const log = deps.log ?? (() => undefined);
|
|
192
|
+
// Silently dropping a lifetime the caller asked for would leave sessions unlimited.
|
|
193
|
+
if (deps.refreshStore && (config.refreshMaxAgeSec !== undefined || config.refreshIdleSec !== undefined || config.refreshMaxFamiliesPerSub !== undefined)) {
|
|
194
|
+
throw new Error('E_REFRESH_OPTION_CONFLICT: refreshMaxAgeSec, refreshIdleSec and refreshMaxFamiliesPerSub configure the refresh store buildAuthServer creates; set them on the injected refreshStore instead');
|
|
195
|
+
}
|
|
196
|
+
const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey, { maxAgeSec: config.refreshMaxAgeSec, idleSec: config.refreshIdleSec, maxFamiliesPerSub: config.refreshMaxFamiliesPerSub, log });
|
|
122
197
|
const nowSec = () => Math.floor(now() / 1000);
|
|
198
|
+
// A full guard is refused at whatever rate the caller likes, so its line is
|
|
199
|
+
// written at most once a minute (or at once after the clock stepped back).
|
|
200
|
+
let fullLoggedAt = -Infinity;
|
|
201
|
+
let fullSuppressed = 0;
|
|
202
|
+
const noteFull = (at) => {
|
|
203
|
+
if (at >= fullLoggedAt && at - fullLoggedAt < 60_000) {
|
|
204
|
+
fullSuppressed++;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
log(`replay guard full: sign-in refused${fullSuppressed > 0 ? ` (n=${fullSuppressed} suppressed)` : ''}`);
|
|
208
|
+
fullLoggedAt = at;
|
|
209
|
+
fullSuppressed = 0;
|
|
210
|
+
};
|
|
211
|
+
const spend = (artifact, ttlSec) => {
|
|
212
|
+
const at = now();
|
|
213
|
+
const r = replay.spend(artifact.jti, ttlSec * 1000, at, artifact.exp * 1000);
|
|
214
|
+
if (r === 'full')
|
|
215
|
+
noteFull(at);
|
|
216
|
+
return r;
|
|
217
|
+
};
|
|
218
|
+
const resolveSubject = deps.resolveSubject ?? ((email) => (config.ownerEmails.includes(email) ? { sub: 'owner' } : null));
|
|
123
219
|
const issHeader = { 'Cache-Control': 'no-store' };
|
|
124
220
|
const prm = {
|
|
125
221
|
resource,
|
|
@@ -140,13 +236,12 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
140
236
|
authorization_response_iss_parameter_supported: true,
|
|
141
237
|
scopes_supported: ['mcp:use'],
|
|
142
238
|
};
|
|
143
|
-
const
|
|
144
|
-
const CIMD_CACHE_TTL_MS = 5 * 60_000;
|
|
239
|
+
const cimdClients = new CimdClientCache({ fetch: cimd, now, log });
|
|
145
240
|
async function validateClient(clientId) {
|
|
146
241
|
const reg = registered.get(clientId);
|
|
147
242
|
if (reg)
|
|
148
243
|
return { redirect_uris: reg.redirect_uris, dcr: true };
|
|
149
|
-
//
|
|
244
|
+
// Validate the issuer host + scheme BEFORE any outbound fetch, so we
|
|
150
245
|
// only ever fetch an allowlisted issuer (no arbitrary-URL SSRF surface).
|
|
151
246
|
let issuerHost;
|
|
152
247
|
let scheme;
|
|
@@ -159,55 +254,60 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
159
254
|
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
160
255
|
}
|
|
161
256
|
if (scheme !== 'https:' || !config.cimdIssuers.includes(issuerHost)) {
|
|
162
|
-
|
|
257
|
+
// The issuer and a length, never the path: an attacker picks the path freely.
|
|
258
|
+
log(`CIMD rejected: issuer=${logSafe(`${scheme}//${issuerHost}`, 80)} not allowlisted client_id_len=${clientId.length}`);
|
|
163
259
|
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
164
260
|
}
|
|
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 {
|
|
261
|
+
// Cached only once it validates; the cache logs a failed fetch.
|
|
262
|
+
const got = await cimdClients.resolve(clientId, (doc) => doc.client_id === clientId && Array.isArray(doc.redirect_uris) && doc.redirect_uris.length > 0);
|
|
263
|
+
if ('busy' in got)
|
|
264
|
+
return got;
|
|
265
|
+
// Never echo internal fetch details to the caller (blind-SSRF oracle).
|
|
266
|
+
if ('error' in got)
|
|
267
|
+
return { error: got.error, message: 'invalid client' };
|
|
268
|
+
const uris = got.doc.redirect_uris;
|
|
269
|
+
// Warn on a localhost-only redirect set (loopback impersonation),
|
|
270
|
+
// once per fetch rather than on every sign-in the cache serves.
|
|
271
|
+
if (got.fresh && uris.every((u) => { try {
|
|
190
272
|
return isLoopbackHostname(new URL(u).hostname);
|
|
191
273
|
}
|
|
192
274
|
catch {
|
|
193
275
|
return false;
|
|
194
276
|
} })) {
|
|
195
|
-
log(`client ${clientId} advertises only loopback redirect_uris`);
|
|
277
|
+
log(`client ${logSafe(clientId, 128)} advertises only loopback redirect_uris`);
|
|
196
278
|
}
|
|
197
279
|
return { redirect_uris: uris, dcr: false };
|
|
198
280
|
}
|
|
199
281
|
function escapeHtml(s) {
|
|
200
282
|
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
201
283
|
}
|
|
202
|
-
|
|
203
|
-
|
|
284
|
+
/** `incoming`: the artifact spent to get here. If no slot is left for the
|
|
285
|
+
* new state it is unspent, so retrying it works once a slot frees. */
|
|
286
|
+
async function toGoogle(res, sp, incoming) {
|
|
287
|
+
// Without this, a Google URL minted here and forwarded to someone
|
|
288
|
+
// else signs THEM in and hands their subject to the starting client.
|
|
289
|
+
const headers = {};
|
|
290
|
+
if (sp.flow === 'owner_gate') {
|
|
291
|
+
const value = randomBytes(32).toString('base64url');
|
|
292
|
+
const bind = sha256Url(value);
|
|
293
|
+
sp = { ...sp, bind };
|
|
294
|
+
headers['Set-Cookie'] = `${bindCookieName(base, bind)}=${value}; ${bindCookieAttrs(base, STATE_TTL_DEFAULT)}`;
|
|
295
|
+
}
|
|
296
|
+
const state = await signState(sp, base, flowSecret, nowSec());
|
|
297
|
+
// Its /callback must find a slot even while a flood fills the guard, or a
|
|
298
|
+
// refusal there (the browser binding) could not be recorded.
|
|
299
|
+
const at = now();
|
|
300
|
+
if (replay.reserve(String(decodeJwt(state).jti), STATE_TTL_DEFAULT * 1000, at) === 'full') {
|
|
301
|
+
if (incoming)
|
|
302
|
+
replay.unspend(incoming);
|
|
303
|
+
noteFull(at);
|
|
304
|
+
return signInBusy(res);
|
|
305
|
+
}
|
|
204
306
|
const authUrl = deps.buildGoogleAuthUrl
|
|
205
|
-
? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state })
|
|
307
|
+
? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
|
|
206
308
|
: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
|
|
207
|
-
return redirect(res, authUrl);
|
|
309
|
+
return redirect(res, authUrl, headers);
|
|
208
310
|
}
|
|
209
|
-
// GET /authorize (validate + interstitial for DCR clients); POST /authorize
|
|
210
|
-
// (a DCR consent approval — verify the signed pending token, then proceed).
|
|
211
311
|
const authorize = async (req, res, url) => {
|
|
212
312
|
if (req.method === 'POST') {
|
|
213
313
|
let form;
|
|
@@ -219,17 +319,24 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
219
319
|
}
|
|
220
320
|
let pending;
|
|
221
321
|
try {
|
|
222
|
-
pending = await verifyPending(form.pending ?? '', base,
|
|
322
|
+
pending = await verifyPending(form.pending ?? '', base, flowSecret, nowSec());
|
|
223
323
|
}
|
|
224
324
|
catch {
|
|
225
325
|
return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
|
|
226
326
|
}
|
|
227
|
-
|
|
228
|
-
|
|
327
|
+
// Defence in depth: a pending from an earlier release, which could carry
|
|
328
|
+
// flow=alias_reauth, was signed with MCP_JWT_KEY and no longer verifies.
|
|
329
|
+
if (pending.flow !== 'owner_gate') {
|
|
330
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
|
|
229
331
|
}
|
|
332
|
+
const spent = spend(pending, STATE_TTL_DEFAULT);
|
|
333
|
+
if (spent === 'full')
|
|
334
|
+
return signInBusy(res);
|
|
335
|
+
if (spent === 'replay')
|
|
336
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'this approval was already used');
|
|
230
337
|
const { jti: _drop, ...sp } = pending;
|
|
231
338
|
void _drop;
|
|
232
|
-
return toGoogle(res, sp);
|
|
339
|
+
return toGoogle(res, sp, pending.jti);
|
|
233
340
|
}
|
|
234
341
|
const q = url.searchParams;
|
|
235
342
|
const clientId = q.get('client_id') ?? '';
|
|
@@ -239,53 +346,74 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
239
346
|
const resourceParam = q.get('resource') ?? '';
|
|
240
347
|
const clientState = q.get('state') ?? undefined;
|
|
241
348
|
const aliasParam = q.get('alias') ?? undefined;
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
349
|
+
// alias_add: tenant/alias/bundles live inside the SIGNED state, verified
|
|
350
|
+
// and spent single-use before re-signing for Google; a caller can't tamper with them.
|
|
351
|
+
if (q.get('flow') === 'alias_add' && !clientId) {
|
|
352
|
+
let minted;
|
|
353
|
+
try {
|
|
354
|
+
minted = await verifyState(q.get('state') ?? '', base, flowSecret, nowSec());
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is invalid, expired, or tampered');
|
|
358
|
+
}
|
|
359
|
+
if (minted.flow !== 'alias_add' || !minted.alias || !minted.tenantId) {
|
|
360
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is malformed');
|
|
361
|
+
}
|
|
362
|
+
const spent = spend(minted, STATE_TTL_DEFAULT);
|
|
363
|
+
if (spent === 'full')
|
|
364
|
+
return signInBusy(res);
|
|
365
|
+
if (spent === 'replay')
|
|
366
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'this add-account link was already used');
|
|
367
|
+
const { jti: _spent, ...sp } = minted;
|
|
368
|
+
void _spent;
|
|
369
|
+
return toGoogle(res, sp, minted.jti);
|
|
370
|
+
}
|
|
371
|
+
// alias_reauth skips client_id/redirect/PKCE (no client leg); see docs/internals.md.
|
|
372
|
+
if (q.get('flow') === 'alias_reauth' && !clientId) {
|
|
373
|
+
if (config.legacyAliasReauth === false) {
|
|
374
|
+
// Alias-only, tenant-ambiguous: disabled on multi-tenant deployments.
|
|
375
|
+
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
376
|
}
|
|
251
|
-
|
|
377
|
+
const alias = verifyReauthLink(base, secret, { alias: aliasParam ?? '', exp: q.get('exp') ?? '', sig: q.get('sig') ?? '' }, nowSec());
|
|
378
|
+
// One answer for an unsigned, forged, expired or stale link, so a
|
|
379
|
+
// request the server did not issue learns nothing about its aliases.
|
|
380
|
+
if (!alias || !deps.aliasEmail?.(alias)) {
|
|
381
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'this re-auth link is invalid or expired; ask the server for a fresh one');
|
|
382
|
+
}
|
|
383
|
+
return toGoogle(res, { flow: 'alias_reauth', client_id: '', redirect_uri: '', code_challenge: '', resource, alias });
|
|
252
384
|
}
|
|
253
385
|
if (!clientId)
|
|
254
386
|
return json(res, 400, { error: 'invalid_request', message: 'client_id required', iss: base }, issHeader);
|
|
255
387
|
const client = await validateClient(clientId);
|
|
388
|
+
if ('busy' in client)
|
|
389
|
+
return clientUnavailable(res);
|
|
256
390
|
if ('error' in client)
|
|
257
391
|
return json(res, 400, { error: 'invalid_client', message: client.message, iss: base }, issHeader);
|
|
258
|
-
//
|
|
392
|
+
// Exact redirect_uri match BEFORE minting anything (open-redirect defense).
|
|
259
393
|
if (!redirectUri || !redirectAllowed(redirectUri, client.redirect_uris)) {
|
|
260
394
|
return errorPage(res, 400, 'E_REDIRECT_URI_MISMATCH', 'redirect_uri is not an allowed callback for this client');
|
|
261
395
|
}
|
|
262
|
-
// (3) PKCE S256 required
|
|
263
396
|
if (!codeChallenge || method !== 'S256') {
|
|
264
397
|
return json(res, 400, { error: 'invalid_request', message: 'code_challenge with S256 required', iss: base }, issHeader);
|
|
265
398
|
}
|
|
266
|
-
// (4) resource must be the canonical resource URI
|
|
267
399
|
if (resourceParam !== resource) {
|
|
268
400
|
return json(res, 400, { error: 'invalid_request', message: `resource must be ${resource}`, iss: base }, issHeader);
|
|
269
401
|
}
|
|
270
|
-
//
|
|
402
|
+
// Only after full validation, and always as owner_gate: a caller-supplied
|
|
403
|
+
// flow here would let any client read an alias's scopes via alias_reauth.
|
|
271
404
|
const statePayload = {
|
|
272
|
-
flow:
|
|
405
|
+
flow: 'owner_gate',
|
|
273
406
|
client_id: clientId,
|
|
274
407
|
redirect_uri: redirectUri,
|
|
275
408
|
code_challenge: codeChallenge,
|
|
276
409
|
client_state: clientState,
|
|
277
410
|
resource,
|
|
278
|
-
alias: q.get('alias') ?? undefined,
|
|
279
411
|
};
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
// consent screen names only THIS server, not the requesting app), show an
|
|
283
|
-
// approval interstitial naming the client + destination. CIMD clients skip
|
|
284
|
-
// it — their redirect_uris come from an issuer-allowlisted document a rogue
|
|
285
|
-
// cannot forge, so there is nothing to spoof.
|
|
412
|
+
// DCR clients get a consent interstitial naming the client + destination
|
|
413
|
+
// (confused-deputy defense); CIMD clients skip it. See docs/internals.md.
|
|
286
414
|
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' });
|
|
415
|
+
const pendingToken = await signPending(statePayload, base, flowSecret, nowSec());
|
|
416
|
+
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
417
|
res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
|
|
290
418
|
`<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
|
|
291
419
|
`<h2>Authorize access?</h2>` +
|
|
@@ -298,19 +426,30 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
298
426
|
}
|
|
299
427
|
return toGoogle(res, statePayload);
|
|
300
428
|
};
|
|
301
|
-
// GET /callback
|
|
302
429
|
const callback = async (req, res, url) => {
|
|
303
430
|
const code = url.searchParams.get('code') ?? '';
|
|
304
431
|
const stateToken = url.searchParams.get('state') ?? '';
|
|
305
432
|
let st;
|
|
306
433
|
try {
|
|
307
|
-
st = await verifyState(stateToken, base,
|
|
434
|
+
st = await verifyState(stateToken, base, flowSecret, nowSec());
|
|
308
435
|
}
|
|
309
436
|
catch {
|
|
310
437
|
return errorPage(res, 400, 'E_STATE_INVALID', 'state is invalid, expired, or tampered');
|
|
311
438
|
}
|
|
312
|
-
|
|
439
|
+
const spent = spend(st, STATE_TTL_DEFAULT);
|
|
440
|
+
if (spent === 'full')
|
|
441
|
+
return signInBusy(res);
|
|
442
|
+
if (spent === 'replay')
|
|
313
443
|
return errorPage(res, 400, 'E_STATE_INVALID', 'state has already been used (replay)');
|
|
444
|
+
// After the spend, so a refused callback URL cannot be replayed from the
|
|
445
|
+
// browser that holds the cookie; before the exchange and resolveSubject.
|
|
446
|
+
if (st.flow === 'owner_gate') {
|
|
447
|
+
const binding = browserBinding(req, base, st.bind);
|
|
448
|
+
if (binding !== 'ok') {
|
|
449
|
+
log(`callback refused flow=owner_gate: browser binding ${binding}`);
|
|
450
|
+
return errorPage(res, 400, 'E_BROWSER_MISMATCH', 'finish the sign-in in the browser that started it; start again from your app');
|
|
451
|
+
}
|
|
452
|
+
res.setHeader('Set-Cookie', `${bindCookieName(base, st.bind ?? '')}=; ${bindCookieAttrs(base, 0)}`);
|
|
314
453
|
}
|
|
315
454
|
if (!code)
|
|
316
455
|
return errorPage(res, 400, 'invalid_request', 'missing code');
|
|
@@ -319,43 +458,99 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
319
458
|
exchanged = deps.exchangeCode ? await deps.exchangeCode(code, st.flow) : { tokens: {} };
|
|
320
459
|
}
|
|
321
460
|
catch (e) {
|
|
322
|
-
|
|
461
|
+
// The detail can name proxies and hosts; it goes to the log, not the browser.
|
|
462
|
+
const detail = e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection';
|
|
463
|
+
log(`callback exchange failed flow=${st.flow}: ${logSafe(detail, 300)}`);
|
|
464
|
+
return errorPage(res, 400, 'invalid_grant', 'Google could not complete the sign-in; start again from your app');
|
|
465
|
+
}
|
|
466
|
+
if (st.flow === 'alias_add') {
|
|
467
|
+
if (!st.alias || !st.tenantId)
|
|
468
|
+
return errorPage(res, 400, 'invalid_request', 'alias_add without an alias/tenant binding');
|
|
469
|
+
if (!deps.bindTenantAlias)
|
|
470
|
+
return errorPage(res, 500, 'E_ALIAS_ADD_UNAVAILABLE', 'account linking is not enabled on this server');
|
|
471
|
+
const boundEmail = (exchanged.email ?? '').toLowerCase();
|
|
472
|
+
if (!boundEmail)
|
|
473
|
+
return errorPage(res, 403, 'access_denied', 'Google did not return an email for the signed-in account');
|
|
474
|
+
let refusal;
|
|
475
|
+
try {
|
|
476
|
+
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 });
|
|
477
|
+
refusal = out?.refused;
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
// Any rejection reason, even none, must still reach this answer.
|
|
481
|
+
log(`alias_add bind failed for "${logSafe(st.alias, 64)}": ${logSafe(e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection', 200)}`);
|
|
482
|
+
return errorPage(res, 500, 'E_ALIAS_ADD_FAILED', 'the account could not be saved; try the link again or ask for a fresh one');
|
|
483
|
+
}
|
|
484
|
+
if (refusal) {
|
|
485
|
+
// The slug lands in a text/plain body; keep it a bare identifier.
|
|
486
|
+
const slug = typeof refusal.slug === 'string' && /^[A-Za-z0-9_]+$/.test(refusal.slug) ? refusal.slug : 'access_denied';
|
|
487
|
+
const message = typeof refusal.message === 'string' && refusal.message ? refusal.message : 'the account could not be linked';
|
|
488
|
+
log(`alias_add refused for "${logSafe(st.alias, 64)}": ${logSafe(slug, 64)}`);
|
|
489
|
+
return errorPage(res, 403, slug, message);
|
|
490
|
+
}
|
|
491
|
+
log(`callback ok flow=alias_add alias=${logSafe(st.alias, 64)}`);
|
|
492
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
493
|
+
res.end(`<!doctype html><meta charset=utf-8><p>Connected "${escapeHtml(st.alias)}". You can close this window and retry your request.</p>`);
|
|
494
|
+
return true;
|
|
323
495
|
}
|
|
324
496
|
if (st.flow === 'alias_reauth') {
|
|
325
497
|
if (!st.alias)
|
|
326
498
|
return errorPage(res, 400, 'invalid_request', 'alias_reauth without an alias');
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// attacker who crafts an alias_reauth link and logs in with their OWN
|
|
330
|
-
// account could inject their tokens (account swap). The alias emails (leg
|
|
331
|
-
// C) are distinct from MCP_OWNER_EMAILS (leg B), so bind to the alias.
|
|
499
|
+
// Identity binding: only overwrite the alias's tokens if the completing
|
|
500
|
+
// Google identity matches the alias's configured account (else account swap).
|
|
332
501
|
const gotEmail = (exchanged.email ?? '').toLowerCase();
|
|
333
502
|
const expected = (deps.aliasEmail?.(st.alias) ?? '').toLowerCase();
|
|
334
503
|
if (!expected || !gotEmail || gotEmail !== expected) {
|
|
335
|
-
log(`alias_reauth identity mismatch for "${st.alias}" (got ${gotEmail || 'none'})`);
|
|
504
|
+
log(`alias_reauth identity mismatch for "${logSafe(st.alias, 64)}" (got ${logSafe(gotEmail || 'none', 254)})`);
|
|
336
505
|
return errorPage(res, 403, 'access_denied', 'the Google account you signed in with is not the one configured for this alias');
|
|
337
506
|
}
|
|
507
|
+
// A completion without a refresh token would replace a working credential
|
|
508
|
+
// with one that dies within the hour; keep the stored token instead.
|
|
509
|
+
const refreshToken = exchanged.tokens.refresh_token;
|
|
510
|
+
if (typeof refreshToken !== 'string' || refreshToken === '') {
|
|
511
|
+
log(`alias_reauth for "${logSafe(st.alias, 64)}" returned no refresh token; stored token kept`);
|
|
512
|
+
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.');
|
|
513
|
+
}
|
|
514
|
+
const granted = typeof exchanged.tokens.scope === 'string' ? exchanged.tokens.scope : undefined;
|
|
515
|
+
const missing = deps.missingScopes?.(st.alias, granted) ?? [];
|
|
516
|
+
const listed = missing.map((m) => `<code>${escapeHtml(m)}</code>`).join(', ');
|
|
517
|
+
// A narrower grant replaces nothing that works: the link is reusable for
|
|
518
|
+
// an hour and the scope isn't signed, so it may not be the holder's choice.
|
|
519
|
+
if (missing.length > 0 && deps.hasToken?.(st.alias)) {
|
|
520
|
+
log(`alias_reauth for "${logSafe(st.alias, 64)}" granted ${missing.length} fewer scope(s); stored token kept`);
|
|
521
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
522
|
+
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>`);
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
338
525
|
deps.writeToken?.(st.alias, exchanged.tokens);
|
|
339
|
-
log(`callback ok flow=alias_reauth alias=${st.alias}`);
|
|
526
|
+
log(`callback ok flow=alias_reauth alias=${logSafe(st.alias, 64)}`);
|
|
527
|
+
// With no stored token a partial grant still beats none; say what is missing.
|
|
528
|
+
const gap = missing.length
|
|
529
|
+
? `<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>`
|
|
530
|
+
: '';
|
|
340
531
|
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
|
|
532
|
+
res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p>${gap}`);
|
|
342
533
|
return true;
|
|
343
534
|
}
|
|
344
|
-
// owner_gate:
|
|
535
|
+
// owner_gate: resolve the verified email to its subject (default: owner
|
|
536
|
+
// allowlist -> 'owner'), discard Google tokens; the sub signs into the authz code.
|
|
345
537
|
const email = (exchanged.email ?? '').toLowerCase();
|
|
346
538
|
const iss = encodeURIComponent(base);
|
|
347
|
-
|
|
539
|
+
const resolved = email ? resolveSubject(email, { sub: exchanged.sub, hd: exchanged.hd }) : null;
|
|
540
|
+
if (!resolved || 'denied' in resolved) {
|
|
348
541
|
const sep = st.redirect_uri.includes('?') ? '&' : '?';
|
|
349
542
|
const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
|
|
350
|
-
|
|
543
|
+
const desc = resolved && 'denied' in resolved ? resolved.denied.description : undefined;
|
|
544
|
+
const d = desc !== undefined && ERROR_DESCRIPTION_RE.test(desc) ? `&error_description=${encodeURIComponent(desc)}` : '';
|
|
545
|
+
return redirect(res, `${st.redirect_uri}${sep}error=access_denied${d}${s}&iss=${iss}`);
|
|
351
546
|
}
|
|
352
|
-
const
|
|
547
|
+
const subject = resolved;
|
|
548
|
+
const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: subject.sub }, base, flowSecret, nowSec());
|
|
353
549
|
log('callback ok flow=owner_gate');
|
|
354
550
|
const sep = st.redirect_uri.includes('?') ? '&' : '?';
|
|
355
551
|
const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
|
|
356
552
|
return redirect(res, `${st.redirect_uri}${sep}code=${encodeURIComponent(authzCode)}${s}&iss=${iss}`);
|
|
357
553
|
};
|
|
358
|
-
// POST /token
|
|
359
554
|
const token = async (req, res) => {
|
|
360
555
|
let form;
|
|
361
556
|
try {
|
|
@@ -368,14 +563,16 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
368
563
|
if (grant === 'authorization_code') {
|
|
369
564
|
let code;
|
|
370
565
|
try {
|
|
371
|
-
code = await verifyAuthzCode(form.code ?? '', base,
|
|
566
|
+
code = await verifyAuthzCode(form.code ?? '', base, flowSecret, nowSec());
|
|
372
567
|
}
|
|
373
568
|
catch {
|
|
374
569
|
return json(res, 400, { error: 'invalid_grant', message: 'authorization code invalid or expired' });
|
|
375
570
|
}
|
|
376
|
-
|
|
571
|
+
const spent = spend(code, CODE_TTL_DEFAULT);
|
|
572
|
+
if (spent === 'full')
|
|
573
|
+
return subjectUnavailable(res);
|
|
574
|
+
if (spent === 'replay')
|
|
377
575
|
return json(res, 400, { error: 'invalid_grant', message: 'authorization code already redeemed' });
|
|
378
|
-
}
|
|
379
576
|
if (form.redirect_uri !== code.redirect_uri) {
|
|
380
577
|
return json(res, 400, { error: 'invalid_grant', message: 'redirect_uri mismatch' });
|
|
381
578
|
}
|
|
@@ -385,23 +582,62 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
385
582
|
if (!form.code_verifier || pkceS256(form.code_verifier) !== code.code_challenge) {
|
|
386
583
|
return json(res, 400, { error: 'invalid_grant', message: 'PKCE verification failed' });
|
|
387
584
|
}
|
|
388
|
-
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
|
|
389
|
-
|
|
585
|
+
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl, sub: code.sub });
|
|
586
|
+
if (deps.subjectActive) {
|
|
587
|
+
let active;
|
|
588
|
+
try {
|
|
589
|
+
active = deps.subjectActive(code.sub);
|
|
590
|
+
}
|
|
591
|
+
catch (e) {
|
|
592
|
+
log(`token deferred grant=authorization_code: subject check failed: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
|
|
593
|
+
// Nothing was issued and PKCE passed, so the retry the 503 asks for may redeem it.
|
|
594
|
+
replay.unspend(code.jti);
|
|
595
|
+
return subjectUnavailable(res);
|
|
596
|
+
}
|
|
597
|
+
if (!active) {
|
|
598
|
+
log('token refused grant=authorization_code: subject inactive');
|
|
599
|
+
return json(res, 400, { error: 'invalid_grant', message: 'the subject is no longer provisioned' });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const refreshToken = refresh.issue(now(), code.sub);
|
|
390
603
|
log('token issued grant=authorization_code');
|
|
391
604
|
return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: refreshToken, scope: 'mcp:use' });
|
|
392
605
|
}
|
|
393
606
|
if (grant === 'refresh_token') {
|
|
394
|
-
const
|
|
607
|
+
const subjectActive = deps.subjectActive;
|
|
608
|
+
const accept = subjectActive
|
|
609
|
+
? (sub) => {
|
|
610
|
+
let active;
|
|
611
|
+
try {
|
|
612
|
+
active = subjectActive(sub);
|
|
613
|
+
}
|
|
614
|
+
catch (e) {
|
|
615
|
+
throw new SubjectCheckUnavailable('subject check failed', { cause: e });
|
|
616
|
+
}
|
|
617
|
+
if (!active)
|
|
618
|
+
log('token refused grant=refresh_token: subject inactive');
|
|
619
|
+
return active;
|
|
620
|
+
}
|
|
621
|
+
: undefined;
|
|
622
|
+
let next;
|
|
623
|
+
try {
|
|
624
|
+
next = refresh.rotate(form.refresh_token ?? '', now(), accept);
|
|
625
|
+
}
|
|
626
|
+
catch (e) {
|
|
627
|
+
if (!(e instanceof SubjectCheckUnavailable))
|
|
628
|
+
throw e;
|
|
629
|
+
log(`token deferred grant=refresh_token: subject check failed: ${e.cause instanceof Error ? logSafe(e.cause.message, 200) : 'non-Error throw'}`);
|
|
630
|
+
return subjectUnavailable(res);
|
|
631
|
+
}
|
|
395
632
|
if (!next)
|
|
396
633
|
return json(res, 400, { error: 'invalid_grant', message: 'unknown or rotated refresh token' });
|
|
397
|
-
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
|
|
634
|
+
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl, sub: next.sub });
|
|
398
635
|
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' });
|
|
636
|
+
return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: next.token, scope: 'mcp:use' });
|
|
400
637
|
}
|
|
401
638
|
return json(res, 400, { error: 'unsupported_grant_type', message: 'authorization_code or refresh_token only' });
|
|
402
639
|
};
|
|
403
|
-
//
|
|
404
|
-
// redirect_uris array must survive intact).
|
|
640
|
+
// Parse the raw JSON body directly; the redirect_uris array must survive intact.
|
|
405
641
|
const register = async (req, res) => {
|
|
406
642
|
let parsed;
|
|
407
643
|
try {
|
|
@@ -410,9 +646,18 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
410
646
|
catch {
|
|
411
647
|
return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
|
|
412
648
|
}
|
|
413
|
-
|
|
649
|
+
// Shape first: a throw here is an unauthenticated 500 per request, and a
|
|
650
|
+
// `null` body has no properties while String() throws on {"toString":1}.
|
|
651
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
652
|
+
return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
|
|
653
|
+
}
|
|
654
|
+
const raw = parsed.redirect_uris;
|
|
655
|
+
const redirectUris = Array.isArray(raw) ? raw : [];
|
|
414
656
|
if (redirectUris.length === 0)
|
|
415
657
|
return json(res, 400, { error: 'invalid_client_metadata', message: 'redirect_uris required' });
|
|
658
|
+
if (!redirectUris.every((u) => typeof u === 'string')) {
|
|
659
|
+
return json(res, 400, { error: 'invalid_client_metadata', message: 'every redirect_uri must be a string' });
|
|
660
|
+
}
|
|
416
661
|
if (redirectUris.length > DCR_MAX_REDIRECT_URIS || redirectUris.some((u) => u.length > DCR_MAX_URI_LEN)) {
|
|
417
662
|
return json(res, 400, { error: 'invalid_client_metadata', message: 'too many or oversized redirect_uris' });
|
|
418
663
|
}
|
|
@@ -446,12 +691,21 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
446
691
|
return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'missing bearer token' }) };
|
|
447
692
|
}
|
|
448
693
|
try {
|
|
449
|
-
await verifyAccessToken(header.slice(7), base, secret);
|
|
450
|
-
return { ok: true };
|
|
694
|
+
const claims = await verifyAccessToken(header.slice(7), base, secret);
|
|
695
|
+
return { ok: true, sub: claims.sub };
|
|
451
696
|
}
|
|
452
697
|
catch {
|
|
453
698
|
return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'invalid or expired token' }) };
|
|
454
699
|
}
|
|
455
700
|
};
|
|
456
|
-
|
|
701
|
+
const mintFlowState = async ({ flow, alias, tenantId, bundles, nonce }) => {
|
|
702
|
+
// /authorize has a signed branch for alias_add only; anything else minted
|
|
703
|
+
// here would be a state no route verifies the way its flow needs.
|
|
704
|
+
if (flow !== 'alias_add')
|
|
705
|
+
throw new Error(`mintFlowState supports alias_add only, not ${String(flow)}`);
|
|
706
|
+
const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, flowSecret, nowSec());
|
|
707
|
+
return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
|
|
708
|
+
};
|
|
709
|
+
const reauthLink = (alias) => `${base}/authorize?flow=alias_reauth&${signReauthLink(base, secret, alias, nowSec())}`;
|
|
710
|
+
return { routes, authenticate, mintFlowState, reauthLink };
|
|
457
711
|
}
|