@volter/twin-googleoauth 0.1.0

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.
Files changed (52) hide show
  1. package/README.md +219 -0
  2. package/client/googleoauth-consent.css +207 -0
  3. package/client/googleoauth-consent.tsx +286 -0
  4. package/dist/client/googleoauth-consent.bundle.js +237 -0
  5. package/dist/client/googleoauth-consent.css +207 -0
  6. package/dist/client/googleoauth-consent.d.ts +88 -0
  7. package/dist/client/googleoauth-consent.js +94 -0
  8. package/dist/client/googleoauth-consent.tsx +286 -0
  9. package/dist/src/cli.d.ts +2 -0
  10. package/dist/src/cli.js +42 -0
  11. package/dist/src/googleoauth-autherror.d.ts +25 -0
  12. package/dist/src/googleoauth-autherror.js +144 -0
  13. package/dist/src/googleoauth-budget.d.ts +48 -0
  14. package/dist/src/googleoauth-budget.js +121 -0
  15. package/dist/src/googleoauth-capabilities.d.ts +3 -0
  16. package/dist/src/googleoauth-capabilities.js +1651 -0
  17. package/dist/src/googleoauth-conformance.d.ts +10 -0
  18. package/dist/src/googleoauth-conformance.js +426 -0
  19. package/dist/src/googleoauth-connector.d.ts +70 -0
  20. package/dist/src/googleoauth-connector.js +244 -0
  21. package/dist/src/googleoauth-consent-client.gen.d.ts +2 -0
  22. package/dist/src/googleoauth-consent-client.gen.js +10 -0
  23. package/dist/src/googleoauth-consent-ui.d.ts +25 -0
  24. package/dist/src/googleoauth-consent-ui.js +102 -0
  25. package/dist/src/googleoauth-jwt.d.ts +78 -0
  26. package/dist/src/googleoauth-jwt.js +183 -0
  27. package/dist/src/googleoauth-scopes.d.ts +36 -0
  28. package/dist/src/googleoauth-scopes.js +92 -0
  29. package/dist/src/googleoauth-server.d.ts +34 -0
  30. package/dist/src/googleoauth-server.js +89 -0
  31. package/dist/src/googleoauth-store.d.ts +78 -0
  32. package/dist/src/googleoauth-store.js +313 -0
  33. package/dist/src/googleoauth-twin.d.ts +53 -0
  34. package/dist/src/googleoauth-twin.js +1050 -0
  35. package/dist/src/index.d.ts +16 -0
  36. package/dist/src/index.js +102 -0
  37. package/package.json +75 -0
  38. package/src/cli.ts +41 -0
  39. package/src/googleoauth-autherror.ts +150 -0
  40. package/src/googleoauth-budget.ts +147 -0
  41. package/src/googleoauth-capabilities.ts +1775 -0
  42. package/src/googleoauth-conformance.ts +472 -0
  43. package/src/googleoauth-connector.ts +266 -0
  44. package/src/googleoauth-consent-client.gen.ts +10 -0
  45. package/src/googleoauth-consent-ui.ts +124 -0
  46. package/src/googleoauth-journey.uitest.ts +296 -0
  47. package/src/googleoauth-jwt.ts +207 -0
  48. package/src/googleoauth-scopes.ts +109 -0
  49. package/src/googleoauth-server.ts +101 -0
  50. package/src/googleoauth-store.ts +359 -0
  51. package/src/googleoauth-twin.ts +1207 -0
  52. package/src/index.ts +175 -0
@@ -0,0 +1,1207 @@
1
+ // Google OAuth 2.0 / OpenID Connect twin REQUEST HANDLER — the vendor's THREE-LEGGED consent
2
+ // surface, served locally.
3
+ //
4
+ // Contract: handleGoogleOAuthTwinRequest({method, path, ...}) -> {status, body, headers}. It is the
5
+ // faithful surface an unmodified OAuth client library talks to:
6
+ //
7
+ // accounts.google.com GET /o/oauth2/v2/auth (+ legacy /o/oauth2/auth) → the CONSENT SCREEN
8
+ // GET /.well-known/openid-configuration → OIDC discovery
9
+ // oauth2.googleapis.com POST /token (+ /oauth2/v4/token, /o/oauth2/token)
10
+ // POST|GET /revoke
11
+ // GET /tokeninfo
12
+ // www.googleapis.com GET /oauth2/v3/certs (+ legacy /oauth2/v1/certs) → the JWKS
13
+ // GET /oauth2/v3/userinfo
14
+ // openidconnect.googleapis.com GET|POST /v1/userinfo
15
+ //
16
+ // ── WHY THIS PACK IS BROWSER-FACING, AND WHAT THAT MEANS ────────────────────────────────────────
17
+ // Every other twin in this repo answers a MACHINE. This one has to answer a HUMAN: the whole point
18
+ // of the authorization-code flow is that a browser is redirected to the vendor, a person reads what
19
+ // the app is asking for, and clicks Allow. A twin that skipped the screen and 302'd straight back
20
+ // would not be a twin of Google OAuth — it would be a bypass, and every integration bug that lives
21
+ // in the consent leg (wrong redirect_uri, missing state, a scope the user declined) would be
22
+ // invisible. So the twin SERVES HTML at the vendor's real path, rendered server-side from its own
23
+ // projection by the SAME React components the client bundle ships (googleoauth-consent-ui.ts).
24
+ // This is the vendor's OWN product UI, not a dashboard mirror — but the mirror DISCIPLINE applies
25
+ // in full: the screen is data-coupled to kernel state and is driven by a Playwright journey.
26
+ //
27
+ // ── WHAT IS REAL HERE ───────────────────────────────────────────────────────────────────────────
28
+ // • The FULL authorization-code round trip: auth request → consent → 302 with code+state → the
29
+ // code is redeemable exactly ONCE at this same pack's token endpoint → refresh_token grant.
30
+ // • PKCE S256/plain, verified with real SHA-256 (googleoauth-jwt.ts `pkceS256`).
31
+ // • id_token is a GENUINE RS256 JWT signed by a persisted RSA keypair whose public half this
32
+ // twin serves at /oauth2/v3/certs, so `google-auth-library`'s `verifyIdToken` and
33
+ // `openid-client`'s id_token validation both succeed UNMODIFIED. (The clerk precedent: real
34
+ // crypto or nothing.)
35
+ // • Redirect-URI matching is EXACT, the way Google's is — the single most common integration bug
36
+ // is only reproducible if the twin is as strict as the vendor.
37
+ //
38
+ // ── WHAT IS NOT ────────────────────────────────────────────────────────────────────────────────
39
+ // The twin authenticates NOBODY. There is no password, no 2FA, no session cookie, no risk engine:
40
+ // the account chooser lists personas seeded into kernel state and picking one IS the login. That is
41
+ // deliberate and stated on the screen, not a gap being hidden — a local twin cannot hold a Google
42
+ // credential and must never appear to.
43
+ //
44
+ // State lives in the kernel action log (D1). No real Google endpoint is ever called from this path
45
+ // (D4).
46
+ import { nodeBuiltin } from '@volter/world-core';
47
+ import { atHash, buildJwks, buildLegacyPemCerts, pkceS256, signJwt } from './googleoauth-jwt.ts';
48
+ import { authErrorRedirect, decodeAuthError, ERROR_DOC_URLS, ERROR_PAGE_PATH, errorHeadline } from './googleoauth-autherror.ts';
49
+ import { consentPageHtml, errorPageHtml, googleOAuthConsentState } from './googleoauth-consent-ui.ts';
50
+ import { formatScopeParam, isGranularlyDeclinable, parseScopeParam } from './googleoauth-scopes.ts';
51
+ import { granularConsentApplies } from '../client/googleoauth-consent.tsx';
52
+ import {
53
+ authRequestIdFor,
54
+ ensureSeed,
55
+ listAccounts,
56
+ mintAccessToken,
57
+ mintAuthorizationCode,
58
+ mintRefreshToken,
59
+ nextRev,
60
+ readOne,
61
+ readType,
62
+ redirectUriAllowed,
63
+ RESOURCE_TYPES as STORE_RESOURCE_TYPES,
64
+ write,
65
+ type Row,
66
+ } from './googleoauth-store.ts';
67
+
68
+ export { RESOURCE_TYPES } from './googleoauth-store.ts';
69
+
70
+ /** Google's real hosts, used when a caller does not tell the twin its own origin. */
71
+ export const ACCOUNTS_ORIGIN = 'https://accounts.google.com';
72
+ export const OAUTH2_ORIGIN = 'https://oauth2.googleapis.com';
73
+ export const APIS_ORIGIN = 'https://www.googleapis.com';
74
+ export const OIDC_ORIGIN = 'https://openidconnect.googleapis.com';
75
+ /** The `iss` Google puts in every id_token. (Google historically also used the bare
76
+ * `accounts.google.com`; verifiers accept both, and this twin issues the URL form.) */
77
+ export const ISSUER = 'https://accounts.google.com';
78
+
79
+ /** Google's real default access-token lifetime. (The web-server guide's sample body shows 3920;
80
+ * live tokens read 3600 at issue, and tokeninfo reports the REMAINING life, hence the ~3568
81
+ * values in Google's own tokeninfo examples.) */
82
+ const ACCESS_TOKEN_TTL_SECONDS = 3600;
83
+ /** Google's authorization codes are short-lived; the published guidance is ~10 minutes. */
84
+ const AUTH_CODE_TTL_SECONDS = 600;
85
+
86
+ export type GoogleOAuthRequest = {
87
+ method: string;
88
+ /** Path plus query string, e.g. `/o/oauth2/v2/auth?client_id=…`. */
89
+ path: string;
90
+ body?: string;
91
+ /** Lower-cased request headers (authorization / content-type). */
92
+ headers?: Record<string, string>;
93
+ occurredAt?: string;
94
+ root?: string;
95
+ readOnly?: boolean;
96
+ /**
97
+ * Where this twin is reached (`twinPublicBase`: origin plus any served-World mount path). The
98
+ * DISCOVERY document, the auth-error redirect and the consent page's own form action have to
99
+ * point back at the twin or a discovery-driven client (openid-client) would follow them out to
100
+ * the real Google. Absent — an in-process call — the
101
+ * document is rendered with Google's REAL endpoint URLs, which is the vendor-faithful answer.
102
+ */
103
+ origin?: string;
104
+ /** The bare origin the request arrived at, when it differs from `origin` (a served World mounts
105
+ * the twin under a path). The seeded demo app's callbacks derive from it; defaults to `origin`. */
106
+ callbackOrigin?: string;
107
+ };
108
+
109
+ export type GoogleOAuthResponse = {
110
+ status: number;
111
+ /** A JSON-serialisable object, or a STRING when the response is an HTML page. */
112
+ body: unknown;
113
+ headers?: Record<string, string>;
114
+ };
115
+
116
+ const HTML = { 'content-type': 'text/html; charset=utf-8' };
117
+ const NOSTORE = { 'cache-control': 'no-store', pragma: 'no-cache' };
118
+
119
+ // ── vendor error envelopes ──────────────────────────────────────────────────────────────────────
120
+
121
+ /** The OAuth 2.0 token-endpoint error body Google returns: `{ error, error_description }`. */
122
+ function oauthError(error: string, description: string, status: number): GoogleOAuthResponse {
123
+ return { status, body: { error, error_description: description }, headers: { ...NOSTORE } };
124
+ }
125
+
126
+ /**
127
+ * The ONE answer an unusable authorization code gets: unknown, already-redeemed, expired, or minted
128
+ * for a different client are deliberately INDISTINGUISHABLE, because telling them apart would leak
129
+ * which codes exist. The wording is Google's own documented text for `invalid_grant`.
130
+ */
131
+ function badCode(): GoogleOAuthResponse {
132
+ return oauthError('invalid_grant', 'The supplied authorization code is invalid or in the wrong format.', 400);
133
+ }
134
+
135
+ /**
136
+ * The Google-API error envelope (`{ error: { code, message, status } }`) — the shape every
137
+ * googleapis.com REST surface uses.
138
+ *
139
+ * NOTE it is NOT what the userinfo endpoints answer with: those return the FLAT OAuth-style
140
+ * `{ error, error_description }` body, and the two hosts disagree on which code they use (see
141
+ * `userinfo` below). Both were captured live on 2026-08-20.
142
+ */
143
+ function apiError(code: number, message: string, status: string): GoogleOAuthResponse {
144
+ return { status: code, body: { error: { code, message, status } }, headers: { ...NOSTORE } };
145
+ }
146
+
147
+ /**
148
+ * The authorization endpoint's failure path, and the single most-misunderstood thing about this
149
+ * surface.
150
+ *
151
+ * Google does NOT answer a bad authorization request with an HTML body, and it does NOT bounce the
152
+ * error to the caller's `redirect_uri` — bouncing would hand an attacker an open redirector, and
153
+ * for a bad `redirect_uri` there is no trustworthy address to bounce TO. It 302s to its OWN error
154
+ * page at `/signin/oauth/error?authError=<protobuf>&flowName=GeneralOAuthFlow`, which then renders
155
+ * "Access blocked: …" and the "Error <status>: <code>" line (googleoauth-autherror.ts).
156
+ *
157
+ * The ONLY things that go back to `redirect_uri` are the two documented user-facing outcomes:
158
+ * `access_denied` (the user pressed Cancel) and `interaction_required` (prompt=none could not be
159
+ * satisfied silently). An earlier version of this handler redirected every parameter error the way
160
+ * RFC 6749 §4.1.2.1 describes; a live capture of the real endpoint refuted that — Google renders
161
+ * the page for `invalid_request`, `invalid_scope` and friends too.
162
+ */
163
+ function errorPage(req: GoogleOAuthRequest, e: { code: string; message: string; status: number; param?: string }): GoogleOAuthResponse {
164
+ const origin = req.origin ?? ACCOUNTS_ORIGIN;
165
+ const clientId = new URLSearchParams(req.path.split('?')[1] ?? '').get('client_id') ?? undefined;
166
+ return {
167
+ status: 302,
168
+ body: '',
169
+ headers: {
170
+ location: authErrorRedirect(origin, { ...e, ...(ERROR_DOC_URLS[e.code] ? { docUrl: ERROR_DOC_URLS[e.code]! } : {}) }, clientId),
171
+ ...NOSTORE,
172
+ },
173
+ };
174
+ }
175
+
176
+ /** A 302 back to a VALIDATED redirect_uri carrying one of the two user-facing OAuth errors. */
177
+ function redirectError(redirectUri: string, error: string, state: string | null, extra: Record<string, string> = {}): GoogleOAuthResponse {
178
+ const url = new URL(redirectUri);
179
+ url.searchParams.set('error', error);
180
+ for (const [k, v] of Object.entries(extra)) url.searchParams.set(k, v);
181
+ if (state !== null) url.searchParams.set('state', state);
182
+ return { status: 302, body: '', headers: { location: url.toString(), ...NOSTORE } };
183
+ }
184
+
185
+ function redirectSuccess(redirectUri: string, params: Record<string, string>): GoogleOAuthResponse {
186
+ const url = new URL(redirectUri);
187
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
188
+ return { status: 302, body: '', headers: { location: url.toString(), ...NOSTORE } };
189
+ }
190
+
191
+ // ── helpers ─────────────────────────────────────────────────────────────────────────────────────
192
+
193
+ function splitPath(raw: string): { path: string; query: URLSearchParams } {
194
+ const [p, q = ''] = raw.split('?');
195
+ const path = (p ?? '/').replace(/\/+$/, '') || '/';
196
+ return { path, query: new URLSearchParams(q) };
197
+ }
198
+
199
+ /**
200
+ * The world instant this request happened at. The twin has NO clock of its own (runtime contract
201
+ * R9: no clock in served content) — `createGoogleOAuthTwinFetch` stamps every request with
202
+ * `worldNow()`, the world's frozen instant, and a caller that names no instant is a DEFECT rather
203
+ * than a cue to read the wall clock. The old `?? Date.now()` fallback put the host's wall clock
204
+ * into `expires_in`/`iat`/`exp` on any path that forgot to stamp; it throws now.
205
+ */
206
+ function instantOf(occurredAt?: string): string {
207
+ if (!occurredAt) {
208
+ throw new Error('googleoauth twin: the request carries no occurredAt — this twin has no clock of its own; the server stamps worldNow()');
209
+ }
210
+ return occurredAt;
211
+ }
212
+
213
+ function nowSeconds(occurredAt?: string): number {
214
+ const ms = Date.parse(instantOf(occurredAt));
215
+ if (Number.isNaN(ms)) throw new Error(`googleoauth twin: occurredAt is not a timestamp: ${occurredAt}`);
216
+ return Math.floor(ms / 1000);
217
+ }
218
+
219
+ /** A deterministic 21-digit stand-in for a service account's numeric unique id, derived from its
220
+ * address. Shape-faithful (Google's `sub` really is a 21-digit string) without pretending to know
221
+ * a number only Google's directory holds. */
222
+ function serviceAccountSubject(email: string): string {
223
+ const { createHash } = nodeBuiltin('node:crypto') as typeof import('node:crypto');
224
+ const digits = BigInt('0x' + createHash('sha256').update(email).digest('hex').slice(0, 24)).toString().slice(0, 20);
225
+ return `1${digits.padStart(20, '0')}`;
226
+ }
227
+
228
+ /** Google's `sub`-keyed account row → the OIDC claim bag, FILTERED BY GRANTED SCOPE. */
229
+ function profileClaims(account: Row, scopes: readonly string[]): Record<string, unknown> {
230
+ const wantsEmail = scopes.includes('email') || scopes.includes('https://www.googleapis.com/auth/userinfo.email');
231
+ const wantsProfile = scopes.includes('profile') || scopes.includes('https://www.googleapis.com/auth/userinfo.profile');
232
+ return {
233
+ ...(wantsEmail ? { email: account.email, email_verified: account.emailVerified === true } : {}),
234
+ ...(wantsProfile
235
+ ? {
236
+ name: account.name,
237
+ given_name: account.givenName,
238
+ family_name: account.familyName,
239
+ picture: account.picture,
240
+ }
241
+ : {}),
242
+ ...(account.hd ? { hd: account.hd } : {}),
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Client authentication. Google accepts the secret in the POST body (`client_secret`, the default
248
+ * `google-auth-library` uses) or as HTTP Basic (`clientAuthentication: 'basic'`), and accepts NO
249
+ * secret at all from an `installed` (public) client, which is what PKCE exists to protect.
250
+ */
251
+ function authenticateClient(
252
+ form: URLSearchParams,
253
+ headers: Record<string, string> | undefined,
254
+ root: string | undefined,
255
+ ): { client: Row } | { fail: GoogleOAuthResponse } {
256
+ let clientId = form.get('client_id');
257
+ let clientSecret = form.get('client_secret');
258
+ const basic = headers?.authorization;
259
+ if (basic && /^basic /i.test(basic)) {
260
+ const decoded = Buffer.from(basic.slice(6).trim(), 'base64').toString('utf8');
261
+ const sep = decoded.indexOf(':');
262
+ if (sep >= 0) {
263
+ clientId = decodeURIComponent(decoded.slice(0, sep));
264
+ clientSecret = decodeURIComponent(decoded.slice(sep + 1));
265
+ }
266
+ }
267
+ if (!clientId) return { fail: oauthError('invalid_request', 'client_id is required', 400) };
268
+ const client = readOne(root, 'oauth_client', clientId);
269
+ // Google answers an unknown client with 401 invalid_client at the TOKEN endpoint (as opposed to
270
+ // the error PAGE it renders at the authorization endpoint).
271
+ if (!client) return { fail: oauthError('invalid_client', 'The OAuth client was not found.', 401) };
272
+ if (client.clientType === 'web') {
273
+ if (!clientSecret) return { fail: oauthError('invalid_client', 'The provided client secret is invalid.', 401) };
274
+ if (clientSecret !== client.secret) return { fail: oauthError('invalid_client', 'The provided client secret is invalid.', 401) };
275
+ } else if (clientSecret && clientSecret !== client.secret) {
276
+ // A public client MAY omit the secret, but a WRONG one is still a rejection.
277
+ return { fail: oauthError('invalid_client', 'The provided client secret is invalid.', 401) };
278
+ }
279
+ return { client };
280
+ }
281
+
282
+ // ── the authorization endpoint (the consent screen) ─────────────────────────────────────────────
283
+
284
+ const AUTH_PATHS = new Set(['/o/oauth2/v2/auth', '/o/oauth2/auth', '/o/oauth2/v2/auth/oauthchooseaccount']);
285
+
286
+ /**
287
+ * Google refuses a REPEATED query parameter outright rather than picking one — captured live:
288
+ * `response_type=code&response_type=id_token` answers
289
+ * "OAuth 2 parameters can only have a single value: response_type". Worth modelling because a
290
+ * duplicated parameter is a real and confusing integration bug (two layers of a framework both
291
+ * appending `scope`), and a twin that quietly took the first value would hide it.
292
+ */
293
+ const SINGLE_VALUED = [
294
+ 'client_id', 'redirect_uri', 'response_type', 'scope', 'state', 'access_type', 'prompt',
295
+ 'login_hint', 'nonce', 'code_challenge', 'code_challenge_method', 'include_granted_scopes',
296
+ 'enable_granular_consent', 'display', 'hd',
297
+ ] as const;
298
+
299
+ /** One enum-valued parameter, with the message Google produces for a bad value (captured live). */
300
+ const ENUM_PARAMS: Array<{ name: string; allowed: string[]; message: (v: string) => string }> = [
301
+ { name: 'access_type', allowed: ['online', 'offline'], message: (v) => `Invalid parameter value for access_type: '${v}' is not valid` },
302
+ { name: 'display', allowed: ['page', 'popup', 'touch', 'wap'], message: (v) => `Invalid parameter value for display: '${v}' is not valid` },
303
+ { name: 'include_granted_scopes', allowed: ['true', 'false'], message: (v) => `Invalid value, must be one of false, true: ${v}` },
304
+ { name: 'enable_granular_consent', allowed: ['true', 'false'], message: (v) => `Invalid value, must be one of false, true: ${v}` },
305
+ ];
306
+
307
+ async function authorize(req: GoogleOAuthRequest, query: URLSearchParams): Promise<GoogleOAuthResponse> {
308
+ const root = req.root;
309
+ const state = query.get('state');
310
+ const fail = (code: string, message: string, status: number, param?: string) =>
311
+ errorPage(req, { code, message, status, ...(param ? { param } : {}) });
312
+
313
+ // 0. Repeated parameters are refused before anything else looks at a value.
314
+ for (const name of SINGLE_VALUED) {
315
+ if (query.getAll(name).length > 1) {
316
+ return fail('invalid_request', `OAuth 2 parameters can only have a single value: ${name}`, 400, name);
317
+ }
318
+ }
319
+
320
+ // 1. client_id.
321
+ const clientId = query.get('client_id');
322
+ if (!clientId) return fail('invalid_request', 'Required parameter is missing: client_id', 400, 'client_id');
323
+ const client = readOne(root, 'oauth_client', clientId);
324
+ if (!client) return fail('invalid_client', 'The OAuth client was not found.', 401);
325
+
326
+ // 2. redirect_uri. THE SECURITY BOUNDARY: until the URI is proven to belong to this client there
327
+ // is no address the twin may bounce an error to, so both failures render the error page.
328
+ const redirectUri = query.get('redirect_uri');
329
+ if (!redirectUri) return fail('invalid_request', 'Required parameter is missing: redirect_uri', 400, 'redirect_uri');
330
+ if (!redirectUriAllowed(client, redirectUri)) {
331
+ return fail(
332
+ 'redirect_uri_mismatch',
333
+ "You can't sign in to this app because it doesn't comply with Google's OAuth 2.0 policy.\n\n"
334
+ + "If you're the app developer, register the redirect URI in the Google Cloud Console.",
335
+ 400,
336
+ `redirect_uri=${redirectUri}`,
337
+ );
338
+ }
339
+
340
+ // 3. …and everything AFTER that boundary is still an error PAGE, not a redirect. This is the part
341
+ // that surprises people (RFC 6749 §4.1.2.1 says to redirect); a live capture of the real
342
+ // endpoint shows Google rendering the page for invalid_request/invalid_scope too. Only
343
+ // `access_denied` and `interaction_required` ever reach the caller's redirect_uri.
344
+ const responseType = query.get('response_type');
345
+ if (!responseType) return fail('invalid_request', 'Required parameter is missing: response_type', 400, 'response_type');
346
+ if (responseType !== 'code') {
347
+ // Google DOES support `token`/`id_token` response types (the implicit and hybrid flows), so the
348
+ // twin must NOT answer "invalid response_type" — that would be claiming the vendor rejects
349
+ // something it accepts, the inverse false-green. It refuses as UNMODELLED instead, and says so.
350
+ return fail(
351
+ 'unsupported_response_type',
352
+ `This twin models the authorization-code flow only; response_type=${responseType} is not modelled `
353
+ + '(googleoauth.authorize.implicit_flow). Real Google supports it.',
354
+ 400,
355
+ `response_type=${responseType}`,
356
+ );
357
+ }
358
+ const scopes = parseScopeParam(query.get('scope'));
359
+ if (scopes.length === 0) return fail('invalid_request', 'Missing required parameter: scope', 400, 'scope');
360
+
361
+ for (const spec of ENUM_PARAMS) {
362
+ const value = query.get(spec.name);
363
+ if (value !== null && !spec.allowed.includes(value)) {
364
+ return fail('invalid_request', spec.message(value), 400, `${spec.name}=${value}`);
365
+ }
366
+ }
367
+
368
+ const codeChallenge = query.get('code_challenge');
369
+ const codeChallengeMethod = query.get('code_challenge_method') ?? (codeChallenge ? 'plain' : null);
370
+ if (codeChallengeMethod && !['S256', 'plain'].includes(codeChallengeMethod)) {
371
+ return fail('invalid_request', `Invalid parameter value for code_challenge_method: '${codeChallengeMethod}' is not a valid CodeChallengeMethod`, 400, `code_challenge_method=${codeChallengeMethod}`);
372
+ }
373
+ if (codeChallengeMethod && !codeChallenge) {
374
+ return fail('invalid_request', 'Missing required parameter: code_challenge', 400, 'code_challenge');
375
+ }
376
+
377
+ const accessType = query.get('access_type') ?? 'online';
378
+ const prompt = query.get('prompt');
379
+ if (prompt !== null) {
380
+ const values = prompt.split(/\s+/).filter(Boolean);
381
+ const allowed = new Set(['none', 'consent', 'select_account']);
382
+ // `none` must be alone — Google documents it as mutually exclusive with the others.
383
+ if (values.length === 0 || values.some((v) => !allowed.has(v)) || (values.includes('none') && values.length > 1)) {
384
+ return fail('invalid_request', `Invalid prompt: ${prompt}`, 400, `prompt=${prompt}`);
385
+ }
386
+ }
387
+
388
+ // 4. Record the request, then render the screen. The auth_request row is what the Allow/Deny post
389
+ // resolves — the browser never carries the parameters back, so they cannot be tampered with
390
+ // between the two legs (which is exactly the property Google's own opaque state handle has).
391
+ const fields = {
392
+ clientId,
393
+ redirectUri,
394
+ scope: formatScopeParam(scopes),
395
+ state: state ?? null,
396
+ nonce: query.get('nonce'),
397
+ accessType,
398
+ prompt: prompt ?? null,
399
+ loginHint: query.get('login_hint'),
400
+ includeGrantedScopes: query.get('include_granted_scopes') === 'true',
401
+ codeChallenge,
402
+ codeChallengeMethod: codeChallenge ? (codeChallengeMethod ?? 'plain') : null,
403
+ settled: false,
404
+ rev: 1,
405
+ };
406
+ // The handle is SERVED (it is the consent form's own hidden field), so R9 governs it: minted from
407
+ // the world instant + the rows already held, never from entropy, and an identical authorize at
408
+ // the same instant reuses the pending row rather than piling up a second screen.
409
+ const requestId = authRequestIdFor(root, instantOf(req.occurredAt), fields);
410
+ await write('auth_request', requestId, 'auth_request.create', fields, { root, occurredAt: instantOf(req.occurredAt) });
411
+
412
+ const accounts = listAccounts(root);
413
+
414
+ // `login_hint` names the account the app already knows about, and Google honours it by SKIPPING
415
+ // the chooser and going straight to consent for that account. An unmatched hint is not an error —
416
+ // Google falls back to the chooser — so the twin does the same rather than inventing a failure.
417
+ const hint = query.get('login_hint');
418
+ const hinted = hint
419
+ ? accounts.find((a) => a.id === hint || String(a.email).toLowerCase() === hint.toLowerCase())
420
+ : undefined;
421
+
422
+ // `prompt=none` is the "NEVER show me a screen" flow, and it has exactly two outcomes: a silent
423
+ // 302 carrying a code, or `interaction_required`. An earlier version got the FAILURE right and
424
+ // then fell through to `renderConsent` on success — serving an HTML page to a caller that asked
425
+ // for no UI, which a redirect-following client cannot use (§9 round one, MAJOR). It also only
426
+ // ever consulted `accounts[0]`, so any persona but the first could hold a covering grant and
427
+ // still be refused.
428
+ if (prompt?.split(/\s+/).includes('none')) {
429
+ const covers = (account: Row) => {
430
+ const grant = readOne(root, 'grant', `${clientId}:${account.id}`);
431
+ const granted: string[] = Array.isArray(grant?.scopes) ? grant!.scopes : [];
432
+ return scopes.every((s) => granted.includes(s));
433
+ };
434
+ // A `login_hint` that does not cover is a REFUSAL, never a substitution. An earlier version fell
435
+ // through to `accounts.find(covers)`, so an app that named Ada and got no screen could be handed
436
+ // an id_token for Grace — a different human — and could not find out until it decoded the token
437
+ // (§9 round two, MAJOR). If the app named a subject, that subject is the only candidate.
438
+ const silent = hinted ? (covers(hinted) ? hinted : undefined) : accounts.find(covers);
439
+ if (!silent) {
440
+ // Captured live: Google answers `?error=interaction_required&error_subtype=access_denied`.
441
+ return redirectError(redirectUri, 'interaction_required', state, { error_subtype: 'access_denied' });
442
+ }
443
+ return grantSilently(req, requestId, silent);
444
+ }
445
+
446
+ return renderConsent(req, requestId, hinted?.id ?? null);
447
+ }
448
+
449
+ /**
450
+ * `prompt=none`'s success path: mint the code and 302, with NO screen. Shares `settleAuthRequest`
451
+ * with the Allow button so the two paths cannot drift — the only difference is that nobody clicked.
452
+ */
453
+ async function grantSilently(req: GoogleOAuthRequest, requestId: string, account: Row): Promise<GoogleOAuthResponse> {
454
+ const form = new URLSearchParams();
455
+ form.set('auth_request', requestId);
456
+ form.set('sub', account.id);
457
+ form.set('decision', 'allow');
458
+ // Everything already granted stays granted: `prompt=none` cannot be a granular-consent screen,
459
+ // because there is no screen.
460
+ const authRequest = readOne(req.root, 'auth_request', requestId);
461
+ for (const scope of parseScopeParam(String(authRequest?.scope ?? ''))) form.append('scope', scope);
462
+ return consentDecision(req, form, true);
463
+ }
464
+
465
+ /** Render whichever consent step the (requestId, sub) pair selects, from the projection alone. */
466
+ function renderConsent(req: GoogleOAuthRequest, requestId: string, sub: string | null): GoogleOAuthResponse {
467
+ const view = googleOAuthConsentState({
468
+ root: req.root,
469
+ requestId,
470
+ sub,
471
+ origin: req.origin ?? ACCOUNTS_ORIGIN,
472
+ });
473
+ if (!view) {
474
+ return errorPage(req, { code: 'invalid_request', message: 'Unknown or expired authorization request.', status: 400 });
475
+ }
476
+ return { status: 200, body: consentPageHtml(view, req.origin ?? ''), headers: { ...HTML, ...NOSTORE } };
477
+ }
478
+
479
+ /**
480
+ * `GET /signin/oauth/error` — the page every authorization failure above is bounced to. It decodes
481
+ * the `authError` protobuf it was handed and renders it with the REAL exported `ErrorPage`
482
+ * component, so the served page and the components the verifies assert over are one renderer.
483
+ *
484
+ * Served 200: the status the page NAMES (its "Error 401: invalid_client" line) travels in the
485
+ * payload, not in the HTTP status, and the real page's own HTTP status was not captured — filed as
486
+ * `googleoauth.errors.error_page_http_status` rather than guessed.
487
+ */
488
+ function signinOAuthErrorPage(req: GoogleOAuthRequest, query: URLSearchParams): GoogleOAuthResponse {
489
+ const decoded = decodeAuthError(query.get('authError') ?? '');
490
+ if (!decoded) {
491
+ return {
492
+ status: 400,
493
+ body: errorPageHtml({ status: 400, code: 'invalid_request', summary: errorHeadline('invalid_request'), detail: 'That authorization error link is not valid.' }, req.origin ?? ''),
494
+ headers: { ...HTML, ...NOSTORE },
495
+ };
496
+ }
497
+ return {
498
+ status: 200,
499
+ body: errorPageHtml({
500
+ status: decoded.status,
501
+ code: decoded.code,
502
+ summary: errorHeadline(decoded.code),
503
+ detail: decoded.message,
504
+ ...(decoded.param ? { requestPath: decoded.param } : {}),
505
+ ...(decoded.docUrl ? { docUrl: decoded.docUrl } : {}),
506
+ appName: appNameFor(req.root, query.get('client_id')),
507
+ }, req.origin ?? ''),
508
+ headers: { ...HTML, ...NOSTORE },
509
+ };
510
+ }
511
+
512
+ function appNameFor(root: string | undefined, clientId: string | null): string | undefined {
513
+ if (!clientId) return undefined;
514
+ const client = readOne(root, 'oauth_client', clientId);
515
+ return client ? String(client.name) : undefined;
516
+ }
517
+
518
+ /**
519
+ * The Allow/Deny post. TWIN-ONLY SURFACE, deliberately namespaced under `/_twin/` and deliberately
520
+ * ABSENT from the capability manifest: Google's consent form posts to an undocumented internal
521
+ * endpoint, so there is no vendor path to be faithful to here and inventing one would be surface
522
+ * the vendor does not have. It is the twin's equivalent of the seed routes ADDING_A_TWIN.md §6
523
+ * sanctions (`POST /twin/observation`, `POST /v1/_twin/...`).
524
+ */
525
+ async function consentDecision(req: GoogleOAuthRequest, form: URLSearchParams, silentlyGranted = false): Promise<GoogleOAuthResponse> {
526
+ const root = req.root;
527
+ const at = req.occurredAt;
528
+ const opts = { root, ...(at ? { occurredAt: at } : {}) };
529
+ const requestId = form.get('auth_request') ?? '';
530
+ const authRequest = readOne(root, 'auth_request', requestId);
531
+ if (!authRequest) return oauthError('invalid_request', 'Unknown or expired authorization request.', 400);
532
+ if (authRequest.settled === true) return oauthError('invalid_request', 'This authorization request was already settled.', 400);
533
+
534
+ const redirectUri = String(authRequest.redirectUri);
535
+ const state: string | null = typeof authRequest.state === 'string' ? authRequest.state : null;
536
+
537
+ await write('auth_request', requestId, 'auth_request.settle', { settled: true, rev: nextRev(root, 'auth_request', requestId) }, opts);
538
+
539
+ // DENY — Google's documented user-refusal redirect.
540
+ if (form.get('decision') !== 'allow') {
541
+ return redirectError(redirectUri, 'access_denied', state);
542
+ }
543
+
544
+ const sub = form.get('sub') ?? '';
545
+ const account = readOne(root, 'account', sub);
546
+ if (!account) return oauthError('invalid_request', 'Unknown account.', 400);
547
+
548
+ // GRANULAR CONSENT — the user may untick individual product scopes; the OIDC scopes ride with
549
+ // signing in and are never declinable. What survives here is what the grant (and the echoed
550
+ // `scope` on the redirect) carries — a real integration MUST cope with getting less than it
551
+ // asked for, and a twin that always granted everything would hide that entire bug class.
552
+ const requested = parseScopeParam(String(authRequest.scope));
553
+ // Google only SHOWS checkboxes when its granular rule applies (a sign-in scope mixed with a
554
+ // product one, or two or more product ones). When it does not, the grant is all-or-nothing and a
555
+ // posted scope list cannot narrow it — so the twin ignores the tick list exactly as the screen
556
+ // that never rendered a checkbox would.
557
+ const granular = granularConsentApplies(requested.map((s) => ({ declinable: isGranularlyDeclinable(s) })));
558
+ const ticked = new Set(form.getAll('scope'));
559
+ const granted = granular ? requested.filter((s) => !isGranularlyDeclinable(s) || ticked.has(s)) : requested;
560
+ if (granted.length === 0) {
561
+ // Declining every declinable scope with nothing left is a refusal, not an empty grant.
562
+ return redirectError(redirectUri, 'access_denied', state);
563
+ }
564
+
565
+ const declined = requested.filter((s) => !granted.includes(s));
566
+ const grantId = `${authRequest.clientId}:${sub}`;
567
+ const priorGrant = readOne(root, 'grant', grantId);
568
+ // A REVOKED grant is not a prior grant. `revoke` clears the scope set (see `revoke` below), and
569
+ // real Google treats a re-consent after a revocation as a first authorization — which is why it
570
+ // hands back a refresh token. Reading the row's mere EXISTENCE made the twin withhold one
571
+ // (§9 round one, MAJOR).
572
+ const priorScopes: string[] = Array.isArray(priorGrant?.scopes) ? priorGrant!.scopes : [];
573
+ const isFirstGrant = priorScopes.length === 0;
574
+ // THE GRANT AFTER THIS SCREEN. Computed BEFORE `effective`, because `effective` must be derived
575
+ // from it: an earlier version unioned the PRE-removal prior scopes into the code and the access
576
+ // token, so a scope the user unticked ON THIS VERY SCREEN was handed straight back in the same
577
+ // request while the grant row correctly dropped it — leaving a token whose scope set was a strict
578
+ // superset of the grant that authorised it (§9 round two, MAJOR).
579
+ const nextScopes = [...new Set([...priorScopes.filter((s) => !declined.includes(s)), ...granted])];
580
+ // include_granted_scopes=true is Google's INCREMENTAL AUTHORIZATION: the new token carries the
581
+ // union of everything this user STILL grants this client — never a scope this screen declined.
582
+ const effective = authRequest.includeGrantedScopes === true ? nextScopes : granted;
583
+
584
+ // THE GRANT IS WHAT THE SCREEN DECIDED, not a monotonic union: a scope this request ASKED about is
585
+ // governed by this request's answer, and a scope it did not mention is untouched. An earlier
586
+ // version unioned unconditionally, so a decline stayed granted forever (§9 round one, MAJOR).
587
+ await write(
588
+ 'grant',
589
+ grantId,
590
+ 'grant.upsert',
591
+ { clientId: authRequest.clientId, sub, scopes: nextScopes, rev: nextRev(root, 'grant', grantId) },
592
+ opts,
593
+ );
594
+
595
+ const code = mintAuthorizationCode(root, instantOf(at), requestId);
596
+ await write(
597
+ 'authorization_code',
598
+ code,
599
+ 'authorization_code.create',
600
+ {
601
+ clientId: authRequest.clientId,
602
+ sub,
603
+ scope: formatScopeParam(effective),
604
+ redirectUri,
605
+ nonce: authRequest.nonce ?? null,
606
+ accessType: authRequest.accessType,
607
+ prompt: authRequest.prompt ?? null,
608
+ codeChallenge: authRequest.codeChallenge ?? null,
609
+ codeChallengeMethod: authRequest.codeChallengeMethod ?? null,
610
+ isFirstGrant,
611
+ consumed: false,
612
+ expiresAt: nowSeconds(at) + AUTH_CODE_TTL_SECONDS,
613
+ rev: 1,
614
+ },
615
+ opts,
616
+ );
617
+
618
+ return redirectSuccess(redirectUri, {
619
+ code,
620
+ ...(state !== null ? { state } : {}),
621
+ scope: formatScopeParam(effective),
622
+ authuser: '0',
623
+ // `prompt` echoes what actually happened. The silent `prompt=none` path shows no screen, so
624
+ // stamping `consent` there positively asserted a screen that does not exist (§9 round two).
625
+ ...(silentlyGranted ? {} : { prompt: 'consent' }),
626
+ // A Workspace account's hosted domain rides on the redirect (GIS code-model documents `hd`).
627
+ ...(account.hd ? { hd: String(account.hd) } : {}),
628
+ // NO `iss` PARAMETER, deliberately. An earlier version emitted RFC 9207's `iss` here, reasoning
629
+ // from an `authorization_response_iss_parameter_supported: true` key it also put in the
630
+ // discovery document — but neither the key nor the parameter could be confirmed against
631
+ // Google's real artefacts, and §9 round one correctly called the pair invented surface. Both
632
+ // are gone: a twin that invents a response parameter is one an integration can come to depend
633
+ // on and then break against the real vendor. Filed as `googleoauth.authorize.iss_parameter`.
634
+ });
635
+ }
636
+
637
+ // ── the token endpoint ──────────────────────────────────────────────────────────────────────────
638
+
639
+ async function token(req: GoogleOAuthRequest): Promise<GoogleOAuthResponse> {
640
+ // Google's token endpoint is `application/x-www-form-urlencoded`. The twin is deliberately strict
641
+ // about that rather than tolerating a JSON body it has no documented evidence the vendor accepts
642
+ // (`googleoauth.token.json_body_tolerance`, todo) — an invented tolerance is the inverse
643
+ // false-green: a client that only works against the twin.
644
+ const form = new URLSearchParams(req.body ?? '');
645
+ const grantType = form.get('grant_type');
646
+ if (!grantType) return oauthError('invalid_request', 'Missing required parameter: grant_type', 400);
647
+
648
+ if (grantType === 'urn:ietf:params:oauth:grant-type:jwt-bearer') return jwtBearerGrant(req, form);
649
+ if (grantType === 'authorization_code') return authorizationCodeGrant(req, form);
650
+ if (grantType === 'refresh_token') return refreshTokenGrant(req, form);
651
+ return oauthError('unsupported_grant_type', `Invalid grant_type: ${grantType}`, 400);
652
+ }
653
+
654
+ async function issueTokens(
655
+ req: GoogleOAuthRequest,
656
+ opts: { client: Row; sub: string; scope: string; nonce: string | null; withRefreshToken: boolean;
657
+ /** The credential being EXCHANGED (the authorization code, or the refresh token presented).
658
+ * It seeds the minted tokens (R9), so two exchanges at one world instant differ by what was
659
+ * handed in as well as by the row count. */
660
+ subject: string;
661
+ /** The grant's access type. Carried EXPLICITLY because a refresh does not mint a new refresh
662
+ * token yet still belongs to an OFFLINE grant — deriving it from `withRefreshToken` made a
663
+ * refreshed token report `access_type: online` at /tokeninfo, which is wrong. */
664
+ accessType?: 'online' | 'offline' },
665
+ ): Promise<GoogleOAuthResponse> {
666
+ const root = req.root;
667
+ const at = req.occurredAt;
668
+ const writeOpts = { root, ...(at ? { occurredAt: at } : {}) };
669
+ const scopes = parseScopeParam(opts.scope);
670
+ const account = readOne(root, 'account', opts.sub);
671
+ if (!account) return badCode();
672
+ const issuedAt = nowSeconds(at);
673
+
674
+ const accessToken = mintAccessToken(root, instantOf(at), opts.subject);
675
+ await write(
676
+ 'access_token',
677
+ accessToken,
678
+ 'access_token.create',
679
+ {
680
+ clientId: opts.client.id,
681
+ sub: opts.sub,
682
+ scope: opts.scope,
683
+ accessType: opts.accessType ?? (opts.withRefreshToken ? 'offline' : 'online'),
684
+ expiresAt: issuedAt + ACCESS_TOKEN_TTL_SECONDS,
685
+ revoked: false,
686
+ rev: 1,
687
+ },
688
+ writeOpts,
689
+ );
690
+
691
+ let refreshToken: string | undefined;
692
+ if (opts.withRefreshToken) {
693
+ refreshToken = mintRefreshToken(root, instantOf(at), opts.subject);
694
+ await write(
695
+ 'refresh_token',
696
+ refreshToken,
697
+ 'refresh_token.create',
698
+ { clientId: opts.client.id, sub: opts.sub, scope: opts.scope, revoked: false, rev: 1 },
699
+ writeOpts,
700
+ );
701
+ }
702
+
703
+ // The id_token rides only when `openid` was granted — that is what makes this an OIDC flow rather
704
+ // than a plain OAuth 2.0 one, and a client that asked for OAuth must not be handed an id_token.
705
+ let idToken: string | undefined;
706
+ if (scopes.includes('openid')) {
707
+ idToken = await signJwt(
708
+ {
709
+ iss: ISSUER,
710
+ azp: opts.client.id,
711
+ aud: opts.client.id,
712
+ sub: opts.sub,
713
+ at_hash: atHash(accessToken),
714
+ ...profileClaims(account, scopes),
715
+ ...(opts.nonce ? { nonce: opts.nonce } : {}),
716
+ },
717
+ { root, expiresInSeconds: ACCESS_TOKEN_TTL_SECONDS + 1, now: issuedAt },
718
+ );
719
+ }
720
+
721
+ return {
722
+ status: 200,
723
+ body: {
724
+ access_token: accessToken,
725
+ expires_in: ACCESS_TOKEN_TTL_SECONDS,
726
+ scope: opts.scope,
727
+ token_type: 'Bearer',
728
+ ...(refreshToken ? { refresh_token: refreshToken } : {}),
729
+ ...(idToken ? { id_token: idToken } : {}),
730
+ },
731
+ headers: { ...NOSTORE },
732
+ };
733
+ }
734
+
735
+ async function authorizationCodeGrant(req: GoogleOAuthRequest, form: URLSearchParams): Promise<GoogleOAuthResponse> {
736
+ const root = req.root;
737
+ const auth = authenticateClient(form, req.headers, root);
738
+ if ('fail' in auth) return auth.fail;
739
+ const { client } = auth;
740
+
741
+ const code = form.get('code');
742
+ if (!code) return oauthError('invalid_request', 'Missing required parameter: code', 400);
743
+ const row = readOne(root, 'authorization_code', code);
744
+ // Unknown, already-redeemed and expired codes are ONE answer — `invalid_grant` — because telling
745
+ // them apart would leak which codes exist. The description is Google's own documented wording for
746
+ // this case (web-server guide, "invalid_grant"). It is NOT "Bad Request": a live capture on
747
+ // 2026-08-20 showed that string belongs to a malformed `invalid_request` on the jwt-bearer grant,
748
+ // refuting a widely-repeated assumption this handler originally encoded.
749
+ if (!row) return badCode();
750
+ if (row.clientId !== client.id) return badCode();
751
+ if (row.consumed === true) return badCode();
752
+ if (typeof row.expiresAt === 'number' && nowSeconds(req.occurredAt) >= row.expiresAt) {
753
+ return badCode();
754
+ }
755
+
756
+ // redirect_uri must be REPEATED at the token endpoint and must match the one the code was minted
757
+ // against (RFC 6749 §4.1.3); Google names this failure specifically.
758
+ const redirectUri = form.get('redirect_uri');
759
+ if (!redirectUri || redirectUri !== row.redirectUri) {
760
+ return oauthError('redirect_uri_mismatch', 'Bad Request', 400);
761
+ }
762
+
763
+ // PKCE.
764
+ if (row.codeChallenge) {
765
+ const verifier = form.get('code_verifier');
766
+ if (!verifier) return oauthError('invalid_grant', 'The code_challenge parameter is invalid or missing.', 400);
767
+ const computed = row.codeChallengeMethod === 'S256' ? pkceS256(verifier) : verifier;
768
+ if (computed !== row.codeChallenge) return badCode();
769
+ } else if (form.get('code_verifier')) {
770
+ // A verifier for a code minted without a challenge is a client bug, and Google rejects it.
771
+ return badCode();
772
+ }
773
+
774
+ await write(
775
+ 'authorization_code',
776
+ code,
777
+ 'authorization_code.consume',
778
+ { consumed: true, rev: nextRev(root, 'authorization_code', code) },
779
+ { root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) },
780
+ );
781
+
782
+ // Google returns a refresh_token only for an OFFLINE request, and then only on the FIRST
783
+ // authorization for this client+user or when the app forced the screen with prompt=consent.
784
+ // Integrations that assume every exchange yields a refresh_token are a well-known class of
785
+ // production bug; the twin reproduces the rule so that bug is reproducible.
786
+ const promptValues = typeof row.prompt === 'string' ? row.prompt.split(/\s+/) : [];
787
+ const withRefreshToken = row.accessType === 'offline' && (row.isFirstGrant === true || promptValues.includes('consent'));
788
+
789
+ return issueTokens(req, {
790
+ client,
791
+ sub: String(row.sub),
792
+ scope: String(row.scope),
793
+ nonce: typeof row.nonce === 'string' ? row.nonce : null,
794
+ withRefreshToken,
795
+ subject: code,
796
+ accessType: row.accessType === 'offline' ? 'offline' : 'online',
797
+ });
798
+ }
799
+
800
+ async function refreshTokenGrant(req: GoogleOAuthRequest, form: URLSearchParams): Promise<GoogleOAuthResponse> {
801
+ const root = req.root;
802
+ const auth = authenticateClient(form, req.headers, root);
803
+ if ('fail' in auth) return auth.fail;
804
+ const { client } = auth;
805
+
806
+ const refreshToken = form.get('refresh_token');
807
+ if (!refreshToken) return oauthError('invalid_request', 'Missing required parameter: refresh_token', 400);
808
+ const row = readOne(root, 'refresh_token', refreshToken);
809
+ if (!row || row.revoked === true || row.clientId !== client.id) {
810
+ return oauthError('invalid_grant', 'Token has been expired or revoked.', 400);
811
+ }
812
+
813
+ // A refresh may NARROW the scope but never widen it (RFC 6749 §6).
814
+ let scope = String(row.scope);
815
+ const requested = parseScopeParam(form.get('scope'));
816
+ if (requested.length) {
817
+ const held = parseScopeParam(scope);
818
+ if (!requested.every((s) => held.includes(s))) {
819
+ return oauthError('invalid_scope', 'Bad Request', 400);
820
+ }
821
+ scope = formatScopeParam(requested);
822
+ }
823
+
824
+ // Google does NOT rotate the refresh token on use — the same one keeps working. The new access
825
+ // token still belongs to an OFFLINE grant, though (a refresh token only exists for one), so it
826
+ // must keep reporting `access_type: offline` at /tokeninfo.
827
+ return issueTokens(req, { client, sub: String(row.sub), scope, nonce: null, withRefreshToken: false, subject: String(row.id), accessType: 'offline' });
828
+ }
829
+
830
+ /**
831
+ * The SERVICE-ACCOUNT (two-legged) grant — `urn:ietf:params:oauth:grant-type:jwt-bearer`.
832
+ *
833
+ * This is the surface the pack-less `googleauth` injector key has been pointing at gemini-twin.ts
834
+ * for: `google-auth-library` signs a JWT locally and posts it here before ANY Vertex/GCS call, so a
835
+ * sealed world that does not model it dies in auth before reaching a modelled API. It is
836
+ * reproduced here — with REAL RS256 signing rather than gemini's `alg: none` stub — so that ONE
837
+ * token endpoint answers ALL of Google's grants, exactly as the vendor's does. See the README
838
+ * §"Why this pack owns oauth2.googleapis.com".
839
+ *
840
+ * The assertion's payload is DECODED, never verified: the twin authenticates nothing and holds no
841
+ * service-account key. It is read only to answer in the flow the caller used — a `target_audience`
842
+ * claim marks the ID-token flow (`getIdTokenClient`), anything else the access-token flow.
843
+ */
844
+ async function jwtBearerGrant(req: GoogleOAuthRequest, form: URLSearchParams): Promise<GoogleOAuthResponse> {
845
+ const assertion = form.get('assertion');
846
+ if (!assertion) return oauthError('invalid_request', 'Missing required parameter: assertion', 400);
847
+ let payload: { target_audience?: unknown; iss?: unknown; scope?: unknown } = {};
848
+ try {
849
+ payload = JSON.parse(Buffer.from(assertion.split('.')[1] ?? '', 'base64url').toString('utf8'));
850
+ } catch {
851
+ // Captured live: a malformed jwt-bearer assertion is `invalid_request` with the description
852
+ // literally "Bad Request" — NOT invalid_grant. (An assertion naming an unknown service account
853
+ // is the one that answers `invalid_grant: Invalid grant: account not found`.)
854
+ return oauthError('invalid_request', 'Bad Request', 400);
855
+ }
856
+ const issuedAt = nowSeconds(req.occurredAt);
857
+ const email = typeof payload.iss === 'string' && payload.iss ? payload.iss : 'twin-service-account';
858
+ // Google's id_token `sub` is the service account's NUMERIC unique id, not its address — the
859
+ // address travels in `email`. The twin has no directory to look one up in, so it derives a stable
860
+ // 21-digit id from the address: vendor-SHAPED, deterministic, and never mistaken for the email
861
+ // (§9 round one caught the two being conflated).
862
+ const sub = serviceAccountSubject(email);
863
+ if (payload.target_audience) {
864
+ const idToken = await signJwt(
865
+ { iss: ISSUER, aud: String(payload.target_audience), azp: sub, sub, email, email_verified: true },
866
+ { root: req.root, expiresInSeconds: ACCESS_TOKEN_TTL_SECONDS + 1, now: issuedAt },
867
+ );
868
+ return { status: 200, body: { id_token: idToken }, headers: { ...NOSTORE } };
869
+ }
870
+ // The access token is PERSISTED, so it is a usable credential the way a real service-account
871
+ // token is. An earlier version minted a bare string and stored nothing, so the token it handed
872
+ // back was refused by the twin's own /tokeninfo the moment anyone tried to use it
873
+ // (§9 round one). The service account is not a consenting user, so no `account` row is written
874
+ // and no `grant` exists — /v1/userinfo correctly refuses it.
875
+ const accessToken = mintAccessToken(req.root, instantOf(req.occurredAt), sub);
876
+ const scope = typeof payload.scope === 'string' ? payload.scope : '';
877
+ await write(
878
+ 'access_token',
879
+ accessToken,
880
+ 'access_token.create',
881
+ { clientId: sub, sub, scope, accessType: 'online', serviceAccountEmail: email, expiresAt: issuedAt + ACCESS_TOKEN_TTL_SECONDS, revoked: false, rev: 1 },
882
+ { root: req.root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) },
883
+ );
884
+ return {
885
+ status: 200,
886
+ body: {
887
+ access_token: accessToken,
888
+ expires_in: ACCESS_TOKEN_TTL_SECONDS,
889
+ token_type: 'Bearer',
890
+ ...(scope ? { scope } : {}),
891
+ },
892
+ headers: { ...NOSTORE },
893
+ };
894
+ }
895
+
896
+ // ── revoke / tokeninfo / userinfo ───────────────────────────────────────────────────────────────
897
+
898
+ async function revoke(req: GoogleOAuthRequest, token_: string | null): Promise<GoogleOAuthResponse> {
899
+ const root = req.root;
900
+ const opts = { root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) };
901
+ if (!token_) return oauthError('invalid_request', 'Missing required parameter: token', 400);
902
+ const access = readOne(root, 'access_token', token_);
903
+ const refresh = readOne(root, 'refresh_token', token_);
904
+ if (!access && !refresh) return oauthError('invalid_token', 'Token expired or revoked', 400);
905
+
906
+ // Revoking EITHER half of a grant kills BOTH — Google revokes the whole grant, and an
907
+ // integration that assumes its access token survives a refresh-token revocation is broken.
908
+ const sub = String((access ?? refresh)!.sub);
909
+ const clientId = String((access ?? refresh)!.clientId);
910
+ for (const row of readType(root, 'access_token')) {
911
+ if (row.sub === sub && row.clientId === clientId && row.revoked !== true) {
912
+ await write('access_token', row.id, 'access_token.revoke', { revoked: true, rev: nextRev(root, 'access_token', row.id) }, opts);
913
+ }
914
+ }
915
+ for (const row of readType(root, 'refresh_token')) {
916
+ if (row.sub === sub && row.clientId === clientId && row.revoked !== true) {
917
+ await write('refresh_token', row.id, 'refresh_token.revoke', { revoked: true, rev: nextRev(root, 'refresh_token', row.id) }, opts);
918
+ }
919
+ }
920
+ // …and the GRANT itself goes with them. Google's revocation "removes all scopes granted to the
921
+ // project", which is why a later re-consent behaves as a FIRST authorization and hands back a
922
+ // refresh token. Leaving the row's scopes in place made the twin withhold one (§9 round one).
923
+ const grantId = `${clientId}:${sub}`;
924
+ if (readOne(root, 'grant', grantId)) {
925
+ await write('grant', grantId, 'grant.revoke', { clientId, sub, scopes: [], rev: nextRev(root, 'grant', grantId) }, opts);
926
+ }
927
+ // Google answers a successful revocation with 200 and an EMPTY body.
928
+ return { status: 200, body: {}, headers: { ...NOSTORE } };
929
+ }
930
+
931
+ function tokeninfo(req: GoogleOAuthRequest, query: URLSearchParams): GoogleOAuthResponse {
932
+ const root = req.root;
933
+ const accessToken = query.get('access_token');
934
+ const idToken = query.get('id_token');
935
+ if (!accessToken && !idToken) return oauthError('invalid_request', 'Invalid Value', 400);
936
+ if (idToken) {
937
+ // The id_token branch is a filed todo (`googleoauth.tokeninfo.id_token`) rather than a fake:
938
+ // answering it needs the token DECODED and its claims re-emitted, which the twin can do, but
939
+ // the vendor's exact field set for this branch has not been grounded.
940
+ return oauthError('invalid_token', 'Invalid Value', 400);
941
+ }
942
+ const row = readOne(root, 'access_token', accessToken!);
943
+ if (!row || row.revoked === true) return oauthError('invalid_token', 'Invalid Value', 400);
944
+ const now = nowSeconds(req.occurredAt);
945
+ if (typeof row.expiresAt === 'number' && now >= row.expiresAt) return oauthError('invalid_token', 'Invalid Value', 400);
946
+ const account = readOne(root, 'account', String(row.sub));
947
+ const scopes = parseScopeParam(String(row.scope));
948
+ const wantsEmail = scopes.includes('email') || scopes.includes('https://www.googleapis.com/auth/userinfo.email');
949
+ return {
950
+ status: 200,
951
+ body: {
952
+ azp: row.clientId,
953
+ aud: row.clientId,
954
+ sub: row.sub,
955
+ scope: row.scope,
956
+ exp: String(row.expiresAt),
957
+ expires_in: String(Math.max(0, Number(row.expiresAt) - now)),
958
+ access_type: row.accessType ?? 'online',
959
+ ...(wantsEmail && account ? { email: account.email, email_verified: String(account.emailVerified === true) } : {}),
960
+ },
961
+ headers: { ...NOSTORE },
962
+ };
963
+ }
964
+
965
+ /**
966
+ * The userinfo endpoints — and a fidelity trap worth spelling out: THE TWO HOSTS DISAGREE ON THEIR
967
+ * ERROR BODY. Captured live on 2026-08-20 with a bad credential:
968
+ *
969
+ * openidconnect.googleapis.com/v1/userinfo → 401 {"error":"invalid_request",
970
+ * "error_description":"Invalid Credentials"}
971
+ * www.googleapis.com/oauth2/v3/userinfo → 401 {"error":"invalid_token",
972
+ * "error_description":"Invalid Value"}
973
+ *
974
+ * Neither is the `{ error: { code, message, status } }` Google-API envelope a reader would predict
975
+ * from the rest of googleapis.com. A twin that normalised the two into one shape would be hiding a
976
+ * real difference an integration can trip over.
977
+ */
978
+ function userinfoUnauthorized(path: string): GoogleOAuthResponse {
979
+ return path === '/v1/userinfo'
980
+ ? oauthError('invalid_request', 'Invalid Credentials', 401)
981
+ : oauthError('invalid_token', 'Invalid Value', 401);
982
+ }
983
+
984
+ function userinfo(req: GoogleOAuthRequest, path: string, query: URLSearchParams): GoogleOAuthResponse {
985
+ const root = req.root;
986
+ const header = req.headers?.authorization ?? '';
987
+ const bearer = /^bearer\s+(.+)$/i.exec(header)?.[1]?.trim() ?? query.get('access_token');
988
+ if (!bearer) return userinfoUnauthorized(path);
989
+ const row = readOne(root, 'access_token', bearer);
990
+ if (!row || row.revoked === true) return userinfoUnauthorized(path);
991
+ if (typeof row.expiresAt === 'number' && nowSeconds(req.occurredAt) >= row.expiresAt) return userinfoUnauthorized(path);
992
+ const scopes = parseScopeParam(String(row.scope));
993
+ const hasIdentityScope = scopes.some((s) =>
994
+ s === 'openid' || s === 'email' || s === 'profile'
995
+ || s === 'https://www.googleapis.com/auth/userinfo.email'
996
+ || s === 'https://www.googleapis.com/auth/userinfo.profile');
997
+ if (!hasIdentityScope) {
998
+ // A token with no identity scope has no right to a profile, so the twin REFUSES rather than
999
+ // serving one — never a fake success. Google's EXACT answer for this case was not captured, so
1000
+ // the twin reuses the credential-rejection envelope above rather than inventing a third shape;
1001
+ // `googleoauth.userinfo.insufficient_scope_shape` is the filed todo for pinning it down.
1002
+ return userinfoUnauthorized(path);
1003
+ }
1004
+ const account = readOne(root, 'account', String(row.sub));
1005
+ if (!account) return apiError(404, 'Requested entity was not found.', 'NOT_FOUND');
1006
+ return { status: 200, body: { sub: account.id, ...profileClaims(account, scopes) }, headers: { ...NOSTORE } };
1007
+ }
1008
+
1009
+ /**
1010
+ * The OIDC discovery document. Every URL is rendered against `origin` when the caller supplied one,
1011
+ * so a DISCOVERY-DRIVEN client (openid-client) that reads this document stays inside the twin
1012
+ * instead of walking back out to the real Google — the single most important property of this
1013
+ * endpoint for a sealed world.
1014
+ */
1015
+ export function discoveryDocument(origin?: string): Record<string, unknown> {
1016
+ const accounts = origin ?? ACCOUNTS_ORIGIN;
1017
+ const oauth2 = origin ?? OAUTH2_ORIGIN;
1018
+ const apis = origin ?? APIS_ORIGIN;
1019
+ const oidc = origin ?? OIDC_ORIGIN;
1020
+ return {
1021
+ issuer: ISSUER,
1022
+ authorization_endpoint: `${accounts}/o/oauth2/v2/auth`,
1023
+ device_authorization_endpoint: `${oauth2}/device/code`,
1024
+ token_endpoint: `${oauth2}/token`,
1025
+ userinfo_endpoint: `${oidc}/v1/userinfo`,
1026
+ revocation_endpoint: `${oauth2}/revoke`,
1027
+ jwks_uri: `${apis}/oauth2/v3/certs`,
1028
+ response_types_supported: ['code', 'token', 'id_token', 'code token', 'code id_token', 'token id_token', 'code token id_token', 'none'],
1029
+ subject_types_supported: ['public'],
1030
+ id_token_signing_alg_values_supported: ['RS256'],
1031
+ scopes_supported: ['openid', 'email', 'profile'],
1032
+ token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
1033
+ claims_supported: ['aud', 'email', 'email_verified', 'exp', 'family_name', 'given_name', 'iat', 'iss', 'name', 'picture', 'sub'],
1034
+ code_challenge_methods_supported: ['plain', 'S256'],
1035
+ grant_types_supported: ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:device_code', 'urn:ietf:params:oauth:grant-type:jwt-bearer'],
1036
+ };
1037
+ }
1038
+
1039
+ /** Every VENDOR endpoint this twin claims to serve. The conformance check drives one real request
1040
+ * per entry and grades the OUTCOME, so an entry here is a promise with teeth. Twin-only routes
1041
+ * (`/_twin/*`) are deliberately absent — they are scaffolding, not vendor surface. */
1042
+ export function googleOAuthTwinSnapshot(): { implementedEndpoints: string[]; resourceTypes: readonly string[]; grantTypes: string[] } {
1043
+ return {
1044
+ implementedEndpoints: [
1045
+ 'GET /.well-known/openid-configuration',
1046
+ 'GET /oauth2/v3/certs',
1047
+ 'GET /oauth2/v1/certs',
1048
+ 'GET /o/oauth2/v2/auth',
1049
+ 'GET /o/oauth2/auth',
1050
+ 'GET /o/oauth2/v2/auth/oauthchooseaccount',
1051
+ 'GET /signin/oauth/error',
1052
+ 'POST /token',
1053
+ 'POST /oauth2/v4/token',
1054
+ 'POST /o/oauth2/token',
1055
+ 'POST /revoke',
1056
+ 'GET /revoke',
1057
+ 'GET /tokeninfo',
1058
+ 'GET /v1/userinfo',
1059
+ 'POST /v1/userinfo',
1060
+ 'GET /oauth2/v3/userinfo',
1061
+ 'POST /oauth2/v3/userinfo',
1062
+ ],
1063
+ resourceTypes: STORE_RESOURCE_TYPES,
1064
+ grantTypes: ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:jwt-bearer'],
1065
+ };
1066
+ }
1067
+
1068
+ // ── twin-only control routes (NOT vendor surface — see the manifest) ────────────────────────────
1069
+
1070
+ async function twinControl(req: GoogleOAuthRequest, path: string, form: URLSearchParams): Promise<GoogleOAuthResponse | null> {
1071
+ const root = req.root;
1072
+ const opts = { root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) };
1073
+ const json = () => {
1074
+ try {
1075
+ return JSON.parse(req.body ?? '{}') as Record<string, unknown>;
1076
+ } catch {
1077
+ return null;
1078
+ }
1079
+ };
1080
+ // STEP 2 of the screen: the account chooser's GET submit lands here with the chosen `sub`.
1081
+ if (req.method === 'GET' && path === '/_twin/consent') {
1082
+ const { query } = splitPath(req.path);
1083
+ return renderConsent(req, query.get('auth_request') ?? '', query.get('sub'));
1084
+ }
1085
+ if (req.method === 'POST' && path === '/_twin/consent') return consentDecision(req, form);
1086
+ if (req.method === 'POST' && path === '/_twin/clients') {
1087
+ const b = json();
1088
+ if (!b || typeof b.client_id !== 'string' || !b.client_id) return oauthError('invalid_request', 'client_id is required', 400);
1089
+ const row = await write(
1090
+ 'oauth_client',
1091
+ b.client_id,
1092
+ 'oauth_client.create',
1093
+ {
1094
+ name: typeof b.name === 'string' ? b.name : 'Twin App',
1095
+ secret: typeof b.client_secret === 'string' ? b.client_secret : `GOCSPX-${b.client_id.slice(0, 20)}`,
1096
+ redirectUris: Array.isArray(b.redirect_uris) ? b.redirect_uris : [],
1097
+ clientType: b.client_type === 'installed' ? 'installed' : 'web',
1098
+ supportEmail: typeof b.support_email === 'string' ? b.support_email : 'support@twin.example',
1099
+ verified: b.verified === true,
1100
+ rev: nextRev(root, 'oauth_client', b.client_id),
1101
+ },
1102
+ opts,
1103
+ );
1104
+ return { status: 200, body: row };
1105
+ }
1106
+ if (req.method === 'POST' && path === '/_twin/accounts') {
1107
+ const b = json();
1108
+ if (!b || typeof b.sub !== 'string' || !b.sub) return oauthError('invalid_request', 'sub is required', 400);
1109
+ const row = await write(
1110
+ 'account',
1111
+ b.sub,
1112
+ 'account.create',
1113
+ {
1114
+ email: typeof b.email === 'string' ? b.email : `${b.sub}@twin.example`,
1115
+ emailVerified: b.email_verified !== false,
1116
+ name: typeof b.name === 'string' ? b.name : 'Twin Persona',
1117
+ givenName: typeof b.given_name === 'string' ? b.given_name : 'Twin',
1118
+ familyName: typeof b.family_name === 'string' ? b.family_name : 'Persona',
1119
+ picture: typeof b.picture === 'string' ? b.picture : `https://lh3.googleusercontent.com/a/${b.sub}`,
1120
+ ...(typeof b.hd === 'string' ? { hd: b.hd } : {}),
1121
+ rev: nextRev(root, 'account', b.sub),
1122
+ },
1123
+ opts,
1124
+ );
1125
+ return { status: 200, body: row };
1126
+ }
1127
+ return null;
1128
+ }
1129
+
1130
+ // ── public entry + router ───────────────────────────────────────────────────────────────────────
1131
+
1132
+ export async function handleGoogleOAuthTwinRequest(req: GoogleOAuthRequest): Promise<GoogleOAuthResponse> {
1133
+ try {
1134
+ return await routeGoogleOAuthTwinRequest(req);
1135
+ } catch (e) {
1136
+ // MALFORMED-REQUEST GUARD (route boundary), the gemini precedent: a residual TypeError (a
1137
+ // wrong-typed field the router walked) or URIError (bad percent-encoding in the query) becomes
1138
+ // the vendor's own 400 envelope instead of escaping as a non-vendor HTML 500. Every OTHER error
1139
+ // type still propagates loudly rather than being masked as a caller mistake.
1140
+ if (e instanceof TypeError || e instanceof URIError) {
1141
+ return oauthError('invalid_request', 'Request contains an invalid argument.', 400);
1142
+ }
1143
+ throw e;
1144
+ }
1145
+ }
1146
+
1147
+ async function routeGoogleOAuthTwinRequest(req: GoogleOAuthRequest): Promise<GoogleOAuthResponse> {
1148
+ const method = req.method.toUpperCase();
1149
+ const { path, query } = splitPath(req.path);
1150
+ const form = new URLSearchParams(method === 'GET' || method === 'HEAD' ? '' : (req.body ?? ''));
1151
+
1152
+ // ── stateless reads, safe on a read-only twin ──
1153
+ if (method === 'GET' && path === '/.well-known/openid-configuration') {
1154
+ return { status: 200, body: discoveryDocument(req.origin) };
1155
+ }
1156
+ // v1 (PEM) and v3 (JWK) ONLY. An earlier version also answered `/oauth2/v2/certs`, which Google
1157
+ // does not publish — invented surface, removed (§9 round one).
1158
+ if (method === 'GET' && path === '/oauth2/v3/certs') {
1159
+ return { status: 200, body: await buildJwks(req.root) };
1160
+ }
1161
+ if (method === 'GET' && path === '/oauth2/v1/certs') {
1162
+ return { status: 200, body: await buildLegacyPemCerts(req.root) };
1163
+ }
1164
+ // The error page every authorization failure is bounced to. A pure read — safe read-only.
1165
+ if (method === 'GET' && path === ERROR_PAGE_PATH) return signinOAuthErrorPage(req, query);
1166
+
1167
+ // D3: a read-only twin cannot mint credentials, so every leg of the flow is refused. This is a
1168
+ // TWIN-level refusal (Google has no read-only mode), deliberately shaped as an OAuth error body
1169
+ // so a client sees a protocol error rather than an HTML surprise.
1170
+ const writes = AUTH_PATHS.has(path) || path.startsWith('/_twin/') || path === '/token'
1171
+ || path === '/oauth2/v4/token' || path === '/o/oauth2/token' || path === '/revoke';
1172
+ if (req.readOnly && writes) {
1173
+ return oauthError('temporarily_unavailable', 'this twin was started read-only; omit readOnly to accept writes', 405);
1174
+ }
1175
+
1176
+ // Seeding is idempotent and cheap; doing it at the router boundary means every entry point sees a
1177
+ // world with a client and personas in it, exactly as a browser signed in to Google would.
1178
+ if (!req.readOnly) await ensureSeed({ root: req.root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), ...((req.callbackOrigin ?? req.origin) ? { origin: req.callbackOrigin ?? req.origin } : {}) });
1179
+
1180
+ const control = await twinControl(req, path, form);
1181
+ if (control) return control;
1182
+
1183
+ if (method === 'GET' && AUTH_PATHS.has(path)) return authorize(req, query);
1184
+ if (method === 'POST' && (path === '/token' || path === '/oauth2/v4/token' || path === '/o/oauth2/token')) return token(req);
1185
+ if (path === '/revoke' && (method === 'POST' || method === 'GET')) {
1186
+ // The token may arrive in the FORM BODY or in the QUERY STRING, on either method.
1187
+ // `google-auth-library`'s own `revokeToken()` issues `POST /revoke?token=…` with an EMPTY body
1188
+ // (oauth2client.js) — a body-only reading of this endpoint refuses the vendor's own client,
1189
+ // which is exactly the bug the SDK fidelity test caught.
1190
+ return revoke(req, form.get('token') ?? query.get('token'));
1191
+ }
1192
+ if (method === 'GET' && path === '/tokeninfo') return tokeninfo(req, query);
1193
+ // `/oauth2/v2/userinfo` is NOT served: it is Google's oldest alias and returns a DIFFERENT field
1194
+ // set, which this twin does not model (`googleoauth.endpoints.userinfo_v2`, todo). Answering it
1195
+ // with the v3 shape would be a fake success on a route the pack itself says is unmodelled
1196
+ // (§9 round one).
1197
+ if ((method === 'GET' || method === 'POST') && (path === '/v1/userinfo' || path === '/oauth2/v3/userinfo')) {
1198
+ return userinfo(req, path, query);
1199
+ }
1200
+
1201
+ // An operation the twin does not model fails like the vendor — never a fake success. Google's own
1202
+ // 404 on these hosts is the API error envelope.
1203
+ if (AUTH_PATHS.has(path) || path === '/token' || path === '/revoke' || path === '/tokeninfo') {
1204
+ return apiError(405, `Method ${method} is not supported on ${path}.`, 'FAILED_PRECONDITION');
1205
+ }
1206
+ return apiError(404, `The requested URL ${path} was not found on this server.`, 'NOT_FOUND');
1207
+ }