@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,1775 @@
1
+ // Google OAuth 2.0 / OpenID Connect capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the
2
+ // target), authored top-down from the vendor's own OIDC discovery document
3
+ // (accounts.google.com/.well-known/openid-configuration) and the four protocol guides
4
+ // (developers.google.com/identity/protocols/oauth2/{web-server,native-app,openid-connect,
5
+ // service-account}) — NOT from what this twin has built. `verify()` (required to count as done) is
6
+ // ground truth; every `expected:'done'` is genuinely claimed, so a broken one shows as a regression.
7
+ //
8
+ // GRANULARITY: one capability per PROTOCOL BEHAVIOUR (a parameter honoured, an error produced, a
9
+ // claim minted), not per endpoint — this surface is nine endpoints with a hundred behaviours, so
10
+ // per-endpoint entries would have been a denominator of nine and a lie.
11
+ //
12
+ // TWIN-ONLY ROUTES ARE NOT COUNTED. `/_twin/consent`, `/_twin/clients` and `/_twin/accounts` are
13
+ // scaffolding (Google's own consent form posts to an undocumented internal endpoint, and there is
14
+ // no API that creates an OAuth client), so they are absent from this denominator exactly as
15
+ // ADDING_A_TWIN.md §6 requires. Counting them would be padding.
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { createElement } from 'react';
20
+ import { renderToStaticMarkup } from 'react-dom/server';
21
+ import { checkCapabilities, uiDataCoupled, verifyBoundary, type CapabilityReport, type CapabilitySpec } from '@volter/world-tooling';
22
+ import { AccountChooser, ConsentScreen, ErrorPage, ScopeList, type ConsentView } from '../client/googleoauth-consent.tsx';
23
+ import { CONSENT_CLIENT_JS, errorPageHtml, googleOAuthConsentState } from './googleoauth-consent-ui.ts';
24
+ import { checkGoogleOAuthConformance } from './googleoauth-conformance.ts';
25
+ import {
26
+ GOOGLEOAUTH_BUDGET_CEILING,
27
+ GOOGLEOAUTH_CALL_WEIGHTS,
28
+ GoogleOAuthBudget,
29
+ GoogleOAuthBudgetError,
30
+ } from './googleoauth-budget.ts';
31
+ import {
32
+ liveGoogleOAuthExecute,
33
+ mapTokenInfoClient,
34
+ pullGoogleOAuthIdentity,
35
+ syncGoogleOAuthFromReal,
36
+ type GoogleOAuthExecute,
37
+ } from './googleoauth-connector.ts';
38
+ import { decodeAuthError, ERROR_PAGE_PATH, FLOW_NAME } from './googleoauth-autherror.ts';
39
+ import { pkceS256, verifyJwtWithJwks, decodeJwt, type Jwks } from './googleoauth-jwt.ts';
40
+ import { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, defaultRedirectUris } from './googleoauth-store.ts';
41
+ import { handleGoogleOAuthTwinRequest, type GoogleOAuthResponse } from './googleoauth-twin.ts';
42
+
43
+ type Step = { m: string; p: string; b?: string; h?: Record<string, string>; at?: string };
44
+ type Body = Record<string, any>;
45
+ /** The per-verify handler, carrying the temp `root` it is bound to. Threading the root ON the
46
+ * handler is what lets a verify call a projection-level helper (`googleOAuthConsentState`) against
47
+ * the SAME isolated root — an omitted root silently falls back to the operator's real ~/.volter
48
+ * state dir, which is gitignored, so nothing would catch the leak. */
49
+ type H = ((s: Step) => Promise<GoogleOAuthResponse>) & { root: string };
50
+
51
+ const AT = '2026-02-01T00:00:00.000Z';
52
+ const AT_SECONDS = Math.floor(Date.parse(AT) / 1000);
53
+ /** The origin this harness's world serves the twin at. The seeded demo client's callbacks are
54
+ * DERIVED from it (runtime contract R7: the port belongs to the caller's world, never to the
55
+ * twin's source), so the probes below name no port of their own. */
56
+ const DEMO_ORIGIN = 'http://localhost:3000';
57
+ const REDIRECT_URIS = defaultRedirectUris(DEMO_ORIGIN);
58
+ const REDIRECT = REDIRECT_URIS[0]!;
59
+ const ADA = DEFAULT_ACCOUNTS[0]!;
60
+ const GRACE = DEFAULT_ACCOUNTS[1]!;
61
+
62
+ async function withRoot(steps: (h: H) => Promise<boolean>): Promise<boolean> {
63
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-cap-'));
64
+ const h = ((s: Step) =>
65
+ handleGoogleOAuthTwinRequest({
66
+ method: s.m,
67
+ path: s.p,
68
+ ...(s.b === undefined ? {} : { body: s.b }),
69
+ ...(s.h ? { headers: s.h } : {}),
70
+ root,
71
+ origin: DEMO_ORIGIN,
72
+ occurredAt: s.at ?? AT,
73
+ })) as H;
74
+ h.root = root;
75
+ try {
76
+ return await verifyBoundary('googleoauth.withRoot', () => steps(h));
77
+ } finally {
78
+ rmSync(root, { recursive: true, force: true });
79
+ }
80
+ }
81
+
82
+ const ok = (r: GoogleOAuthResponse) => r.status >= 200 && r.status < 300;
83
+ /** Rendered markup as a human reads it. React escapes `'` to `&#x27;`, so an assertion about
84
+ * Google's own wording ("This app's request is invalid") must compare against the DECODED text or
85
+ * it fails for a reason that has nothing to do with the twin. */
86
+ const text = (markup: string) =>
87
+ markup.replace(/&#x27;/g, "'").replace(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
88
+ const body = (r: GoogleOAuthResponse) => r.body as Body;
89
+ const html = (r: GoogleOAuthResponse) => String(r.body);
90
+ const err = (r: GoogleOAuthResponse) => (r.body as { error?: unknown } | null)?.error;
91
+ const loc = (r: GoogleOAuthResponse) => r.headers?.location ?? '';
92
+ const qp = (r: GoogleOAuthResponse, key: string) => {
93
+ try {
94
+ return new URL(loc(r)).searchParams.get(key);
95
+ } catch {
96
+ return null;
97
+ }
98
+ };
99
+ const form = (params: Record<string, string>) => new URLSearchParams(params).toString();
100
+
101
+ /** Build an authorization-endpoint URL from parameters, so a verify can vary exactly one of them. */
102
+ function authUrl(params: Record<string, string | undefined>, path = '/o/oauth2/v2/auth'): string {
103
+ const q = new URLSearchParams();
104
+ const base: Record<string, string | undefined> = {
105
+ client_id: DEFAULT_CLIENT_ID,
106
+ redirect_uri: REDIRECT,
107
+ response_type: 'code',
108
+ scope: 'openid email profile',
109
+ ...params,
110
+ };
111
+ for (const [k, v] of Object.entries(base)) if (v !== undefined) q.set(k, v);
112
+ return `${path}?${q.toString()}`;
113
+ }
114
+
115
+ const AUTH_REQUEST_RE = /name="auth_request" value="([^"]+)"/;
116
+
117
+ /**
118
+ * Drive an authorization request that is expected to FAIL, and follow Google's real failure path:
119
+ * a 302 to `/signin/oauth/error?authError=<protobuf>` and then the rendered page. Returns the
120
+ * decoded payload AND the markup, so a verify can assert both the machine-readable error and what
121
+ * the human is shown.
122
+ */
123
+ async function errorPageFor(h: H, path: string): Promise<{ redirect: GoogleOAuthResponse; error: ReturnType<typeof decodeAuthError>; markup: string }> {
124
+ const redirect = await h({ m: 'GET', p: path });
125
+ if (redirect.status !== 302) return { redirect, error: null, markup: '' };
126
+ const target = new URL(loc(redirect));
127
+ const error = decodeAuthError(target.searchParams.get('authError') ?? '');
128
+ const page = await h({ m: 'GET', p: `${target.pathname}${target.search}` });
129
+ return { redirect, error, markup: html(page) };
130
+ }
131
+
132
+ type FlowOpts = {
133
+ params?: Record<string, string | undefined>;
134
+ sub?: string;
135
+ decision?: 'allow' | 'deny';
136
+ /** Which scope checkboxes are TICKED. Omitted ⇒ all of them, which is what the served form does
137
+ * (every declinable checkbox ships `defaultChecked`, so a browser posts them all). Supply this
138
+ * only when the point of the verify is a user UNTICKING something. */
139
+ tick?: string[];
140
+ at?: string;
141
+ };
142
+
143
+ /**
144
+ * Drive the REAL browser legs: authorization request → consent screen → Allow (or Deny) → the 302.
145
+ * Everything a verify needs from the round trip comes back, so no verify has to hand-craft a code.
146
+ */
147
+ async function consentFlow(
148
+ h: H,
149
+ opts: FlowOpts = {},
150
+ ): Promise<{ auth: GoogleOAuthResponse; requestId: string; redirect: GoogleOAuthResponse; code: string | null; state: string | null }> {
151
+ const authPath = authUrl(opts.params ?? {});
152
+ const auth = await h({ m: 'GET', p: authPath, ...(opts.at ? { at: opts.at } : {}) });
153
+ const requestId = AUTH_REQUEST_RE.exec(html(auth))?.[1] ?? '';
154
+ const fields = new URLSearchParams();
155
+ fields.set('auth_request', requestId);
156
+ fields.set('sub', opts.sub ?? ADA.sub);
157
+ fields.set('decision', opts.decision ?? 'allow');
158
+ const ticked = opts.tick ?? (new URL(authPath, 'http://x.test').searchParams.get('scope') ?? '').split(/\s+/).filter(Boolean);
159
+ for (const s of ticked) fields.append('scope', s);
160
+ const redirect = await h({ m: 'POST', p: '/_twin/consent', b: fields.toString(), ...(opts.at ? { at: opts.at } : {}) });
161
+ return { auth, requestId, redirect, code: qp(redirect, 'code'), state: qp(redirect, 'state') };
162
+ }
163
+
164
+ /** The full round trip, ending in redeemed tokens. */
165
+ async function fullFlow(
166
+ h: H,
167
+ opts: FlowOpts & { verifier?: string } = {},
168
+ ): Promise<{ code: string | null; tokens: Body; status: number; redirect: GoogleOAuthResponse }> {
169
+ const flow = await consentFlow(h, opts);
170
+ if (!flow.code) return { code: null, tokens: {}, status: 0, redirect: flow.redirect };
171
+ const res = await h({
172
+ m: 'POST',
173
+ p: '/token',
174
+ b: form({
175
+ grant_type: 'authorization_code',
176
+ code: flow.code,
177
+ client_id: DEFAULT_CLIENT_ID,
178
+ client_secret: DEFAULT_CLIENT_SECRET,
179
+ redirect_uri: REDIRECT,
180
+ ...(opts.verifier ? { code_verifier: opts.verifier } : {}),
181
+ }),
182
+ ...(opts.at ? { at: opts.at } : {}),
183
+ });
184
+ return { code: flow.code, tokens: body(res), status: res.status, redirect: flow.redirect };
185
+ }
186
+
187
+ /** A grant with an offline refresh token — the fixture the refresh/revoke verifies start from. */
188
+ const offline = { access_type: 'offline', prompt: 'consent' } as const;
189
+
190
+ // ── shorthands ──
191
+ const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'done', verify });
192
+ const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo' });
193
+
194
+ // ── the connector's offline fake: a REAL-SHAPED Google reply, not an invented one ──
195
+ const FAKE_TOKENINFO = {
196
+ azp: '1044839207-realclient.apps.googleusercontent.com',
197
+ aud: '1044839207-realclient.apps.googleusercontent.com',
198
+ sub: '109876543210987654321',
199
+ scope: 'openid https://www.googleapis.com/auth/userinfo.email',
200
+ exp: '1800000000',
201
+ expires_in: '3599',
202
+ email: 'real.person@example.com',
203
+ email_verified: 'true',
204
+ access_type: 'offline',
205
+ };
206
+ const FAKE_USERINFO = {
207
+ sub: '109876543210987654321',
208
+ email: 'real.person@example.com',
209
+ email_verified: true,
210
+ name: 'Real Person',
211
+ given_name: 'Real',
212
+ family_name: 'Person',
213
+ picture: 'https://lh3.googleusercontent.com/a/real',
214
+ };
215
+ const fakeExecute: GoogleOAuthExecute = async (_method, path) => {
216
+ if (path.startsWith('/tokeninfo')) return { ...FAKE_TOKENINFO };
217
+ if (path === '/v1/userinfo') return { ...FAKE_USERINFO };
218
+ return {};
219
+ };
220
+
221
+ /** A `fetch` stand-in that COUNTS calls — "it threw" is not proof; the unchanged count is. */
222
+ function countingFetch(response: () => Response): { impl: typeof fetch; calls: () => number } {
223
+ let calls = 0;
224
+ const impl = (async () => {
225
+ calls += 1;
226
+ return response();
227
+ }) as unknown as typeof fetch;
228
+ return { impl, calls: () => calls };
229
+ }
230
+ const jsonResponse = (payload: unknown, init: ResponseInit = {}) =>
231
+ new Response(JSON.stringify(payload), { status: 200, headers: { 'content-type': 'application/json' }, ...init });
232
+
233
+ export const GOOGLEOAUTH_CAPABILITIES: CapabilitySpec[] = [
234
+ // ── Discovery ────────────────────────────────────────────────────────────────────────────────
235
+ done('googleoauth.discovery.document', 'discovery', 'GET /.well-known/openid-configuration — the OIDC discovery document', 'api', 'core', () =>
236
+ withRoot(async () => {
237
+ // Not routed through `h` for the same reason `origin_rewrite` below is not: this probe is
238
+ // about the ABSENT-`origin` rendering, where the document carries Google's REAL endpoints.
239
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-disc-doc-'));
240
+ try {
241
+ const r = await handleGoogleOAuthTwinRequest({ method: 'GET', path: '/.well-known/openid-configuration', root, occurredAt: AT });
242
+ const d = body(r);
243
+ return ok(r)
244
+ && d.issuer === 'https://accounts.google.com'
245
+ && d.authorization_endpoint === 'https://accounts.google.com/o/oauth2/v2/auth'
246
+ && d.token_endpoint === 'https://oauth2.googleapis.com/token'
247
+ && d.jwks_uri === 'https://www.googleapis.com/oauth2/v3/certs'
248
+ && d.userinfo_endpoint === 'https://openidconnect.googleapis.com/v1/userinfo'
249
+ && Array.isArray(d.id_token_signing_alg_values_supported)
250
+ && d.id_token_signing_alg_values_supported.includes('RS256')
251
+ && d.code_challenge_methods_supported.includes('S256');
252
+ } finally {
253
+ rmSync(root, { recursive: true, force: true });
254
+ }
255
+ })),
256
+ done('googleoauth.discovery.origin_rewrite', 'discovery', 'Discovery rewrites every endpoint to the twin\'s own origin (a discovery-driven client stays inside the world)', 'api', 'core', () =>
257
+ withRoot(async () => {
258
+ // Not routed through `h` because this is about the `origin` input specifically.
259
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-disc-'));
260
+ try {
261
+ const r = await handleGoogleOAuthTwinRequest({ method: 'GET', path: '/.well-known/openid-configuration', root, occurredAt: AT, origin: 'http://127.0.0.1:45999' });
262
+ const d = body(r);
263
+ // The ISSUER must stay Google's — it is a token claim, not an address — while every
264
+ // ENDPOINT must move. Getting that backwards is the bug this pins.
265
+ return ok(r)
266
+ && d.issuer === 'https://accounts.google.com'
267
+ && d.authorization_endpoint === 'http://127.0.0.1:45999/o/oauth2/v2/auth'
268
+ && d.token_endpoint === 'http://127.0.0.1:45999/token'
269
+ && d.jwks_uri === 'http://127.0.0.1:45999/oauth2/v3/certs'
270
+ && d.revocation_endpoint === 'http://127.0.0.1:45999/revoke'
271
+ && d.userinfo_endpoint === 'http://127.0.0.1:45999/v1/userinfo';
272
+ } finally {
273
+ rmSync(root, { recursive: true, force: true });
274
+ }
275
+ })),
276
+ // Tiered `common` to match its three `device.*` siblings: they describe one flow, and splitting
277
+ // its tier across areas made the same endpoint look niche in one place and common in another.
278
+ todo('googleoauth.discovery.device_authorization', 'discovery', 'GET /device/code — the device-authorization endpoint the discovery document advertises', 'api', 'common'),
279
+
280
+ // ── The authorization endpoint ───────────────────────────────────────────────────────────────
281
+ done('googleoauth.authorize.consent_screen', 'authorize', 'GET /o/oauth2/v2/auth — renders the consent surface as HTML (200)', 'api', 'core', () =>
282
+ withRoot(async (h) => {
283
+ const r = await h({ m: 'GET', p: authUrl({ state: 'xyz' }) });
284
+ return r.status === 200
285
+ && r.headers?.['content-type'] === 'text/html; charset=utf-8'
286
+ && html(r).startsWith('<!doctype html>')
287
+ && html(r).includes('Choose an account')
288
+ && html(r).includes('Twin Demo App');
289
+ })),
290
+ done('googleoauth.authorize.legacy_path', 'authorize', 'GET /o/oauth2/auth — the legacy authorization path serves the same screen', 'api', 'common', () =>
291
+ withRoot(async (h) => {
292
+ const r = await h({ m: 'GET', p: authUrl({}, '/o/oauth2/auth') });
293
+ return r.status === 200 && html(r).includes('Twin Demo App') && html(r).includes(ADA.email);
294
+ })),
295
+ done('googleoauth.authorize.unknown_client_error_page', 'authorize', 'Unknown client_id → a 302 to Google\'s own /signin/oauth/error page carrying invalid_client (401), never to redirect_uri', 'api', 'core', () =>
296
+ withRoot(async (h) => {
297
+ const { redirect, error, markup } = await errorPageFor(h, authUrl({ client_id: '999-nosuch.apps.googleusercontent.com' }));
298
+ const target = new URL(loc(redirect));
299
+ // THE SECURITY PROPERTY: the bounce goes to Google's OWN error page, never to the
300
+ // redirect_uri the unknown client claimed — that would make the twin an open redirector.
301
+ return redirect.status === 302
302
+ && target.pathname === ERROR_PAGE_PATH
303
+ && !loc(redirect).startsWith(REDIRECT)
304
+ && target.searchParams.get('flowName') === FLOW_NAME
305
+ && error?.code === 'invalid_client'
306
+ && error.status === 401
307
+ && error.message === 'The OAuth client was not found.'
308
+ && text(markup).includes('Error 401')
309
+ && text(markup).includes('The OAuth client was not found.')
310
+ && text(markup).includes('Access blocked: Authorization Error');
311
+ })),
312
+ done('googleoauth.authorize.missing_client_id', 'authorize', 'Missing client_id → 400 error page naming the parameter', 'api', 'common', () =>
313
+ withRoot(async (h) => {
314
+ const { redirect, error, markup } = await errorPageFor(h, authUrl({ client_id: undefined }));
315
+ return redirect.status === 302 && new URL(loc(redirect)).pathname === ERROR_PAGE_PATH
316
+ && error?.code === 'invalid_request' && error.status === 400
317
+ && error.message === 'Required parameter is missing: client_id'
318
+ && text(markup).includes('Error 400') && text(markup).includes('Required parameter is missing: client_id');
319
+ })),
320
+ done('googleoauth.authorize.missing_redirect_uri', 'authorize', 'Missing redirect_uri → 400 error page naming the parameter', 'api', 'common', () =>
321
+ withRoot(async (h) => {
322
+ const { redirect, error } = await errorPageFor(h, authUrl({ redirect_uri: undefined }));
323
+ return redirect.status === 302 && new URL(loc(redirect)).pathname === ERROR_PAGE_PATH
324
+ && error?.code === 'invalid_request' && error.message === 'Required parameter is missing: redirect_uri';
325
+ })),
326
+ done('googleoauth.authorize.redirect_uri_mismatch_page', 'authorize', 'Unregistered redirect_uri → the "redirect_uri_mismatch" page with Google\'s own policy wording, never a bounce to that URI', 'api', 'core', () =>
327
+ withRoot(async (h) => {
328
+ const { redirect, error, markup } = await errorPageFor(h, authUrl({ redirect_uri: 'https://evil.test/steal' }));
329
+ return redirect.status === 302
330
+ // The attacker-supplied URI is NOT where the browser is sent.
331
+ && !loc(redirect).startsWith('https://evil.test')
332
+ && new URL(loc(redirect)).pathname === ERROR_PAGE_PATH
333
+ && error?.code === 'redirect_uri_mismatch'
334
+ && error.status === 400
335
+ && error.message.includes("doesn't comply with Google's OAuth 2.0 policy")
336
+ && error.param === 'redirect_uri=https://evil.test/steal'
337
+ // …and the page uses the OTHER headline Google reserves for a misconfigured app.
338
+ && text(markup).includes("Access blocked: This app's request is invalid")
339
+ && text(markup).includes('Error 400') && text(markup).includes('redirect_uri_mismatch');
340
+ })),
341
+ done('googleoauth.authorize.redirect_uri_bypasses_refused', 'authorize', 'A fragment, a userinfo subcomponent or an alternate IPv4 spelling cannot smuggle past redirect-URI matching', 'api', 'core', () =>
342
+ withRoot(async (h) => {
343
+ // Three bypasses §9 round two found, all against the LOOPBACK branch, which compared
344
+ // protocol/host/path/search and nothing else. Google forbids a fragment outright, forbids the
345
+ // userinfo subcomponent, and documents its loopback exception for the LITERALS 127.0.0.1 and
346
+ // [::1] — but WHATWG `URL` folds 0177.0.0.1, 2130706433 and 127.1 all onto 127.0.0.1, so
347
+ // reading `.hostname` accepted three spellings the vendor's exact-match rule does not.
348
+ const bypasses = [
349
+ 'http://127.0.0.1:3000/api/auth/callback/google#evil',
350
+ 'http://attacker:hunter2@127.0.0.1:3000/api/auth/callback/google',
351
+ 'http://0177.0.0.1:3000/api/auth/callback/google',
352
+ 'http://2130706433:3000/api/auth/callback/google',
353
+ 'http://127.1:3000/api/auth/callback/google',
354
+ ];
355
+ for (const redirect_uri of bypasses) {
356
+ const { error } = await errorPageFor(h, authUrl({ redirect_uri }));
357
+ if (error?.code !== 'redirect_uri_mismatch') return false;
358
+ }
359
+ // …and the genuine loopback registration still works, so this is not "everything is refused".
360
+ return (await h({ m: 'GET', p: authUrl({ redirect_uri: 'http://127.0.0.1:51999/api/auth/callback/google' }) })).status === 200;
361
+ })),
362
+ done('googleoauth.authorize.exact_redirect_uri_matching', 'authorize', 'redirect_uri matching is EXACT — a trailing slash is a different URI', 'api', 'core', () =>
363
+ withRoot(async (h) => {
364
+ // The single most common Google integration bug, and only reproducible if the twin is as
365
+ // strict as the vendor.
366
+ const exact = await h({ m: 'GET', p: authUrl({}) });
367
+ const slashed = await errorPageFor(h, authUrl({ redirect_uri: `${REDIRECT}/` }));
368
+ const cased = await errorPageFor(h, authUrl({ redirect_uri: REDIRECT.replace('/callback/google', '/Callback/Google') }));
369
+ return exact.status === 200
370
+ && slashed.error?.code === 'redirect_uri_mismatch'
371
+ && cased.error?.code === 'redirect_uri_mismatch';
372
+ })),
373
+ done('googleoauth.authorize.loopback_port_ignored', 'authorize', 'Installed-app loopback redirect: the PORT is ignored, the host and path are not', 'api', 'common', () =>
374
+ withRoot(async (h) => {
375
+ // Registered: http://127.0.0.1:3000/api/auth/callback/google
376
+ const otherPort = await h({ m: 'GET', p: authUrl({ redirect_uri: 'http://127.0.0.1:51999/api/auth/callback/google' }) });
377
+ const otherPath = await errorPageFor(h, authUrl({ redirect_uri: 'http://127.0.0.1:51999/other' }));
378
+ // …and the exception is for the loopback IP LITERALS only: `localhost` is an ordinary host to
379
+ // Google, so a different port there is a mismatch even though the path matches a registration.
380
+ const localhostOtherPort = await errorPageFor(h, authUrl({ redirect_uri: 'http://localhost:51999/api/auth/callback/google' }));
381
+ const localhostExact = await h({ m: 'GET', p: authUrl({ redirect_uri: 'http://localhost:3000/api/auth/callback/google' }) });
382
+ return otherPort.status === 200
383
+ && otherPath.error?.code === 'redirect_uri_mismatch'
384
+ && localhostOtherPort.error?.code === 'redirect_uri_mismatch'
385
+ && localhostExact.status === 200;
386
+ })),
387
+ done('googleoauth.authorize.parameter_errors_never_reach_redirect_uri', 'authorize', 'EVERY parameter error renders Google\'s error page — it is NOT bounced to redirect_uri (RFC 6749 notwithstanding)', 'api', 'core', () =>
388
+ withRoot(async (h) => {
389
+ // The behaviour a live capture of the real endpoint refuted an RFC-shaped implementation on:
390
+ // the spec says redirect these back to the client, and Google does not.
391
+ const cases: Array<[string, string]> = [
392
+ [authUrl({ response_type: undefined, state: 'keepme' }), 'Required parameter is missing: response_type'],
393
+ [authUrl({ scope: undefined }), 'Missing required parameter: scope'],
394
+ [authUrl({ access_type: 'sideways' }), "Invalid parameter value for access_type: 'sideways' is not valid"],
395
+ [authUrl({ display: 'bogus' }), "Invalid parameter value for display: 'bogus' is not valid"],
396
+ [authUrl({ prompt: 'shout' }), 'Invalid prompt: shout'],
397
+ [authUrl({ include_granted_scopes: 'yes' }), 'Invalid value, must be one of false, true: yes'],
398
+ [authUrl({ enable_granular_consent: 'maybe' }), 'Invalid value, must be one of false, true: maybe'],
399
+ ];
400
+ for (const [path, message] of cases) {
401
+ const { redirect, error } = await errorPageFor(h, path);
402
+ if (redirect.status !== 302) return false;
403
+ if (loc(redirect).startsWith(REDIRECT)) return false; // the whole point
404
+ if (new URL(loc(redirect)).pathname !== ERROR_PAGE_PATH) return false;
405
+ if (error?.code !== 'invalid_request' || error.message !== message) return false;
406
+ }
407
+ // …and the same request WITHOUT the bad parameter renders the screen, so this is not
408
+ // "everything fails".
409
+ return (await h({ m: 'GET', p: authUrl({ display: 'popup', prompt: 'consent select_account', include_granted_scopes: 'true' }) })).status === 200;
410
+ })),
411
+ done('googleoauth.authorize.unsupported_response_type', 'authorize', 'response_type=token is refused as UNMODELLED — the twin never claims Google rejects a flow Google supports', 'api', 'common', () =>
412
+ withRoot(async (h) => {
413
+ const { redirect, error } = await errorPageFor(h, authUrl({ response_type: 'token' }));
414
+ return redirect.status === 302
415
+ && error?.code === 'unsupported_response_type'
416
+ // The INVERSE false-green guard: Google DOES support the implicit flow, so the message must
417
+ // say the TWIN does not model it, not that the request is invalid.
418
+ && error.message.includes('not modelled')
419
+ && error.message.includes('Real Google supports it')
420
+ && qp(redirect, 'code') === null;
421
+ })),
422
+ done('googleoauth.authorize.repeated_parameter', 'authorize', 'A repeated query parameter is refused outright ("OAuth 2 parameters can only have a single value")', 'api', 'common', () =>
423
+ withRoot(async (h) => {
424
+ const { error } = await errorPageFor(h, `${authUrl({})}&response_type=id_token`);
425
+ const scopeErr = await errorPageFor(h, `${authUrl({})}&scope=openid`);
426
+ return error?.code === 'invalid_request'
427
+ && error.message === 'OAuth 2 parameters can only have a single value: response_type'
428
+ && scopeErr.error?.message === 'OAuth 2 parameters can only have a single value: scope';
429
+ })),
430
+ done('googleoauth.authorize.prompt_none_interaction_required', 'authorize', 'prompt=none with no covering grant → 302 to redirect_uri with error=interaction_required + error_subtype', 'api', 'common', () =>
431
+ withRoot(async (h) => {
432
+ const r = await h({ m: 'GET', p: authUrl({ prompt: 'none', state: 'sil' }) });
433
+ // One of the only TWO outcomes that reach the caller's redirect_uri.
434
+ return r.status === 302 && loc(r).startsWith(REDIRECT)
435
+ && qp(r, 'error') === 'interaction_required'
436
+ && qp(r, 'error_subtype') === 'access_denied'
437
+ && qp(r, 'state') === 'sil'
438
+ && qp(r, 'code') === null;
439
+ })),
440
+ done('googleoauth.authorize.prompt_none_must_be_alone', 'authorize', 'prompt=none combined with another value is refused (Google documents it as mutually exclusive)', 'api', 'niche', () =>
441
+ withRoot(async (h) => {
442
+ const { error } = await errorPageFor(h, authUrl({ prompt: 'none consent' }));
443
+ const fine = await h({ m: 'GET', p: authUrl({ prompt: 'consent select_account' }) });
444
+ return error?.code === 'invalid_request' && error.message === 'Invalid prompt: none consent' && fine.status === 200;
445
+ })),
446
+ done('googleoauth.authorize.login_hint', 'authorize', 'login_hint naming a known account SKIPS the chooser; an unknown hint falls back to it', 'api', 'common', () =>
447
+ withRoot(async (h) => {
448
+ const hinted = await h({ m: 'GET', p: authUrl({ login_hint: GRACE.email }) });
449
+ const unknown = await h({ m: 'GET', p: authUrl({ login_hint: 'nobody@nowhere.test' }) });
450
+ return hinted.status === 200
451
+ && html(hinted).includes('wants access to your Google Account')
452
+ && html(hinted).includes(GRACE.email)
453
+ && !html(hinted).includes('Choose an account')
454
+ // An unknown hint is NOT an error on Google — it just shows the chooser.
455
+ && unknown.status === 200
456
+ && html(unknown).includes('Choose an account');
457
+ })),
458
+ done('googleoauth.authorize.state_roundtrip', 'authorize', 'state is echoed VERBATIM on the success redirect (CSRF binding)', 'api', 'core', () =>
459
+ withRoot(async (h) => {
460
+ const weird = 'a b/c+d=e&f%20g';
461
+ const flow = await consentFlow(h, { params: { state: weird } });
462
+ return flow.redirect.status === 302 && flow.state === weird && !!flow.code;
463
+ })),
464
+ done('googleoauth.authorize.no_state_no_echo', 'authorize', 'A request with NO state gets NO state parameter back (not an empty one)', 'api', 'niche', () =>
465
+ withRoot(async (h) => {
466
+ const flow = await consentFlow(h, {});
467
+ return flow.redirect.status === 302 && !!flow.code && new URL(loc(flow.redirect)).searchParams.has('state') === false;
468
+ })),
469
+ done('googleoauth.authorize.code_redirect_shape', 'authorize', 'The success redirect carries EXACTLY Google\'s documented parameters — no more, no fewer', 'api', 'core', () =>
470
+ withRoot(async (h) => {
471
+ const flow = await consentFlow(h, { params: { state: 's' } });
472
+ if (flow.redirect.status !== 302) return false;
473
+ if (!(flow.code ?? '').startsWith('4/0A')) return false;
474
+ if (qp(flow.redirect, 'scope') !== 'openid email profile') return false;
475
+ if (qp(flow.redirect, 'authuser') !== '0' || qp(flow.redirect, 'error') !== null) return false;
476
+
477
+ // AND NOTHING ELSE. This is the one assertion in the pack that pins an ABSENCE, and it exists
478
+ // because §9 found the inverse false-green here: the twin was emitting RFC 9207's `iss`,
479
+ // licensed by a discovery key neither of which could be confirmed against Google's artefacts.
480
+ // ADDING_A_TWIN.md §6 says no gate can red-flag invented surface because "there is no oracle
481
+ // for absence" — but on a REDIRECT there is one, because the vendor's parameter set is closed
482
+ // and documented. An allowlist turns the unpinnable class into a pinned one: re-add `iss`, or
483
+ // any other invention, and this capability reddens by name.
484
+ const DOCUMENTED = new Set(['code', 'state', 'scope', 'authuser', 'prompt', 'hd', 'error', 'error_subtype', 'error_description']);
485
+ const undocumented = [...new URL(loc(flow.redirect)).searchParams.keys()].filter((k) => !DOCUMENTED.has(k));
486
+ if (undocumented.length) return false;
487
+ // …and the same closed set on a Workspace account (which adds `hd`) and on the denial path.
488
+ const workspace = await consentFlow(h, { params: { state: 's' }, sub: GRACE.sub });
489
+ const denied = await consentFlow(h, { params: { state: 's' }, decision: 'deny' });
490
+ for (const r of [workspace.redirect, denied.redirect]) {
491
+ if ([...new URL(loc(r)).searchParams.keys()].some((k) => !DOCUMENTED.has(k))) return false;
492
+ }
493
+ return qp(workspace.redirect, 'hd') === GRACE.hd;
494
+ })),
495
+ done('googleoauth.authorize.access_denied', 'authorize', 'Deny → 302 back with error=access_denied and the state, and NO code', 'api', 'core', () =>
496
+ withRoot(async (h) => {
497
+ const flow = await consentFlow(h, { params: { state: 'denied-state' }, decision: 'deny' });
498
+ return flow.redirect.status === 302
499
+ && qp(flow.redirect, 'error') === 'access_denied'
500
+ && qp(flow.redirect, 'state') === 'denied-state'
501
+ && flow.code === null;
502
+ })),
503
+ done('googleoauth.authorize.consent_replay_refused', 'authorize', 'An authorization request can be settled ONCE — a replayed consent post is refused', 'api', 'common', () =>
504
+ withRoot(async (h) => {
505
+ const auth = await h({ m: 'GET', p: authUrl({}) });
506
+ const rid = AUTH_REQUEST_RE.exec(html(auth))?.[1] ?? '';
507
+ const first = await h({ m: 'POST', p: '/_twin/consent', b: form({ auth_request: rid, sub: ADA.sub, decision: 'allow' }) });
508
+ const second = await h({ m: 'POST', p: '/_twin/consent', b: form({ auth_request: rid, sub: ADA.sub, decision: 'allow' }) });
509
+ return first.status === 302 && !!qp(first, 'code') && second.status === 400 && err(second) === 'invalid_request';
510
+ })),
511
+ done('googleoauth.authorize.prompt_none_succeeds_silently', 'authorize', 'prompt=none WITH a covering grant mints a code silently — a 302, never an HTML screen', 'api', 'core', () =>
512
+ withRoot(async (h) => {
513
+ // The other half of prompt=none, and the half that was broken: an earlier version answered
514
+ // `interaction_required` correctly and then fell through to RENDERING THE CONSENT SCREEN when
515
+ // the grant did cover, handing an HTML page to a caller that asked for no UI (§9 round one).
516
+ const scope = 'openid email';
517
+ // Grace, deliberately NOT the first seeded account: the old check only ever consulted
518
+ // accounts[0], so any other persona's covering grant was invisible to it.
519
+ await consentFlow(h, { params: { scope }, sub: GRACE.sub });
520
+ const silent = await h({ m: 'GET', p: authUrl({ scope, prompt: 'none', state: 'quiet' }) });
521
+ if (silent.status !== 302 || !loc(silent).startsWith(REDIRECT)) return false;
522
+ if (qp(silent, 'error') !== null || qp(silent, 'state') !== 'quiet') return false;
523
+ const code = qp(silent, 'code');
524
+ if (!code?.startsWith('4/0A')) return false;
525
+ // …the silently-minted code is a REAL code, redeemable for the account that granted it.
526
+ const token = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT }) });
527
+ if (!ok(token) || decodeJwt(String(body(token).id_token)).payload.sub !== GRACE.sub) return false;
528
+ // The silent redirect must NOT claim a screen was shown: `prompt` echoes what happened, and
529
+ // stamping `consent` on a no-UI response asserts the opposite (§9 round two).
530
+ if (qp(silent, 'prompt') !== null) return false;
531
+ // …and asking for MORE than the grant covers still refuses rather than showing a screen.
532
+ const wider = await h({ m: 'GET', p: authUrl({ scope: `${scope} https://www.googleapis.com/auth/drive`, prompt: 'none' }) });
533
+ return wider.status === 302 && qp(wider, 'error') === 'interaction_required';
534
+ })),
535
+ done('googleoauth.authorize.prompt_none_never_substitutes_an_account', 'authorize', 'prompt=none with a login_hint that does NOT cover refuses — it never silently hands back a different human\'s token', 'api', 'core', () =>
536
+ withRoot(async (h) => {
537
+ // The sharpest failure this surface can have, and one an app cannot detect until it decodes
538
+ // the token: with no screen shown, a substituted subject is invisible. An earlier version fell
539
+ // through from an uncovering hint to `accounts.find(covers)` and minted a code for whoever
540
+ // happened to have a grant (§9 round two, MAJOR).
541
+ const scope = 'openid email';
542
+ await consentFlow(h, { params: { scope }, sub: GRACE.sub }); // Grace covers; Ada does not.
543
+ const hintedAtAda = await h({ m: 'GET', p: authUrl({ scope, prompt: 'none', login_hint: ADA.email }) });
544
+ if (hintedAtAda.status !== 302 || qp(hintedAtAda, 'error') !== 'interaction_required') return false;
545
+ if (qp(hintedAtAda, 'code') !== null) return false;
546
+ // …and the SAME request hinted at the account that DOES cover succeeds, so this is a refusal
547
+ // to substitute rather than a blanket refusal.
548
+ const hintedAtGrace = await h({ m: 'GET', p: authUrl({ scope, prompt: 'none', login_hint: GRACE.email }) });
549
+ const code = qp(hintedAtGrace, 'code');
550
+ if (!code) return false;
551
+ const token = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT }) });
552
+ return ok(token) && decodeJwt(String(body(token).id_token)).payload.sub === GRACE.sub;
553
+ })),
554
+ todo('googleoauth.authorize.iss_parameter', 'authorize', 'RFC 9207 `iss` on the authorization response — the twin does NOT emit it: neither the parameter nor the discovery flag that would license it could be confirmed against Google\'s real artefacts', 'api', 'niche'),
555
+ todo('googleoauth.authorize.implicit_flow', 'authorize', 'response_type=token / id_token — the implicit and hybrid flows (fragment response)', 'api', 'niche'),
556
+ todo('googleoauth.authorize.response_mode', 'authorize', 'response_mode=form_post / fragment / query', 'api', 'niche'),
557
+ todo('googleoauth.authorize.hd_parameter', 'authorize', 'hd=<domain> restricts the chooser to a Workspace domain (and error=org_internal)', 'api', 'common'),
558
+ todo('googleoauth.authorize.enable_granular_consent', 'authorize', 'enable_granular_consent parameter toggles the per-scope checkbox UI explicitly', 'api', 'niche'),
559
+ todo('googleoauth.authorize.admin_policy_enforced', 'authorize', 'error=admin_policy_enforced when a Workspace admin blocks the scope', 'api', 'niche'),
560
+ todo('googleoauth.authorize.disallowed_useragent', 'authorize', 'error=disallowed_useragent for an embedded webview', 'api', 'niche'),
561
+
562
+ // ── PKCE ─────────────────────────────────────────────────────────────────────────────────────
563
+ done('googleoauth.pkce.s256_roundtrip', 'pkce', 'PKCE S256: a code minted with a challenge is redeemable with the matching verifier', 'api', 'core', () =>
564
+ withRoot(async (h) => {
565
+ const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
566
+ const flow = await fullFlow(h, { params: { code_challenge: pkceS256(verifier), code_challenge_method: 'S256' }, verifier });
567
+ return flow.status === 200 && typeof flow.tokens.access_token === 'string';
568
+ })),
569
+ done('googleoauth.pkce.s256_mismatch_rejected', 'pkce', 'PKCE S256: the WRONG verifier is refused with invalid_grant', 'api', 'core', () =>
570
+ withRoot(async (h) => {
571
+ const flow = await fullFlow(h, { params: { code_challenge: pkceS256('right-verifier-right-verifier-right-verifier'), code_challenge_method: 'S256' }, verifier: 'wrong-verifier-wrong-verifier-wrong-verifier' });
572
+ return flow.status === 400 && flow.tokens.error === 'invalid_grant';
573
+ })),
574
+ done('googleoauth.pkce.missing_verifier_rejected', 'pkce', 'PKCE: a challenged code redeemed with NO verifier is refused', 'api', 'core', () =>
575
+ withRoot(async (h) => {
576
+ const flow = await fullFlow(h, { params: { code_challenge: pkceS256('some-verifier-some-verifier-some-verifier'), code_challenge_method: 'S256' } });
577
+ return flow.status === 400 && flow.tokens.error === 'invalid_grant';
578
+ })),
579
+ done('googleoauth.pkce.plain_method', 'pkce', 'PKCE plain: the verifier IS the challenge', 'api', 'niche', () =>
580
+ withRoot(async (h) => {
581
+ const verifier = 'plain-verifier-plain-verifier-plain-verifier';
582
+ const good = await fullFlow(h, { params: { code_challenge: verifier, code_challenge_method: 'plain' }, verifier });
583
+ const bad = await fullFlow(h, { params: { code_challenge: verifier, code_challenge_method: 'plain' }, verifier: 'other' });
584
+ return good.status === 200 && typeof good.tokens.access_token === 'string' && bad.status === 400 && bad.tokens.error === 'invalid_grant';
585
+ })),
586
+ done('googleoauth.pkce.invalid_method_rejected', 'pkce', 'code_challenge_method outside {S256, plain} → 302 error=invalid_request', 'api', 'common', () =>
587
+ withRoot(async (h) => {
588
+ const { error } = await errorPageFor(h, authUrl({ code_challenge: 'abc', code_challenge_method: 'SHA1' }));
589
+ return error?.code === 'invalid_request'
590
+ && error.message === "Invalid parameter value for code_challenge_method: 'SHA1' is not a valid CodeChallengeMethod";
591
+ })),
592
+ done('googleoauth.pkce.unexpected_verifier_rejected', 'pkce', 'A verifier presented for a code minted WITHOUT a challenge is refused', 'api', 'niche', () =>
593
+ withRoot(async (h) => {
594
+ const flow = await fullFlow(h, { verifier: 'unexpected-verifier-unexpected-verifier' });
595
+ return flow.status === 400 && flow.tokens.error === 'invalid_grant';
596
+ })),
597
+ done('googleoauth.pkce.s256_transform', 'pkce', 'The S256 transform is real SHA-256: BASE64URL(SHA256(ASCII(verifier))), unpadded', 'api', 'core', () =>
598
+ withRoot(async () =>
599
+ // RFC 7636 Appendix B's own test vector — a LITERAL, so this cannot become the twin
600
+ // agreeing with itself.
601
+ pkceS256('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk') === 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'),
602
+ ),
603
+ todo('googleoauth.pkce.verifier_length_validation', 'pkce', 'RFC 7636 verifier length rules (43-128 chars) rejected at the token endpoint', 'api', 'niche'),
604
+
605
+ // ── The token endpoint ───────────────────────────────────────────────────────────────────────
606
+ done('googleoauth.token.authorization_code', 'token', 'POST /token grant_type=authorization_code — the full success envelope', 'api', 'core', () =>
607
+ withRoot(async (h) => {
608
+ const flow = await fullFlow(h, { params: offline });
609
+ const t = flow.tokens;
610
+ return flow.status === 200
611
+ && typeof t.access_token === 'string' && String(t.access_token).startsWith('ya29.')
612
+ && t.expires_in === 3600
613
+ && t.token_type === 'Bearer'
614
+ && t.scope === 'openid email profile'
615
+ && typeof t.refresh_token === 'string' && String(t.refresh_token).startsWith('1//')
616
+ && typeof t.id_token === 'string';
617
+ })),
618
+ done('googleoauth.token.legacy_v4_path', 'token', 'POST /oauth2/v4/token — the legacy token path older clients default to', 'api', 'common', () =>
619
+ withRoot(async (h) => {
620
+ const flow = await consentFlow(h, {});
621
+ const r = await h({ m: 'POST', p: '/oauth2/v4/token', b: form({ grant_type: 'authorization_code', code: flow.code!, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT }) });
622
+ return r.status === 200 && String(body(r).access_token).startsWith('ya29.');
623
+ })),
624
+ done('googleoauth.token.code_single_use', 'token', 'An authorization code is redeemable EXACTLY ONCE; the replay is invalid_grant', 'api', 'core', () =>
625
+ withRoot(async (h) => {
626
+ const flow = await consentFlow(h, {});
627
+ const redeem = () => h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: flow.code!, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT }) });
628
+ const first = await redeem();
629
+ const second = await redeem();
630
+ return first.status === 200 && second.status === 400 && err(second) === 'invalid_grant';
631
+ })),
632
+ done('googleoauth.token.code_expiry', 'token', 'An authorization code older than its ~10-minute life is invalid_grant', 'api', 'common', () =>
633
+ withRoot(async (h) => {
634
+ const flow = await consentFlow(h, {});
635
+ const late = new Date(Date.parse(AT) + 11 * 60_000).toISOString();
636
+ const r = await h({ m: 'POST', p: '/token', at: late, b: form({ grant_type: 'authorization_code', code: flow.code!, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT }) });
637
+ // …and it is only the CLOCK that made it fail: the same code inside the window works.
638
+ const flow2 = await consentFlow(h, {});
639
+ const early = new Date(Date.parse(AT) + 60_000).toISOString();
640
+ const r2 = await h({ m: 'POST', p: '/token', at: early, b: form({ grant_type: 'authorization_code', code: flow2.code!, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT }) });
641
+ return r.status === 400 && err(r) === 'invalid_grant' && r2.status === 200;
642
+ })),
643
+ done('googleoauth.token.unknown_code', 'token', 'An unknown code is invalid_grant with Google\'s "Bad Request" description', 'api', 'core', () =>
644
+ withRoot(async (h) => {
645
+ const r = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: '4/0Anever-minted', client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT }) });
646
+ return r.status === 400 && err(r) === 'invalid_grant'
647
+ && body(r).error_description === 'The supplied authorization code is invalid or in the wrong format.';
648
+ })),
649
+ done('googleoauth.token.redirect_uri_mismatch', 'token', 'The redirect_uri must be REPEATED at the token endpoint and must match the code\'s', 'api', 'core', () =>
650
+ withRoot(async (h) => {
651
+ const flow = await consentFlow(h, {});
652
+ const wrong = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: flow.code!, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT_URIS[1]! }) });
653
+ const missing = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: flow.code!, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET }) });
654
+ return wrong.status === 400 && err(wrong) === 'redirect_uri_mismatch' && missing.status === 400 && err(missing) === 'redirect_uri_mismatch';
655
+ })),
656
+ done('googleoauth.token.wrong_client_rejected', 'token', 'A code minted for one client cannot be redeemed by another', 'api', 'core', () =>
657
+ withRoot(async (h) => {
658
+ await h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_id: 'other.apps.googleusercontent.com', client_secret: 'GOCSPX-other', redirect_uris: [REDIRECT] }) });
659
+ const flow = await consentFlow(h, {});
660
+ const r = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: flow.code!, client_id: 'other.apps.googleusercontent.com', client_secret: 'GOCSPX-other', redirect_uri: REDIRECT }) });
661
+ return r.status === 400 && err(r) === 'invalid_grant';
662
+ })),
663
+ done('googleoauth.token.invalid_client_secret', 'token', 'A wrong or missing client_secret is 401 invalid_client (not 400)', 'api', 'core', () =>
664
+ withRoot(async (h) => {
665
+ const flow = await consentFlow(h, {});
666
+ const wrong = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: flow.code!, client_id: DEFAULT_CLIENT_ID, client_secret: 'GOCSPX-wrong', redirect_uri: REDIRECT }) });
667
+ const missing = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: flow.code!, client_id: DEFAULT_CLIENT_ID, redirect_uri: REDIRECT }) });
668
+ const unknown = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: flow.code!, client_id: 'nope.apps.googleusercontent.com', client_secret: 'x', redirect_uri: REDIRECT }) });
669
+ // The two DISTINCT messages captured live: an unknown client is "The OAuth client was not
670
+ // found."; a known client with the wrong secret is "The provided client secret is invalid."
671
+ return wrong.status === 401 && err(wrong) === 'invalid_client' && body(wrong).error_description === 'The provided client secret is invalid.'
672
+ && missing.status === 401 && err(missing) === 'invalid_client'
673
+ && unknown.status === 401 && err(unknown) === 'invalid_client' && body(unknown).error_description === 'The OAuth client was not found.';
674
+ })),
675
+ done('googleoauth.token.basic_auth', 'token', 'client_secret_basic: credentials in the Authorization header are accepted', 'api', 'common', () =>
676
+ withRoot(async (h) => {
677
+ const flow = await consentFlow(h, {});
678
+ const basic = Buffer.from(`${encodeURIComponent(DEFAULT_CLIENT_ID)}:${encodeURIComponent(DEFAULT_CLIENT_SECRET)}`).toString('base64');
679
+ const r = await h({ m: 'POST', p: '/token', h: { authorization: `Basic ${basic}` }, b: form({ grant_type: 'authorization_code', code: flow.code!, redirect_uri: REDIRECT }) });
680
+ // …and a WRONG basic secret is still refused, so this is not "any header passes".
681
+ const flow2 = await consentFlow(h, {});
682
+ const badBasic = Buffer.from(`${encodeURIComponent(DEFAULT_CLIENT_ID)}:nope`).toString('base64');
683
+ const bad = await h({ m: 'POST', p: '/token', h: { authorization: `Basic ${badBasic}` }, b: form({ grant_type: 'authorization_code', code: flow2.code!, redirect_uri: REDIRECT }) });
684
+ return r.status === 200 && String(body(r).access_token).startsWith('ya29.') && bad.status === 401 && err(bad) === 'invalid_client';
685
+ })),
686
+ done('googleoauth.token.public_client_no_secret', 'token', 'An installed (public) client redeems with NO secret; a wrong one is still refused', 'api', 'common', () =>
687
+ withRoot(async (h) => {
688
+ await h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_id: 'installed.apps.googleusercontent.com', client_secret: 'GOCSPX-installed', client_type: 'installed', redirect_uris: [REDIRECT], name: 'Installed App' }) });
689
+ const mint = async () => {
690
+ const a = await h({ m: 'GET', p: authUrl({ client_id: 'installed.apps.googleusercontent.com' }) });
691
+ const rid = AUTH_REQUEST_RE.exec(html(a))?.[1] ?? '';
692
+ const d = await h({ m: 'POST', p: '/_twin/consent', b: form({ auth_request: rid, sub: ADA.sub, decision: 'allow' }) });
693
+ return qp(d, 'code')!;
694
+ };
695
+ const noSecret = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: await mint(), client_id: 'installed.apps.googleusercontent.com', redirect_uri: REDIRECT }) });
696
+ const wrongSecret = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: await mint(), client_id: 'installed.apps.googleusercontent.com', client_secret: 'GOCSPX-wrong', redirect_uri: REDIRECT }) });
697
+ return noSecret.status === 200 && String(body(noSecret).access_token).startsWith('ya29.') && wrongSecret.status === 401;
698
+ })),
699
+ done('googleoauth.token.missing_grant_type', 'token', 'Missing grant_type → 400 invalid_request naming the parameter', 'api', 'common', () =>
700
+ withRoot(async (h) => {
701
+ const r = await h({ m: 'POST', p: '/token', b: form({ code: 'x' }) });
702
+ return r.status === 400 && err(r) === 'invalid_request' && body(r).error_description === 'Missing required parameter: grant_type';
703
+ })),
704
+ done('googleoauth.token.unsupported_grant_type', 'token', 'An unmodelled grant_type is refused with unsupported_grant_type — never faked', 'api', 'core', () =>
705
+ withRoot(async (h) => {
706
+ const password = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'password', username: 'a', password: 'b' }) });
707
+ const device = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: 'x' }) });
708
+ return password.status === 400 && err(password) === 'unsupported_grant_type'
709
+ && device.status === 400 && err(device) === 'unsupported_grant_type';
710
+ })),
711
+ done('googleoauth.token.missing_code', 'token', 'authorization_code grant with no code → 400 invalid_request', 'api', 'niche', () =>
712
+ withRoot(async (h) => {
713
+ const r = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT }) });
714
+ return r.status === 400 && err(r) === 'invalid_request' && String(body(r).error_description).includes('code');
715
+ })),
716
+ done('googleoauth.token.no_store_headers', 'token', 'Token responses carry Cache-Control: no-store (RFC 6749 §5.1)', 'api', 'niche', () =>
717
+ withRoot(async (h) => {
718
+ const flow = await consentFlow(h, {});
719
+ const r = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: flow.code!, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT }) });
720
+ return r.status === 200 && r.headers?.['cache-control'] === 'no-store' && r.headers?.pragma === 'no-cache';
721
+ })),
722
+ todo('googleoauth.token.json_body_tolerance', 'token', 'Accept an application/json token request body (the twin is form-encoded-only until this is grounded)', 'api', 'niche'),
723
+ todo('googleoauth.token.device_code_grant', 'token', 'grant_type=urn:ietf:params:oauth:grant-type:device_code (the TV/CLI flow)', 'api', 'common'),
724
+ todo('googleoauth.token.sts_token_exchange', 'token', 'The STS token-exchange grant — on sts.googleapis.com, a HOST this pack does not claim in VENDOR_HOSTS (workload identity federation)', 'api', 'niche'),
725
+
726
+ // ── Refresh ──────────────────────────────────────────────────────────────────────────────────
727
+ done('googleoauth.refresh.grant', 'refresh', 'grant_type=refresh_token mints a NEW access token for the same subject', 'api', 'core', () =>
728
+ withRoot(async (h) => {
729
+ const first = await fullFlow(h, { params: offline });
730
+ const r = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'refresh_token', refresh_token: String(first.tokens.refresh_token), client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET }) });
731
+ const t = body(r);
732
+ // …and the REFRESHED token still belongs to an OFFLINE grant: deriving access_type from
733
+ // "did a refresh token come back?" made a refreshed token report `online`, which is wrong.
734
+ const info = await h({ m: 'GET', p: `/tokeninfo?access_token=${t.access_token}` });
735
+ return r.status === 200
736
+ && String(t.access_token).startsWith('ya29.')
737
+ && t.access_token !== first.tokens.access_token
738
+ && t.expires_in === 3600
739
+ // Google does NOT rotate the refresh token, so none comes back.
740
+ && t.refresh_token === undefined
741
+ && typeof t.id_token === 'string'
742
+ && ok(info) && body(info).access_type === 'offline' && body(info).sub === ADA.sub;
743
+ })),
744
+ done('googleoauth.refresh.only_when_offline', 'refresh', 'A refresh_token is issued only for access_type=offline — an online grant gets none', 'api', 'core', () =>
745
+ withRoot(async (h) => {
746
+ const online = await fullFlow(h, {});
747
+ const offlineFlow = await fullFlow(h, { params: offline });
748
+ return online.status === 200 && online.tokens.refresh_token === undefined
749
+ && offlineFlow.status === 200 && typeof offlineFlow.tokens.refresh_token === 'string';
750
+ })),
751
+ done('googleoauth.refresh.second_grant_needs_prompt', 'refresh', 'A SECOND offline authorization gets no refresh_token unless prompt=consent forced the screen', 'api', 'common', () =>
752
+ withRoot(async (h) => {
753
+ // Google's real rule, and the source of the classic "we lost the refresh token" bug.
754
+ const first = await fullFlow(h, { params: { access_type: 'offline' } });
755
+ const second = await fullFlow(h, { params: { access_type: 'offline' } });
756
+ const forced = await fullFlow(h, { params: offline });
757
+ return typeof first.tokens.refresh_token === 'string'
758
+ && second.status === 200 && second.tokens.refresh_token === undefined
759
+ && typeof forced.tokens.refresh_token === 'string';
760
+ })),
761
+ done('googleoauth.refresh.unknown_rejected', 'refresh', 'An unknown refresh token is invalid_grant "Token has been expired or revoked."', 'api', 'core', () =>
762
+ withRoot(async (h) => {
763
+ const r = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'refresh_token', refresh_token: '1//0never', client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET }) });
764
+ return r.status === 400 && err(r) === 'invalid_grant' && body(r).error_description === 'Token has been expired or revoked.';
765
+ })),
766
+ done('googleoauth.refresh.scope_narrowing', 'refresh', 'A refresh may NARROW the scope; widening it is invalid_scope (RFC 6749 §6)', 'api', 'common', () =>
767
+ withRoot(async (h) => {
768
+ const first = await fullFlow(h, { params: offline });
769
+ const rt = String(first.tokens.refresh_token);
770
+ const narrow = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'refresh_token', refresh_token: rt, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, scope: 'openid email' }) });
771
+ const wide = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'refresh_token', refresh_token: rt, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, scope: 'openid email https://www.googleapis.com/auth/drive' }) });
772
+ return narrow.status === 200 && body(narrow).scope === 'openid email'
773
+ && wide.status === 400 && err(wide) === 'invalid_scope';
774
+ })),
775
+ done('googleoauth.refresh.missing_token', 'refresh', 'refresh_token grant with no token → 400 invalid_request', 'api', 'niche', () =>
776
+ withRoot(async (h) => {
777
+ const r = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'refresh_token', client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET }) });
778
+ return r.status === 400 && err(r) === 'invalid_request'
779
+ && body(r).error_description === 'Missing required parameter: refresh_token';
780
+ })),
781
+ todo('googleoauth.refresh.testing_mode_7_day_expiry', 'refresh', 'Refresh tokens expire after 7 days while the app is in testing status', 'api', 'common'),
782
+ todo('googleoauth.refresh.hundred_token_cap', 'refresh', 'The 101st refresh token per (user, client) silently invalidates the oldest', 'api', 'niche'),
783
+
784
+ // ── id_token / JWKS (real crypto) ────────────────────────────────────────────────────────────
785
+ done('googleoauth.idtoken.rs256_signed', 'idtoken', 'The id_token is a REAL RS256 JWT with Google\'s header shape', 'api', 'core', () =>
786
+ withRoot(async (h) => {
787
+ const flow = await fullFlow(h, {});
788
+ const { header } = decodeJwt(String(flow.tokens.id_token));
789
+ return header.alg === 'RS256' && header.typ === 'JWT' && typeof header.kid === 'string' && (header.kid as string).length === 40;
790
+ })),
791
+ done('googleoauth.idtoken.jwks_endpoint', 'idtoken', 'GET /oauth2/v3/certs serves a real JWKS document', 'api', 'core', () =>
792
+ withRoot(async (h) => {
793
+ const r = await h({ m: 'GET', p: '/oauth2/v3/certs' });
794
+ const keys = body(r).keys as Body[];
795
+ return ok(r) && Array.isArray(keys) && keys.length === 1
796
+ && keys[0]!.kty === 'RSA' && keys[0]!.alg === 'RS256' && keys[0]!.use === 'sig'
797
+ && typeof keys[0]!.n === 'string' && keys[0]!.n.length > 300 && keys[0]!.e === 'AQAB';
798
+ })),
799
+ done('googleoauth.idtoken.verifies_against_jwks', 'idtoken', 'A twin-issued id_token VERIFIES against the JWKS the twin serves (claims round-trip)', 'api', 'core', () =>
800
+ withRoot(async (h) => {
801
+ const flow = await fullFlow(h, {});
802
+ const jwks = body(await h({ m: 'GET', p: '/oauth2/v3/certs' })) as Jwks;
803
+ const v = verifyJwtWithJwks(String(flow.tokens.id_token), jwks, { now: AT_SECONDS + 10 });
804
+ return v.valid === true
805
+ && v.payload!.iss === 'https://accounts.google.com'
806
+ && v.payload!.sub === ADA.sub
807
+ && v.payload!.aud === DEFAULT_CLIENT_ID
808
+ && v.payload!.azp === DEFAULT_CLIENT_ID
809
+ && v.payload!.email === ADA.email
810
+ && v.payload!.email_verified === true;
811
+ })),
812
+ done('googleoauth.idtoken.tamper_rejected', 'idtoken', 'A tampered or expired id_token FAILS verification against the same JWKS', 'api', 'core', () =>
813
+ withRoot(async (h) => {
814
+ const flow = await fullFlow(h, {});
815
+ const token = String(flow.tokens.id_token);
816
+ const jwks = body(await h({ m: 'GET', p: '/oauth2/v3/certs' })) as Jwks;
817
+ const tampered = `${token.slice(0, -4)}AAAA`;
818
+ const good = verifyJwtWithJwks(token, jwks, { now: AT_SECONDS + 10 });
819
+ const bad = verifyJwtWithJwks(tampered, jwks, { now: AT_SECONDS + 10 });
820
+ const expired = verifyJwtWithJwks(token, jwks, { now: AT_SECONDS + 100_000 });
821
+ return good.valid === true && bad.valid === false && bad.reason === 'bad_signature' && expired.valid === false && expired.reason === 'expired';
822
+ })),
823
+ done('googleoauth.idtoken.stable_kid', 'idtoken', 'The token header kid matches a JWKS kid, and is STABLE across calls (one persisted key)', 'api', 'common', () =>
824
+ withRoot(async (h) => {
825
+ const flow = await fullFlow(h, {});
826
+ const { header } = decodeJwt(String(flow.tokens.id_token));
827
+ const jwks1 = body(await h({ m: 'GET', p: '/oauth2/v3/certs' })) as Jwks;
828
+ const jwks2 = body(await h({ m: 'GET', p: '/oauth2/v3/certs' })) as Jwks;
829
+ return header.kid === jwks1.keys[0]!.kid && jwks1.keys[0]!.kid === jwks2.keys[0]!.kid && jwks1.keys[0]!.n === jwks2.keys[0]!.n;
830
+ })),
831
+ done('googleoauth.idtoken.at_hash', 'idtoken', 'at_hash is the real OIDC left-half SHA-256 of the access token (openid-client validates it)', 'api', 'common', () =>
832
+ withRoot(async (h) => {
833
+ const flow = await fullFlow(h, {});
834
+ const { payload } = decodeJwt(String(flow.tokens.id_token));
835
+ // Recomputed here from the OIDC Core §3.1.3.6 definition, independently of the handler.
836
+ const digest = new Bun.CryptoHasher('sha256').update(String(flow.tokens.access_token)).digest();
837
+ const expected = Buffer.from(digest.subarray(0, digest.length / 2)).toString('base64url');
838
+ return payload.at_hash === expected && typeof payload.at_hash === 'string';
839
+ })),
840
+ done('googleoauth.idtoken.nonce_roundtrip', 'idtoken', 'A `nonce` on the authorization request rides into the id_token; without one it is absent', 'api', 'core', () =>
841
+ withRoot(async (h) => {
842
+ const withNonce = await fullFlow(h, { params: { nonce: 'n-0S6_WzA2Mj' } });
843
+ const without = await fullFlow(h, {});
844
+ return decodeJwt(String(withNonce.tokens.id_token)).payload.nonce === 'n-0S6_WzA2Mj'
845
+ && decodeJwt(String(without.tokens.id_token)).payload.nonce === undefined;
846
+ })),
847
+ done('googleoauth.idtoken.claims_follow_scope', 'idtoken', 'Profile/email claims appear ONLY when their scope was granted', 'api', 'core', () =>
848
+ withRoot(async (h) => {
849
+ const full = await fullFlow(h, { params: { scope: 'openid email profile' } });
850
+ const openidOnly = await fullFlow(h, { params: { scope: 'openid' } });
851
+ const emailOnly = await fullFlow(h, { params: { scope: 'openid email' } });
852
+ const f = decodeJwt(String(full.tokens.id_token)).payload;
853
+ const o = decodeJwt(String(openidOnly.tokens.id_token)).payload;
854
+ const e = decodeJwt(String(emailOnly.tokens.id_token)).payload;
855
+ return f.email === ADA.email && f.name === ADA.name && f.picture === ADA.picture
856
+ && o.email === undefined && o.name === undefined && o.sub === ADA.sub
857
+ && e.email === ADA.email && e.name === undefined;
858
+ })),
859
+ done('googleoauth.idtoken.omitted_without_openid', 'idtoken', 'A plain OAuth 2.0 grant (no `openid` scope) gets NO id_token', 'api', 'core', () =>
860
+ withRoot(async (h) => {
861
+ const r = await fullFlow(h, { params: { scope: 'https://www.googleapis.com/auth/calendar.readonly' } });
862
+ return r.status === 200 && typeof r.tokens.access_token === 'string' && r.tokens.id_token === undefined;
863
+ })),
864
+ done('googleoauth.idtoken.hd_claim', 'idtoken', 'A Workspace account\'s `hd` claim rides in the id_token; a consumer account has none', 'api', 'common', () =>
865
+ withRoot(async (h) => {
866
+ const workspace = await fullFlow(h, { sub: GRACE.sub });
867
+ const consumer = await fullFlow(h, { sub: ADA.sub });
868
+ return decodeJwt(String(workspace.tokens.id_token)).payload.hd === GRACE.hd
869
+ && decodeJwt(String(consumer.tokens.id_token)).payload.hd === undefined;
870
+ })),
871
+ done('googleoauth.idtoken.legacy_pem_certs', 'idtoken', 'GET /oauth2/v1/certs serves the SAME key in PEM form, keyed by kid', 'api', 'niche', () =>
872
+ withRoot(async (h) => {
873
+ const pem = body(await h({ m: 'GET', p: '/oauth2/v1/certs' }));
874
+ const jwks = body(await h({ m: 'GET', p: '/oauth2/v3/certs' })) as Jwks;
875
+ const kid = jwks.keys[0]!.kid;
876
+ return Object.keys(pem).length === 1 && typeof pem[kid] === 'string' && String(pem[kid]).includes('-----BEGIN PUBLIC KEY-----');
877
+ })),
878
+ todo('googleoauth.idtoken.key_rotation', 'idtoken', 'Google publishes several keys and rotates them; the twin serves exactly one', 'api', 'common'),
879
+ todo('googleoauth.certs.v1_x509_certificate', 'idtoken', '/oauth2/v1/certs serves X.509 CERTIFICATES; the twin serves the bare public key PEM', 'api', 'niche'),
880
+ todo('googleoauth.idtoken.iss_bare_host_variant', 'idtoken', 'Google also issues `iss: accounts.google.com` (no scheme); the twin always uses the URL form', 'api', 'niche'),
881
+
882
+ // ── Granular / incremental consent ───────────────────────────────────────────────────────────
883
+ done('googleoauth.granular.declined_scope_excluded', 'granular', 'A scope the user unticks is NOT in the grant, the redirect\'s scope, or the token', 'api', 'core', () =>
884
+ withRoot(async (h) => {
885
+ const scope = 'openid email https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/drive.file';
886
+ // Tick only the calendar scope; drive.file is declined.
887
+ const flow = await consentFlow(h, { params: { scope }, tick: ['https://www.googleapis.com/auth/calendar.readonly'] });
888
+ const granted = qp(flow.redirect, 'scope') ?? '';
889
+ const token = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: flow.code!, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT }) });
890
+ if (!granted.includes('calendar.readonly') || granted.includes('drive.file')) return false;
891
+ if (!String(body(token).scope).includes('calendar.readonly') || String(body(token).scope).includes('drive.file')) return false;
892
+
893
+ // …and the GRANT, which is the clause the title makes and the one that was false: an earlier
894
+ // version unioned the prior scopes in unconditionally, so a decline stayed in the grant and a
895
+ // later `include_granted_scopes=true` handed the scope back WITHOUT the user re-ticking it.
896
+ // Grant everything first, then decline one, then ask incrementally — the resurrection path.
897
+ await consentFlow(h, { params: { scope } });
898
+ const declineRound = await consentFlow(h, { params: { scope }, tick: ['https://www.googleapis.com/auth/calendar.readonly'] });
899
+ if (String(qp(declineRound.redirect, 'scope')).includes('drive.file')) return false;
900
+ const incremental = await consentFlow(h, { params: { scope: 'openid', include_granted_scopes: 'true' } });
901
+ const unioned = qp(incremental.redirect, 'scope') ?? '';
902
+ if (!unioned.includes('calendar.readonly') || unioned.includes('drive.file')) return false;
903
+
904
+ // …and the SAME-REQUEST case, which the across-requests test above structurally cannot see:
905
+ // decline a scope on a screen that ALSO carries include_granted_scopes=true. An earlier
906
+ // version computed the token's scope set from the PRE-removal prior scopes, so the scope the
907
+ // user unticked on that very screen was handed straight back — leaving an access token whose
908
+ // scope set was a strict superset of the grant that authorised it (§9 round two, MAJOR).
909
+ const both = 'openid https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/calendar.readonly';
910
+ await consentFlow(h, { params: { scope: both }, sub: GRACE.sub });
911
+ const sameRequest = await consentFlow(h, {
912
+ params: { scope: both, include_granted_scopes: 'true' },
913
+ sub: GRACE.sub,
914
+ tick: ['https://www.googleapis.com/auth/calendar.readonly'],
915
+ });
916
+ const echoed = qp(sameRequest.redirect, 'scope') ?? '';
917
+ if (echoed.includes('drive.file')) return false;
918
+ const sameToken = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'authorization_code', code: sameRequest.code!, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT }) });
919
+ return ok(sameToken) && !String(body(sameToken).scope).includes('drive.file')
920
+ && String(body(sameToken).scope).includes('calendar.readonly');
921
+ })),
922
+ done('googleoauth.granular.oidc_scopes_not_declinable', 'granular', 'openid/email/profile ride with signing in — they survive even when nothing is ticked', 'api', 'common', () =>
923
+ withRoot(async (h) => {
924
+ const flow = await consentFlow(h, { params: { scope: 'openid email profile https://www.googleapis.com/auth/drive.file' }, tick: [] });
925
+ const granted = qp(flow.redirect, 'scope') ?? '';
926
+ return granted === 'openid email profile' && !granted.includes('drive.file');
927
+ })),
928
+ done('googleoauth.granular.all_declined_is_denial', 'granular', 'Unticking every scope on a granular screen is access_denied, not an empty grant', 'api', 'common', () =>
929
+ withRoot(async (h) => {
930
+ // TWO product scopes, so Google's granular rule actually shows checkboxes to untick.
931
+ const flow = await consentFlow(h, { params: { scope: 'https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/calendar.readonly' }, tick: [] });
932
+ return flow.redirect.status === 302 && qp(flow.redirect, 'error') === 'access_denied' && flow.code === null;
933
+ })),
934
+ done('googleoauth.granular.rule_governs_the_screen', 'granular', 'The per-scope checkboxes appear ONLY when Google\'s granular rule applies — and the grant follows the screen', 'api', 'core', () =>
935
+ withRoot(async (h) => {
936
+ // Google documents the rule: checkboxes when (a sign-in scope + a product scope) or (two or
937
+ // more product scopes). One scope, or sign-in scopes alone, is all-or-nothing. An integration
938
+ // asking for exactly one product scope can therefore NEVER get a partial grant, and a twin
939
+ // that offered a checkbox there would invent a code path production never produces.
940
+ const single = await consentFlow(h, { params: { scope: 'https://www.googleapis.com/auth/drive.file' }, tick: [] });
941
+ const signInOnly = await consentFlow(h, { params: { scope: 'openid email profile' }, tick: [] });
942
+ const mixed = await consentFlow(h, { params: { scope: 'openid https://www.googleapis.com/auth/drive.file' }, tick: [] });
943
+ return (qp(single.redirect, 'scope') ?? '') === 'https://www.googleapis.com/auth/drive.file'
944
+ && (qp(signInOnly.redirect, 'scope') ?? '') === 'openid email profile'
945
+ // …and where the rule DOES apply, unticking really removes the scope.
946
+ && (qp(mixed.redirect, 'scope') ?? '') === 'openid';
947
+ })),
948
+ done('googleoauth.granular.incremental_authorization', 'granular', 'include_granted_scopes=true unions this grant with everything previously granted', 'api', 'common', () =>
949
+ withRoot(async (h) => {
950
+ await consentFlow(h, { params: { scope: 'openid https://www.googleapis.com/auth/calendar.readonly' } });
951
+ const second = await consentFlow(h, { params: { scope: 'openid https://www.googleapis.com/auth/drive.file', include_granted_scopes: 'true' } });
952
+ const granted = qp(second.redirect, 'scope') ?? '';
953
+ // …and WITHOUT the flag the third grant is narrow again, so the union is the flag's doing.
954
+ const third = await consentFlow(h, { params: { scope: 'openid https://www.googleapis.com/auth/drive.file' } });
955
+ const narrow = qp(third.redirect, 'scope') ?? '';
956
+ return granted.includes('calendar.readonly') && granted.includes('drive.file')
957
+ && !narrow.includes('calendar.readonly') && narrow.includes('drive.file');
958
+ })),
959
+ done('googleoauth.granular.unknown_scope_accepted', 'granular', 'An unrecognised scope string is ACCEPTED (Google does not curate them) and echoed back', 'api', 'common', () =>
960
+ withRoot(async (h) => {
961
+ const weird = 'https://www.googleapis.com/auth/some-future-product.readonly';
962
+ const flow = await consentFlow(h, { params: { scope: `openid ${weird}` }, tick: [weird] });
963
+ return (qp(flow.redirect, 'scope') ?? '').includes(weird) && !!flow.code;
964
+ })),
965
+
966
+ // ── The dirty-state verify (behaviour that only breaks OVER prior state) ─────────────────────
967
+ done('googleoauth.grants.regrant_after_revoke', 'grants', 'DIRTY STATE: revoke a grant, re-consent, and the NEW tokens work while the old stay dead', 'api', 'core', () =>
968
+ withRoot(async (h) => {
969
+ // Every other verify starts from a fresh root and therefore cannot see this class of bug:
970
+ // a sticky revocation tombstone that poisons the re-grant, or a re-grant that resurrects
971
+ // the revoked tokens.
972
+ const first = await fullFlow(h, { params: offline });
973
+ const oldAccess = String(first.tokens.access_token);
974
+ const oldRefresh = String(first.tokens.refresh_token);
975
+ const revoked = await h({ m: 'POST', p: '/revoke', b: `token=${oldRefresh}` });
976
+ if (revoked.status !== 200) return false;
977
+ // The old credentials are dead in BOTH directions.
978
+ const deadUser = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${oldAccess}` } });
979
+ const deadRefresh = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'refresh_token', refresh_token: oldRefresh, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET }) });
980
+ if (deadUser.status !== 401 || deadRefresh.status !== 400) return false;
981
+ // Re-consent over that dirty state mints working credentials again — and crucially WITHOUT
982
+ // `prompt=consent`, which would force a refresh token unconditionally and route around the
983
+ // defect. Google treats a re-consent after revocation as a FIRST authorization, so an offline
984
+ // request must get a refresh token back; an earlier version left the grant row standing after
985
+ // revocation and silently withheld one (§9 round one).
986
+ const second = await fullFlow(h, { params: { access_type: 'offline' } });
987
+ if (typeof second.tokens.refresh_token !== 'string') return false;
988
+ const newAccess = String(second.tokens.access_token);
989
+ const liveUser = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${newAccess}` } });
990
+ // …and the OLD ones are still dead afterwards (no resurrection).
991
+ const stillDead = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${oldAccess}` } });
992
+ // …and the revoked refresh token is still dead, so the new grant did not resurrect the old one.
993
+ const oldRefreshAgain = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'refresh_token', refresh_token: oldRefresh, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET }) });
994
+ return second.status === 200 && newAccess !== oldAccess && liveUser.status === 200
995
+ && body(liveUser).sub === ADA.sub && stillDead.status === 401 && oldRefreshAgain.status === 400;
996
+ })),
997
+ done('googleoauth.grants.per_account_isolation', 'grants', 'Two accounts consenting to one client keep SEPARATE grants and tokens', 'api', 'common', () =>
998
+ withRoot(async (h) => {
999
+ const ada = await fullFlow(h, { sub: ADA.sub });
1000
+ const grace = await fullFlow(h, { sub: GRACE.sub });
1001
+ const adaInfo = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${ada.tokens.access_token}` } });
1002
+ const graceInfo = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${grace.tokens.access_token}` } });
1003
+ // Revoking one must not touch the other — the grant is per (client, user).
1004
+ await h({ m: 'POST', p: '/revoke', b: `token=${ada.tokens.access_token}` });
1005
+ const adaAfter = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${ada.tokens.access_token}` } });
1006
+ const graceAfter = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${grace.tokens.access_token}` } });
1007
+ return body(adaInfo).sub === ADA.sub && body(graceInfo).sub === GRACE.sub
1008
+ && adaAfter.status === 401 && graceAfter.status === 200 && body(graceAfter).sub === GRACE.sub;
1009
+ })),
1010
+
1011
+ done('googleoauth.ids.pull_after_local_create_no_collision', 'grants', 'ID COLLISION: a pull over a locally registered client of the SAME client_id leaves it BEHAVIOURALLY intact (no confidential\u2192public downgrade)', 'connector', 'core', () =>
1012
+ withRoot(async () => {
1013
+ // This vendor is unusually exposed to the collision class §9 names: the connector pulls rows
1014
+ // keyed by the REAL client_id and the REAL account `sub`, which are exactly the ids an
1015
+ // operator registers locally. A pulled client carries `secret: null` and
1016
+ // `clientType: 'installed'`, so a clobber would DOWNGRADE a confidential client — after which
1017
+ // the app's real secret is rejected and a secret-less caller is accepted.
1018
+ //
1019
+ // WHAT THIS ACTUALLY PINS, stated honestly (§9 round two): the kernel folds local actions OVER
1020
+ // the observed mirror unconditionally, so this direction is safe BY CONSTRUCTION rather than
1021
+ // by anything this pack does. That is exactly why it is worth a capability — the property is
1022
+ // load-bearing here, it is invisible in this pack's own source, and nothing else in the pack
1023
+ // would notice if the kernel's fold order ever changed.
1024
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-collide-'));
1025
+ try {
1026
+ const azp = FAKE_TOKENINFO.azp;
1027
+ const h = (m: string, p: string, b?: string) =>
1028
+ handleGoogleOAuthTwinRequest({ method: m, path: p, ...(b !== undefined ? { body: b } : {}), root, occurredAt: AT });
1029
+ // LOCAL first, then PULL over it.
1030
+ await h('POST', '/_twin/clients', JSON.stringify({ client_id: azp, client_secret: 'GOCSPX-mine', name: 'My Real App', redirect_uris: [REDIRECT] }));
1031
+ await syncGoogleOAuthFromReal(fakeExecute, { root });
1032
+ // The local registration must survive, and be BEHAVIOURALLY intact: a code minted for it
1033
+ // still requires the real secret, which is the property a downgrade would destroy.
1034
+ const page = await h('GET', authUrl({ client_id: azp }));
1035
+ if (!String(page.body).includes('My Real App')) return false;
1036
+ const rid = AUTH_REQUEST_RE.exec(String(page.body))?.[1] ?? '';
1037
+ const decision = await h('POST', '/_twin/consent', form({ auth_request: rid, sub: ADA.sub, decision: 'allow' }));
1038
+ const code = new URL(decision.headers?.location ?? 'http://x.test/').searchParams.get('code') ?? '';
1039
+ const withSecret = await h('POST', '/token', form({ grant_type: 'authorization_code', code, client_id: azp, client_secret: 'GOCSPX-mine', redirect_uri: REDIRECT }));
1040
+ const page2 = await h('GET', authUrl({ client_id: azp }));
1041
+ const rid2 = AUTH_REQUEST_RE.exec(String(page2.body))?.[1] ?? '';
1042
+ const d2 = await h('POST', '/_twin/consent', form({ auth_request: rid2, sub: ADA.sub, decision: 'allow' }));
1043
+ const code2 = new URL(d2.headers?.location ?? 'http://x.test/').searchParams.get('code') ?? '';
1044
+ const withoutSecret = await h('POST', '/token', form({ grant_type: 'authorization_code', code: code2, client_id: azp, redirect_uri: REDIRECT }));
1045
+ return withSecret.status === 200 && withoutSecret.status === 401;
1046
+ } finally {
1047
+ rmSync(root, { recursive: true, force: true });
1048
+ }
1049
+ })),
1050
+ done('googleoauth.ids.local_create_after_pull_no_collision', 'grants', 'ID COLLISION, the other direction: a pulled client is UNUSABLE until registered, and registering over it takes effect', 'connector', 'core', () =>
1051
+ withRoot(async () => {
1052
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-collide2-'));
1053
+ try {
1054
+ // The second half of this pair is NOT the mirror image of the first — under the kernel's
1055
+ // fold there is no mirror image. What is genuinely this pack's own behaviour is the FIRST
1056
+ // assertion below: a pulled-only client is UNUSABLE, because tokeninfo discloses no redirect
1057
+ // URIs and the twin refuses to invent one (§9 round two).
1058
+ const azp = FAKE_TOKENINFO.azp;
1059
+ const sub = FAKE_USERINFO.sub;
1060
+ const h = (m: string, p: string, b?: string) =>
1061
+ handleGoogleOAuthTwinRequest({ method: m, path: p, ...(b !== undefined ? { body: b } : {}), root, occurredAt: AT });
1062
+ // PULL FIRST. A pulled client discloses no redirect URIs (tokeninfo does not return any), so
1063
+ // it is correctly UNUSABLE for a consent flow — the twin refuses with redirect_uri_mismatch
1064
+ // rather than inventing a registration. That refusal is the honest state, and it is what a
1065
+ // clobber in this direction would silently replace.
1066
+ await syncGoogleOAuthFromReal(fakeExecute, { root });
1067
+ const beforeRegistration = await h('GET', authUrl({ client_id: azp }));
1068
+ if (beforeRegistration.status !== 302) return false;
1069
+ const errorTarget = new URL(loc(beforeRegistration));
1070
+ if (decodeAuthError(errorTarget.searchParams.get('authError') ?? '')?.code !== 'redirect_uri_mismatch') return false;
1071
+
1072
+ // …then REGISTER over it. The local action must win over the pulled observation.
1073
+ await h('POST', '/_twin/clients', JSON.stringify({ client_id: azp, client_secret: 'GOCSPX-later', name: 'Registered Later', redirect_uris: [REDIRECT] }));
1074
+ const page = await h('GET', authUrl({ client_id: azp }));
1075
+ if (page.status !== 200) return false;
1076
+ if (!html(page).includes('Registered Later') || html(page).includes('Pulled client')) return false;
1077
+
1078
+ // …and the PULLED PERSONA is a real account that can consent through it, keyed by the real
1079
+ // Google `sub` the pull brought in — a local consent must not overwrite a pulled subject.
1080
+ if (!html(page).includes(FAKE_USERINFO.email)) return false;
1081
+ const rid = AUTH_REQUEST_RE.exec(html(page))?.[1] ?? '';
1082
+ const decision = await h('POST', '/_twin/consent', form({ auth_request: rid, sub, decision: 'allow' }));
1083
+ const code = new URL(decision.headers?.location ?? 'http://x.test/').searchParams.get('code') ?? '';
1084
+ const token = await h('POST', '/token', form({ grant_type: 'authorization_code', code, client_id: azp, client_secret: 'GOCSPX-later', redirect_uri: REDIRECT }));
1085
+ return token.status === 200
1086
+ && decodeJwt(String((token.body as Body).id_token)).payload.sub === sub
1087
+ && decodeJwt(String((token.body as Body).id_token)).payload.email === FAKE_USERINFO.email;
1088
+ } finally {
1089
+ rmSync(root, { recursive: true, force: true });
1090
+ }
1091
+ })),
1092
+ // ── userinfo ─────────────────────────────────────────────────────────────────────────────────
1093
+ done('googleoauth.userinfo.get', 'userinfo', 'GET /v1/userinfo returns the OIDC claim set for the bearer\'s subject', 'api', 'core', () =>
1094
+ withRoot(async (h) => {
1095
+ const flow = await fullFlow(h, {});
1096
+ const r = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${flow.tokens.access_token}` } });
1097
+ const u = body(r);
1098
+ return ok(r) && u.sub === ADA.sub && u.email === ADA.email && u.email_verified === true
1099
+ && u.name === ADA.name && u.given_name === ADA.givenName && u.picture === ADA.picture;
1100
+ })),
1101
+ done('googleoauth.userinfo.legacy_path', 'userinfo', 'GET /oauth2/v3/userinfo — the www.googleapis.com alias serves the same claims', 'api', 'common', () =>
1102
+ withRoot(async (h) => {
1103
+ const flow = await fullFlow(h, {});
1104
+ const r = await h({ m: 'GET', p: '/oauth2/v3/userinfo', h: { authorization: `Bearer ${flow.tokens.access_token}` } });
1105
+ return ok(r) && body(r).sub === ADA.sub && body(r).email === ADA.email;
1106
+ })),
1107
+ done('googleoauth.userinfo.post_method', 'userinfo', 'POST /v1/userinfo is accepted as well as GET (OIDC Core §5.3.1)', 'api', 'niche', () =>
1108
+ withRoot(async (h) => {
1109
+ const flow = await fullFlow(h, {});
1110
+ const r = await h({ m: 'POST', p: '/v1/userinfo', b: '', h: { authorization: `Bearer ${flow.tokens.access_token}` } });
1111
+ return ok(r) && body(r).sub === ADA.sub;
1112
+ })),
1113
+ done('googleoauth.userinfo.unauthenticated_two_hosts', 'userinfo', 'A bad credential is 401 on BOTH hosts — with the DIFFERENT error bodies each host really returns', 'api', 'core', () =>
1114
+ withRoot(async (h) => {
1115
+ // The trap: openidconnect.googleapis.com answers `invalid_request`/"Invalid Credentials"
1116
+ // while www.googleapis.com answers `invalid_token`/"Invalid Value" for the same failure.
1117
+ // Normalising the two would hide a real difference an integration can trip over.
1118
+ const noneOidc = await h({ m: 'GET', p: '/v1/userinfo' });
1119
+ const bogusOidc = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: 'Bearer ya29.not-a-real-token' } });
1120
+ const bogusApis = await h({ m: 'GET', p: '/oauth2/v3/userinfo', h: { authorization: 'Bearer ya29.not-a-real-token' } });
1121
+ return noneOidc.status === 401 && err(noneOidc) === 'invalid_request' && body(noneOidc).error_description === 'Invalid Credentials'
1122
+ && bogusOidc.status === 401 && err(bogusOidc) === 'invalid_request'
1123
+ && bogusApis.status === 401 && err(bogusApis) === 'invalid_token' && body(bogusApis).error_description === 'Invalid Value';
1124
+ })),
1125
+ done('googleoauth.userinfo.insufficient_scope', 'userinfo', 'A token with no identity scope is REFUSED — never an empty or partial profile', 'api', 'common', () =>
1126
+ withRoot(async (h) => {
1127
+ const flow = await fullFlow(h, { params: { scope: 'https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/drive.file' } });
1128
+ const r = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${flow.tokens.access_token}` } });
1129
+ // Refusal, not a fake success. Google's EXACT answer for this case was not captured, so the
1130
+ // twin reuses the credential-rejection envelope rather than inventing a third shape —
1131
+ // `googleoauth.userinfo.insufficient_scope_shape` is the filed todo.
1132
+ const okToken = await fullFlow(h, { params: { scope: 'openid email' } });
1133
+ const good = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${okToken.tokens.access_token}` } });
1134
+ return r.status === 401 && body(r).sub === undefined && body(r).email === undefined
1135
+ && good.status === 200 && body(good).sub === ADA.sub;
1136
+ })),
1137
+ done('googleoauth.userinfo.claims_follow_scope', 'userinfo', 'userinfo claims are filtered by granted scope, exactly as the id_token\'s are', 'api', 'common', () =>
1138
+ withRoot(async (h) => {
1139
+ const emailOnly = await fullFlow(h, { params: { scope: 'openid email' } });
1140
+ const r = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${emailOnly.tokens.access_token}` } });
1141
+ return ok(r) && body(r).sub === ADA.sub && body(r).email === ADA.email && body(r).name === undefined;
1142
+ })),
1143
+ done('googleoauth.userinfo.expired_token', 'userinfo', 'An access token past its 3600s life → 401, not a stale profile', 'api', 'common', () =>
1144
+ withRoot(async (h) => {
1145
+ const flow = await fullFlow(h, {});
1146
+ const later = new Date(Date.parse(AT) + 3600_000).toISOString();
1147
+ const r = await h({ m: 'GET', p: '/v1/userinfo', at: later, h: { authorization: `Bearer ${flow.tokens.access_token}` } });
1148
+ const stillFine = await h({ m: 'GET', p: '/v1/userinfo', at: new Date(Date.parse(AT) + 1000).toISOString(), h: { authorization: `Bearer ${flow.tokens.access_token}` } });
1149
+ return r.status === 401 && stillFine.status === 200;
1150
+ })),
1151
+
1152
+ // ── revoke ───────────────────────────────────────────────────────────────────────────────────
1153
+ done('googleoauth.revoke.post', 'revoke', 'POST /revoke with a form-encoded token → 200, an empty body, and the token really stops working', 'api', 'core', () =>
1154
+ withRoot(async (h) => {
1155
+ // A `200 {}` is exactly what a DEAD handler returns, so status+shape alone is not a check —
1156
+ // the mutation gate caught this verify surviving a behaviourally-empty twin. The teeth are the
1157
+ // EFFECT: the revoked token must stop working, and an unrelated token must not.
1158
+ const victim = await fullFlow(h, { params: offline });
1159
+ const bystander = await fullFlow(h, { sub: GRACE.sub, params: offline });
1160
+ const before = await h({ m: 'GET', p: `/tokeninfo?access_token=${victim.tokens.access_token}` });
1161
+ if (!ok(before)) return false;
1162
+
1163
+ const r = await h({ m: 'POST', p: '/revoke', b: `token=${victim.tokens.refresh_token}` });
1164
+ if (r.status !== 200 || Object.keys(body(r)).length !== 0) return false;
1165
+
1166
+ const after = await h({ m: 'GET', p: `/tokeninfo?access_token=${victim.tokens.access_token}` });
1167
+ const refreshed = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'refresh_token', refresh_token: String(victim.tokens.refresh_token), client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET }) });
1168
+ const other = await h({ m: 'GET', p: `/tokeninfo?access_token=${bystander.tokens.access_token}` });
1169
+ return after.status === 400 && err(after) === 'invalid_token'
1170
+ && refreshed.status === 400 && err(refreshed) === 'invalid_grant'
1171
+ && ok(other) && body(other).sub === GRACE.sub;
1172
+ })),
1173
+ done('googleoauth.revoke.get', 'revoke', 'GET /revoke?token= is accepted too', 'api', 'common', () =>
1174
+ withRoot(async (h) => {
1175
+ const flow = await fullFlow(h, {});
1176
+ const r = await h({ m: 'GET', p: `/revoke?token=${flow.tokens.access_token}` });
1177
+ const after = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${flow.tokens.access_token}` } });
1178
+ return r.status === 200 && after.status === 401;
1179
+ })),
1180
+ done('googleoauth.revoke.whole_grant', 'revoke', 'Revoking EITHER token kills the WHOLE grant — access and refresh together', 'api', 'core', () =>
1181
+ withRoot(async (h) => {
1182
+ const flow = await fullFlow(h, { params: offline });
1183
+ // Revoke the ACCESS token; the REFRESH token must die with it.
1184
+ const r = await h({ m: 'POST', p: '/revoke', b: `token=${flow.tokens.access_token}` });
1185
+ const refresh = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'refresh_token', refresh_token: String(flow.tokens.refresh_token), client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET }) });
1186
+ return r.status === 200 && refresh.status === 400 && err(refresh) === 'invalid_grant';
1187
+ })),
1188
+ done('googleoauth.revoke.unknown_token', 'revoke', 'An unknown token → 400 invalid_token "Token expired or revoked"', 'api', 'common', () =>
1189
+ withRoot(async (h) => {
1190
+ const r = await h({ m: 'POST', p: '/revoke', b: 'token=ya29.never-existed' });
1191
+ const missing = await h({ m: 'POST', p: '/revoke', b: '' });
1192
+ return r.status === 400 && err(r) === 'invalid_token' && body(r).error_description === 'Token expired or revoked'
1193
+ && missing.status === 400 && err(missing) === 'invalid_request';
1194
+ })),
1195
+
1196
+ // ── tokeninfo ────────────────────────────────────────────────────────────────────────────────
1197
+ done('googleoauth.tokeninfo.access_token', 'tokeninfo', 'GET /tokeninfo?access_token= describes the token (aud/azp/sub/scope/exp, as STRINGS)', 'api', 'common', () =>
1198
+ withRoot(async (h) => {
1199
+ const flow = await fullFlow(h, { params: offline });
1200
+ const r = await h({ m: 'GET', p: `/tokeninfo?access_token=${flow.tokens.access_token}` });
1201
+ const t = body(r);
1202
+ return ok(r) && t.aud === DEFAULT_CLIENT_ID && t.azp === DEFAULT_CLIENT_ID && t.sub === ADA.sub
1203
+ && t.scope === 'openid email profile' && t.email === ADA.email
1204
+ // Google returns exp/expires_in as STRINGS on this endpoint, not numbers.
1205
+ && typeof t.exp === 'string' && typeof t.expires_in === 'string' && t.access_type === 'offline';
1206
+ })),
1207
+ done('googleoauth.tokeninfo.invalid', 'tokeninfo', 'An unknown, revoked or expired token → 400 invalid_token "Invalid Value"', 'api', 'common', () =>
1208
+ withRoot(async (h) => {
1209
+ const flow = await fullFlow(h, {});
1210
+ const unknown = await h({ m: 'GET', p: '/tokeninfo?access_token=ya29.nope' });
1211
+ await h({ m: 'POST', p: '/revoke', b: `token=${flow.tokens.access_token}` });
1212
+ const revoked = await h({ m: 'GET', p: `/tokeninfo?access_token=${flow.tokens.access_token}` });
1213
+ const none = await h({ m: 'GET', p: '/tokeninfo' });
1214
+ return unknown.status === 400 && err(unknown) === 'invalid_token' && body(unknown).error_description === 'Invalid Value'
1215
+ && revoked.status === 400 && none.status === 400 && err(none) === 'invalid_request';
1216
+ })),
1217
+ todo('googleoauth.tokeninfo.id_token', 'tokeninfo', 'GET /tokeninfo?id_token= — the id_token branch (its exact field set is not grounded yet)', 'api', 'niche'),
1218
+ // Two gaps this pack's own code and README already CITE by id. Filing them is not bookkeeping:
1219
+ // an id referenced in a comment but absent from the manifest is a gap that reads as tracked and
1220
+ // is tracked nowhere.
1221
+ todo('googleoauth.errors.authError_captured_literal', 'errors', 'Pin the authError protobuf against a CAPTURED base64url literal from a real ?authError= — the wire format is reproduced from a decode and is self-consistent, but nothing in the pack proves it byte-for-byte', 'api', 'common'),
1222
+ todo('googleoauth.errors.error_page_http_status', 'errors', 'The HTTP status Google serves /signin/oauth/error with (the twin serves 200 and carries the named status in the payload)', 'api', 'niche'),
1223
+ todo('googleoauth.userinfo.insufficient_scope_shape', 'userinfo', 'Google\'s exact status + body for a VALID token that holds no identity scope (the twin refuses with the credential-rejection envelope)', 'api', 'niche'),
1224
+
1225
+ // ── The service-account (two-legged) grant ───────────────────────────────────────────────────
1226
+ done('googleoauth.serviceaccount.jwt_bearer_access_token', 'serviceaccount', 'grant_type=jwt-bearer mints an access token (what google-auth-library does before any Vertex/GCS call)', 'api', 'core', () =>
1227
+ withRoot(async (h) => {
1228
+ const assertion = `e30.${Buffer.from(JSON.stringify({ iss: 'svc@twin.iam.gserviceaccount.com', scope: 'https://www.googleapis.com/auth/cloud-platform' })).toString('base64url')}.sig`;
1229
+ const r = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }) });
1230
+ const t = body(r);
1231
+ if (!ok(r) || !String(t.access_token).startsWith('ya29.') || t.token_type !== 'Bearer' || t.expires_in !== 3600) return false;
1232
+ if (t.scope !== 'https://www.googleapis.com/auth/cloud-platform' || t.id_token !== undefined) return false;
1233
+ // …and the token is USABLE, which is the whole point of a credential. An earlier version
1234
+ // minted a bare string and persisted nothing, so the twin's own /tokeninfo refused the token
1235
+ // it had just issued (§9 round one).
1236
+ const info = await h({ m: 'GET', p: `/tokeninfo?access_token=${t.access_token}` });
1237
+ // A service account is not a consenting user, so userinfo correctly refuses it — there is no
1238
+ // account row and no grant behind it.
1239
+ const profile = await h({ m: 'GET', p: '/v1/userinfo', h: { authorization: `Bearer ${t.access_token}` } });
1240
+ return ok(info) && body(info).scope === 'https://www.googleapis.com/auth/cloud-platform'
1241
+ && profile.status === 401;
1242
+ })),
1243
+ done('googleoauth.serviceaccount.jwt_bearer_id_token', 'serviceaccount', 'A target_audience assertion mints a REAL RS256 id_token for that audience (getIdTokenClient)', 'api', 'core', () =>
1244
+ withRoot(async (h) => {
1245
+ const assertion = `e30.${Buffer.from(JSON.stringify({ iss: 'svc@twin.iam.gserviceaccount.com', target_audience: 'https://my-service.run.app' })).toString('base64url')}.sig`;
1246
+ const r = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }) });
1247
+ const jwks = body(await h({ m: 'GET', p: '/oauth2/v3/certs' })) as Jwks;
1248
+ const v = verifyJwtWithJwks(String(body(r).id_token), jwks, { now: AT_SECONDS + 10 });
1249
+ // gemini-twin.ts's §googleauth stub returned an `alg: none` token here. This one is signed
1250
+ // and verifies, which is the whole reason the surface moved into a pack of its own.
1251
+ return ok(r) && body(r).access_token === undefined && v.valid === true
1252
+ && v.payload!.aud === 'https://my-service.run.app'
1253
+ && v.payload!.email === 'svc@twin.iam.gserviceaccount.com'
1254
+ // `sub` is the account's NUMERIC unique id, NOT its address — an earlier version put the
1255
+ // e-mail in both (§9 round one). The twin derives a stable 21-digit stand-in, because it has
1256
+ // no directory to look the real number up in.
1257
+ && typeof v.payload!.sub === 'string'
1258
+ && /^\d{21}$/.test(String(v.payload!.sub))
1259
+ && v.payload!.sub !== v.payload!.email;
1260
+ })),
1261
+ done('googleoauth.serviceaccount.missing_assertion', 'serviceaccount', 'jwt-bearer with no assertion → 400 invalid_request; an unparseable one → invalid_grant', 'api', 'common', () =>
1262
+ withRoot(async (h) => {
1263
+ const none = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer' }) });
1264
+ const junk = await h({ m: 'POST', p: '/token', b: form({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: 'not.a.jwt' }) });
1265
+ // Captured live: a MALFORMED assertion is `invalid_request` / "Bad Request" — the string a
1266
+ // widely-repeated assumption misattributes to `invalid_grant` on the code grant.
1267
+ return none.status === 400 && err(none) === 'invalid_request'
1268
+ && junk.status === 400 && err(junk) === 'invalid_request' && body(junk).error_description === 'Bad Request';
1269
+ })),
1270
+ // §9 round one was right: verifying an RS256 assertion needs the
1271
+ // PUBLIC half, which a world's own service-account key file carries and the twin could register
1272
+ // exactly as it registers OAuth clients. The honest label is "no service-account key registry
1273
+ // yet", which is a todo.
1274
+ todo('googleoauth.serviceaccount.assertion_signature_verified', 'serviceaccount', 'Verify the jwt-bearer assertion\'s RS256 signature against a registered service-account public key (the twin decodes but does not verify today)', 'api', 'common'),
1275
+ todo('googleoauth.serviceaccount.domain_wide_delegation', 'serviceaccount', 'The `sub` impersonation claim (Workspace domain-wide delegation)', 'api', 'niche'),
1276
+
1277
+ // ── Errors / twin-level behaviour ────────────────────────────────────────────────────────────
1278
+ done('googleoauth.errors.unmodeled_route_404', 'errors', 'An unmodelled path is REFUSED with a 404 error envelope — never a fake success', 'api', 'core', () =>
1279
+ withRoot(async (h) => {
1280
+ // Paths the twin does not serve, including two Google DOES serve and this pack files as
1281
+ // todos (`/gsi/client`, `/o/oauth2/postmessageRelay`) — refusing those is the point: an
1282
+ // unmodelled operation must fail, not be answered by a twin that cannot serve it.
1283
+ const e = (r: GoogleOAuthResponse) => (body(r).error ?? {}) as Body;
1284
+ const paths = ['/o/oauth2/postmessageRelay', '/gsi/client', '/device/code', '/oauth2/v2/userinfo', '/oauth2/v2/certs', '/no/such/thing'];
1285
+ for (const path of paths) {
1286
+ const r = await h({ m: 'GET', p: path });
1287
+ if (r.status !== 404 || e(r).code !== 404 || e(r).status !== 'NOT_FOUND') return false;
1288
+ }
1289
+ // …and a path the twin DOES serve is not swept up by the same branch.
1290
+ return (await h({ m: 'GET', p: '/oauth2/v3/certs' })).status === 200;
1291
+ })),
1292
+ done('googleoauth.errors.wrong_method', 'errors', 'A wrong METHOD on a modelled endpoint is 405, not a 404 or a silent success', 'api', 'common', () =>
1293
+ withRoot(async (h) => {
1294
+ const r = await h({ m: 'DELETE', p: '/token' });
1295
+ return r.status === 405 && ((body(r).error ?? {}) as Body).status === 'FAILED_PRECONDITION';
1296
+ })),
1297
+ done('googleoauth.errors.malformed_request_guard', 'errors', 'A malformed request becomes the vendor 400 envelope, never an unhandled throw or a non-vendor 500', 'api', 'common', () =>
1298
+ withRoot(async (h) => {
1299
+ // The route-boundary guard's real job. Basic credentials are percent-DECODED (RFC 6749
1300
+ // §2.3.1 requires it), so a broken escape sequence reaches `decodeURIComponent` and throws a
1301
+ // URIError — which must surface as Google's own error body, not as an HTML 500.
1302
+ const broken = Buffer.from('%E0%A4%A:secret').toString('base64');
1303
+ const r = await h({ m: 'POST', p: '/token', h: { authorization: `Basic ${broken}` }, b: form({ grant_type: 'authorization_code', code: 'x', redirect_uri: REDIRECT }) });
1304
+ if (r.status !== 400 || err(r) !== 'invalid_request') return false;
1305
+ // …and the CLASS is closed: a spread of hostile inputs each answers with a modelled status
1306
+ // and a vendor-shaped body rather than escaping the handler.
1307
+ const hostile: Step[] = [
1308
+ { m: 'POST', p: '/token', b: '%%%' },
1309
+ { m: 'POST', p: '/token', b: 'grant_type=authorization_code&code=%E0%A4%A' },
1310
+ { m: 'GET', p: '/o/oauth2/v2/auth?client_id=%E0%A4%A' },
1311
+ { m: 'GET', p: `/tokeninfo?access_token=${'x'.repeat(5000)}` },
1312
+ { m: 'POST', p: '/_twin/clients', b: 'not json at all' },
1313
+ { m: 'POST', p: '/revoke', b: 'token=%E0%A4%A' },
1314
+ ];
1315
+ for (const step of hostile) {
1316
+ const res = await h(step);
1317
+ // A 302 to Google's own error page is a MODELLED answer, not an escape: its payload is the
1318
+ // vendor-shaped part, and the body is deliberately empty.
1319
+ // The predicate has to be one a DEAD handler fails. `!!body && typeof body === 'object'`
1320
+ // was not: `return {}` satisfies it for every step (§9 round one). A modelled refusal names
1321
+ // itself — an OAuth `error` code, a Google-API `error.status`, a 302 to the error page, or a
1322
+ // rendered "Error <n>: <code>" line — and `{}` has none of those.
1323
+ const b = res.body as Record<string, unknown> | string | null;
1324
+ const named = res.status === 302
1325
+ ? !!res.headers?.location && new URL(res.headers.location).pathname === ERROR_PAGE_PATH
1326
+ : typeof b === 'string'
1327
+ ? /Error \d{3}: [a-z_]+/.test(text(b))
1328
+ // A JSON refusal must ALSO be a 4xx. Without the status check a saboteur answering one
1329
+ // hard-coded `{ error: … }` at 200 read as a modelled refusal for every step
1330
+ // (§9 round two).
1331
+ : res.status >= 400
1332
+ && (typeof (b as Record<string, unknown>)?.error === 'string'
1333
+ || typeof ((b as Record<string, any>)?.error?.status) === 'string');
1334
+ if (res.status < 200 || res.status >= 500 || !named) return false;
1335
+ }
1336
+ return true;
1337
+ })),
1338
+ done('googleoauth.twin.read_only_refuses_writes', 'errors', 'A read-only twin refuses every credential-minting leg but still serves the stateless reads', 'api', 'common', () =>
1339
+ withRoot(async () => {
1340
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-ro-'));
1341
+ try {
1342
+ const ro = (method: string, path: string, b?: string) =>
1343
+ handleGoogleOAuthTwinRequest({ method, path, ...(b !== undefined ? { body: b } : {}), root, occurredAt: AT, readOnly: true });
1344
+ const auth = await ro('GET', authUrl({}));
1345
+ const token = await ro('POST', '/token', form({ grant_type: 'refresh_token', refresh_token: 'x' }));
1346
+ const certs = await ro('GET', '/oauth2/v3/certs');
1347
+ const disc = await ro('GET', '/.well-known/openid-configuration');
1348
+ return auth.status === 405 && err(auth) === 'temporarily_unavailable'
1349
+ && token.status === 405
1350
+ && certs.status === 200 && disc.status === 200;
1351
+ } finally {
1352
+ rmSync(root, { recursive: true, force: true });
1353
+ }
1354
+ })),
1355
+
1356
+ // ── The consent SCREEN (the vendor's own UI — data-coupled, per ADDING_A_TWIN.md §6) ─────────
1357
+ done('googleoauth.ui.account_chooser', 'ui', 'Account chooser lists every seeded persona with its real name and address (data-coupled)', 'ui', 'core',
1358
+ uiDataCoupled<H, string>({
1359
+ withRoot,
1360
+ // Seed a THIRD persona through the twin's own write path, so the screen cannot be passing on
1361
+ // the two defaults alone.
1362
+ seed: (h) => h({ m: 'POST', p: '/_twin/accounts', b: JSON.stringify({ sub: '100000000000000000003', email: 'katherine.johnson@nasa.example', name: 'Katherine Johnson', given_name: 'Katherine', family_name: 'Johnson' }) }),
1363
+ fetch: async (h) => html(await h({ m: 'GET', p: authUrl({}) })),
1364
+ assert: (markup) =>
1365
+ markup.includes('Choose an account')
1366
+ && markup.includes(ADA.name) && markup.includes(ADA.email)
1367
+ && markup.includes(GRACE.name) && markup.includes(GRACE.email)
1368
+ && markup.includes('Katherine Johnson') && markup.includes('katherine.johnson@nasa.example')
1369
+ // each account is a real submit button carrying its own sub — the click IS the choice
1370
+ && (markup.match(/class="account-row"/g) ?? []).length === 3
1371
+ && markup.includes('value="100000000000000000003"'),
1372
+ })),
1373
+ done('googleoauth.ui.app_identity', 'ui', 'The screen names the REGISTERED app, not a hardcoded one (data-coupled to the client registry)', 'ui', 'core',
1374
+ uiDataCoupled<H, string>({
1375
+ withRoot,
1376
+ seed: (h) => h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_id: 'seeded.apps.googleusercontent.com', client_secret: 'GOCSPX-seeded', name: 'Acme Scheduling', support_email: 'dev@acme.test', redirect_uris: [REDIRECT] }) }),
1377
+ fetch: async (h) => html(await h({ m: 'GET', p: authUrl({ client_id: 'seeded.apps.googleusercontent.com' }) })),
1378
+ assert: (markup) => markup.includes('Acme Scheduling') && markup.includes('to continue to') && !markup.includes('Twin Demo App'),
1379
+ })),
1380
+ done('googleoauth.ui.consent_screen', 'ui', 'The consent step names the app, the chosen account and every requested scope (data-coupled)', 'ui', 'core',
1381
+ uiDataCoupled<H, string>({
1382
+ withRoot,
1383
+ seed: (h) => h({ m: 'GET', p: authUrl({}) }),
1384
+ fetch: async (h) => {
1385
+ const a = await h({ m: 'GET', p: authUrl({ scope: 'openid email https://www.googleapis.com/auth/calendar.readonly' }) });
1386
+ const rid = AUTH_REQUEST_RE.exec(html(a))?.[1] ?? '';
1387
+ return html(await h({ m: 'GET', p: `/_twin/consent?auth_request=${rid}&sub=${ADA.sub}` }));
1388
+ },
1389
+ assert: (markup) =>
1390
+ markup.includes('wants access to your Google Account')
1391
+ && markup.includes('Twin Demo App')
1392
+ && markup.includes(ADA.email)
1393
+ // the scope SENTENCES Google shows, from the catalog — not the raw scope strings
1394
+ && markup.includes('See your primary Google Account email address')
1395
+ && markup.includes('See and download any calendar you can access using your Google Calendar')
1396
+ && markup.includes('Continue') && markup.includes('Cancel'),
1397
+ })),
1398
+ done('googleoauth.ui.scope_rows_render_real_component', 'ui', 'The shipped ScopeList component renders the twin\'s OWN scope rows over real state', 'ui', 'core',
1399
+ uiDataCoupled<H, ConsentView | null>({
1400
+ withRoot,
1401
+ seed: (h) => h({ m: 'GET', p: authUrl({ scope: 'openid https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/gmail.send' }) }),
1402
+ fetch: async (h) => {
1403
+ const a = await h({ m: 'GET', p: authUrl({ scope: 'openid https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/gmail.send' }) });
1404
+ const rid = AUTH_REQUEST_RE.exec(html(a))?.[1] ?? '';
1405
+ // The SAME state builder the served page uses — one renderer, no lookalike.
1406
+ return googleOAuthConsentState({ root: h.root, requestId: rid, sub: ADA.sub, origin: 'https://accounts.google.com' });
1407
+ },
1408
+ render: (view) => (view ? renderToStaticMarkup(createElement(ScopeList, { scopes: view.scopes })) : ''),
1409
+ assert: (view, markup) =>
1410
+ !!view && !!markup
1411
+ && view.scopes.length === 3
1412
+ && markup.includes('See, edit, create, and delete all of your Google Drive files')
1413
+ && markup.includes('Send email on your behalf')
1414
+ // the sensitivity tags Google shows, driven by the catalog
1415
+ && markup.includes('restricted') && markup.includes('sensitive')
1416
+ // openid is not declinable, so it is a hidden input; the two product scopes are checkboxes
1417
+ && (markup.match(/type="checkbox"/g) ?? []).length === 2
1418
+ && markup.includes('value="https://www.googleapis.com/auth/drive"'),
1419
+ })),
1420
+ done('googleoauth.ui.unknown_scope_shown_raw', 'ui', 'An unknown scope is shown with its RAW string and flagged — never a fabricated description', 'ui', 'common',
1421
+ uiDataCoupled<H, ConsentView | null>({
1422
+ withRoot,
1423
+ seed: (h) => h({ m: 'GET', p: authUrl({ scope: 'openid https://www.googleapis.com/auth/not-a-real-google-scope' }) }),
1424
+ fetch: async (h) => {
1425
+ const a = await h({ m: 'GET', p: authUrl({ scope: 'openid https://www.googleapis.com/auth/not-a-real-google-scope' }) });
1426
+ const rid = AUTH_REQUEST_RE.exec(html(a))?.[1] ?? '';
1427
+ return googleOAuthConsentState({ root: h.root, requestId: rid, sub: ADA.sub, origin: 'https://accounts.google.com' });
1428
+ },
1429
+ render: (view) => (view ? renderToStaticMarkup(createElement(ScopeList, { scopes: view.scopes })) : ''),
1430
+ assert: (view, markup) => {
1431
+ const unknown = view?.scopes.find((s) => s.scope.endsWith('not-a-real-google-scope'));
1432
+ return !!unknown && unknown.known === false && unknown.label === unknown.scope
1433
+ && !!markup && markup.includes('not in the twin')
1434
+ && markup.includes('https://www.googleapis.com/auth/not-a-real-google-scope');
1435
+ },
1436
+ })),
1437
+ done('googleoauth.ui.unverified_notice', 'ui', 'The "Google hasn\'t verified this app" notice tracks the client\'s real verified flag', 'ui', 'common',
1438
+ uiDataCoupled<H, { unverified: string; verified: string }>({
1439
+ withRoot,
1440
+ seed: (h) => h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_id: 'verified.apps.googleusercontent.com', client_secret: 'GOCSPX-v', name: 'Verified App', verified: true, redirect_uris: [REDIRECT] }) }),
1441
+ fetch: async (h) => {
1442
+ const unverifiedFlow = await h({ m: 'GET', p: authUrl({ login_hint: ADA.email }) });
1443
+ const verifiedFlow = await h({ m: 'GET', p: authUrl({ client_id: 'verified.apps.googleusercontent.com', login_hint: ADA.email }) });
1444
+ return { unverified: html(unverifiedFlow), verified: html(verifiedFlow) };
1445
+ },
1446
+ assert: (pages) =>
1447
+ pages.unverified.includes('Google hasn') && pages.unverified.includes('verified this app')
1448
+ && !pages.verified.includes('verified this app')
1449
+ && pages.verified.includes('Verified App'),
1450
+ })),
1451
+ done('googleoauth.ui.chooser_renders_real_component', 'ui', 'The shipped AccountChooser component renders the twin\'s OWN accounts (server-render parity)', 'ui', 'common',
1452
+ uiDataCoupled<H, ConsentView | null>({
1453
+ withRoot,
1454
+ seed: (h) => h({ m: 'POST', p: '/_twin/accounts', b: JSON.stringify({ sub: '100000000000000000009', email: 'margaret.hamilton@mit.example', name: 'Margaret Hamilton', given_name: 'Margaret' }) }),
1455
+ fetch: async (h) => {
1456
+ const a = await h({ m: 'GET', p: authUrl({}) });
1457
+ const rid = AUTH_REQUEST_RE.exec(html(a))?.[1] ?? '';
1458
+ return googleOAuthConsentState({ root: h.root, requestId: rid, origin: 'https://accounts.google.com' });
1459
+ },
1460
+ render: (view) => (view ? renderToStaticMarkup(createElement(AccountChooser, { view })) : ''),
1461
+ assert: (view, markup) =>
1462
+ !!view && view.step === 'choose' && view.accounts.length === 3
1463
+ && !!markup && markup.includes('margaret.hamilton@mit.example') && markup.includes('Margaret Hamilton')
1464
+ && markup.includes('value="100000000000000000009"')
1465
+ && markup.includes(`action="https://accounts.google.com/_twin/consent"`),
1466
+ })),
1467
+ done('googleoauth.ui.consent_renders_real_component', 'ui', 'The shipped ConsentScreen component renders the chosen account + form over real state', 'ui', 'common',
1468
+ uiDataCoupled<H, ConsentView | null>({
1469
+ withRoot,
1470
+ seed: (h) => h({ m: 'GET', p: authUrl({ scope: 'openid email' }) }),
1471
+ fetch: async (h) => {
1472
+ const a = await h({ m: 'GET', p: authUrl({ scope: 'openid email' }) });
1473
+ const rid = AUTH_REQUEST_RE.exec(html(a))?.[1] ?? '';
1474
+ return googleOAuthConsentState({ root: h.root, requestId: rid, sub: GRACE.sub, origin: 'https://accounts.google.com' });
1475
+ },
1476
+ render: (view) => (view ? renderToStaticMarkup(createElement(ConsentScreen, { view })) : ''),
1477
+ assert: (view, markup) =>
1478
+ !!view && view.step === 'consent' && view.account?.sub === GRACE.sub
1479
+ && !!markup && markup.includes(GRACE.email) && markup.includes('Twin Demo App')
1480
+ && markup.includes('value="deny"') && markup.includes('value="allow"')
1481
+ && markup.includes(`value="${view!.requestId}"`),
1482
+ })),
1483
+ done('googleoauth.ui.error_page', 'ui', 'The error page renders Google\'s headline, wording and "Error <status>: <code>" line over the real failure', 'ui', 'common', () =>
1484
+ withRoot(async (h) => {
1485
+ // SERVED path: the real 302 → /signin/oauth/error → the rendered page, driven end to end, with
1486
+ // the app name resolved from the client registry rather than hardcoded.
1487
+ const served = text((await errorPageFor(h, authUrl({ redirect_uri: 'https://evil.test/x' }))).markup);
1488
+ // COMPONENT path: the SAME exported component, rendered directly — one renderer, no lookalike.
1489
+ const direct = text(errorPageHtml({ status: 401, code: 'invalid_client', summary: 'Access blocked: Authorization Error', detail: 'The OAuth client was not found.', appName: 'Twin Demo App' }));
1490
+ const bare = text(renderToStaticMarkup(createElement(ErrorPage, { status: 400, code: 'redirect_uri_mismatch', summary: 'S', detail: 'D' })));
1491
+ return served.includes('Error 400')
1492
+ && served.includes('redirect_uri_mismatch')
1493
+ && served.includes('redirect_uri=https://evil.test/x')
1494
+ && served.includes("Access blocked: This app's request is invalid")
1495
+ && served.includes('flowName=GeneralOAuthFlow')
1496
+ && served.includes('If you are a developer of Twin Demo App, see error details.')
1497
+ && direct.includes('Error 401') && direct.includes('The OAuth client was not found.')
1498
+ && bare.includes('Error 400') && bare.includes('redirect_uri_mismatch')
1499
+ // the generic fallback wording when the failing request named no known client
1500
+ && bare.includes('If you are a developer of this app, see error details.');
1501
+ })),
1502
+ done('googleoauth.ui.client_transpiles', 'ui', 'The consent client bundles for the browser and ships its granular-consent enhancement', 'ui', 'niche', async () => {
1503
+ // The sanctioned STRUCTURAL fallback (the aws.s3.ui.transpiles precedent): bundling has no
1504
+ // seedable state, so this asserts the REAL bundle — the COMMITTED one the twin serves
1505
+ // (googleoauth-consent-client.gen.ts; consent-clients.test.ts proves it is a fresh build of
1506
+ // the TSX) — rather than faking data-coupling. Static literals only — a template-literal
1507
+ // class name would be erased by the minifier.
1508
+ const js = CONSENT_CLIENT_JS;
1509
+ return js.length > 10_000 && js.includes('Select all') && js.includes('granular-consent-controls') && js.includes('scope-check');
1510
+ }),
1511
+ todo('googleoauth.ui.two_step_account_add', 'ui', 'The "Use another account" sign-in step of Google\'s real chooser', 'ui', 'niche'),
1512
+ todo('googleoauth.ui.branding', 'ui', 'The developer\'s configured app logo, homepage and privacy-policy links on the screen', 'ui', 'common'),
1513
+
1514
+ // ── Sign-in itself: genuinely impossible for a local twin ────────────────────────────────────
1515
+
1516
+ // ── Connector ────────────────────────────────────────────────────────────────────────────────
1517
+ done('googleoauth.connector.pull_identity', 'connector', 'Pull the operator\'s real identity + client from tokeninfo/userinfo into the projection', 'connector', 'core', () =>
1518
+ withRoot(async (h) => {
1519
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-pull-'));
1520
+ try {
1521
+ const result = await syncGoogleOAuthFromReal(fakeExecute, { root, occurredAt: AT });
1522
+ if (result.observed !== 3 || result.deltasAppended < 1) return false;
1523
+ // The pulled persona is REACHABLE THROUGH THE PROTOCOL — a pull that only writes rows a
1524
+ // consumer can never see has not pulled anything useful.
1525
+ const page = await handleGoogleOAuthTwinRequest({ method: 'GET', path: authUrl({}), root, origin: DEMO_ORIGIN, occurredAt: AT });
1526
+ return String(page.body).includes('real.person@example.com') && String(page.body).includes('Real Person');
1527
+ } finally {
1528
+ rmSync(root, { recursive: true, force: true });
1529
+ }
1530
+ })),
1531
+ done('googleoauth.connector.pull_idempotent', 'connector', 'A re-pull of identical state appends NOTHING (shadow-diff dedupe)', 'connector', 'core', () =>
1532
+ withRoot(async () => {
1533
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-pull2-'));
1534
+ try {
1535
+ // NO pinned `occurredAt`: the connector's own MOVING pollTimestamp() is used, so a zero
1536
+ // delta can only come from the shadow diff. Pinning both pulls to one millisecond let the
1537
+ // kernel's content+timestamp dedupe produce the same answer for a different reason, which
1538
+ // is a confound rather than a proof (§9 round one).
1539
+ const first = await syncGoogleOAuthFromReal(fakeExecute, { root });
1540
+ const second = await syncGoogleOAuthFromReal(fakeExecute, { root });
1541
+ return first.deltasAppended > 0 && second.observed === 3 && second.deltasAppended === 0;
1542
+ } finally {
1543
+ rmSync(root, { recursive: true, force: true });
1544
+ }
1545
+ })),
1546
+ done('googleoauth.connector.reverting_value_still_lands', 'connector', 'A vendor value that REVERTS across polls still lands (moving poll timestamp, not a pinned one)', 'connector', 'common', () =>
1547
+ withRoot(async () => {
1548
+ // The phantom-delta hazard: with a PINNED occurredAt the kernel hashes an observed event over
1549
+ // (occurredAt + post-state), so an A→B→A sequence collides with its own first observation and
1550
+ // syncPull reports a delta while the projection keeps the stale value.
1551
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-revert-'));
1552
+ try {
1553
+ let name = 'Real Person';
1554
+ const varying: GoogleOAuthExecute = async (_m, path) => {
1555
+ if (path.startsWith('/tokeninfo')) return { ...FAKE_TOKENINFO };
1556
+ if (path === '/v1/userinfo') return { ...FAKE_USERINFO, name };
1557
+ return {};
1558
+ };
1559
+ await pullGoogleOAuthIdentity(varying, root);
1560
+ name = 'Renamed Person';
1561
+ await pullGoogleOAuthIdentity(varying, root);
1562
+ name = 'Real Person'; // …back to the ORIGINAL value
1563
+ await pullGoogleOAuthIdentity(varying, root);
1564
+ const page = await handleGoogleOAuthTwinRequest({ method: 'GET', path: authUrl({}), root, origin: DEMO_ORIGIN, occurredAt: AT });
1565
+ // The projection must show the value the LAST poll observed, not the middle one.
1566
+ return String(page.body).includes('Real Person') && !String(page.body).includes('Renamed Person');
1567
+ } finally {
1568
+ rmSync(root, { recursive: true, force: true });
1569
+ }
1570
+ })),
1571
+ done('googleoauth.connector.refused_pull_throws', 'connector', 'A REFUSED pull throws — it is never mapped to an empty account folded over real state', 'connector', 'core', () =>
1572
+ withRoot(async () => {
1573
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-refuse-'));
1574
+ try {
1575
+ // First a good pull, so there IS real observed state to protect.
1576
+ await syncGoogleOAuthFromReal(fakeExecute, { root, occurredAt: AT });
1577
+ // Google answers a bad token on tokeninfo with an error envelope under HTTP 200 — a status
1578
+ // check alone cannot tell refusal from genuine emptiness.
1579
+ const counting = countingFetch(() => jsonResponse({ error: 'invalid_token', error_description: 'Invalid Value' }));
1580
+ const execute = liveGoogleOAuthExecute('ya29.stale', { fetchImpl: counting.impl, budgetOptions: { path: join(root, 'ledger.json') } });
1581
+ let threw = false;
1582
+ try {
1583
+ await syncGoogleOAuthFromReal(execute, { root });
1584
+ } catch {
1585
+ threw = true;
1586
+ }
1587
+ // …and the previously observed persona is still there.
1588
+ const page = await handleGoogleOAuthTwinRequest({ method: 'GET', path: authUrl({}), root, origin: DEMO_ORIGIN, occurredAt: AT });
1589
+ return threw && counting.calls() === 1 && String(page.body).includes('real.person@example.com');
1590
+ } finally {
1591
+ rmSync(root, { recursive: true, force: true });
1592
+ }
1593
+ })),
1594
+ done('googleoauth.connector.unmapped_host_refused', 'connector', 'The live executor REFUSES a path it has no Google host for (no accidental raw call)', 'connector', 'common', () =>
1595
+ withRoot(async () => {
1596
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-unmapped-'));
1597
+ try {
1598
+ const counting = countingFetch(() => jsonResponse({}));
1599
+ const execute = liveGoogleOAuthExecute('ya29.x', { fetchImpl: counting.impl, budgetOptions: { path: join(root, 'ledger.json') } });
1600
+ let threw = false;
1601
+ try {
1602
+ await execute('GET', '/some/unmapped/path');
1603
+ } catch {
1604
+ threw = true;
1605
+ }
1606
+ // The count is the proof: nothing reached the network.
1607
+ return threw && counting.calls() === 0;
1608
+ } finally {
1609
+ rmSync(root, { recursive: true, force: true });
1610
+ }
1611
+ })),
1612
+ done('googleoauth.connector.pull_invents_nothing', 'connector', 'A pulled client records what tokeninfo did NOT disclose — no fabricated secret, name or verification status', 'connector', 'common', () =>
1613
+ withRoot(async () => {
1614
+ const mapped = mapTokenInfoClient(FAKE_TOKENINFO);
1615
+ const name = String(mapped.fields.name);
1616
+ return mapped.type === 'oauth_client'
1617
+ && mapped.id === FAKE_TOKENINFO.azp
1618
+ && mapped.fields.secret === null
1619
+ && mapped.fields.clientType === 'installed'
1620
+ && Array.isArray(mapped.fields.redirectUris) && (mapped.fields.redirectUris as unknown[]).length === 0
1621
+ // The name must SAY it is a pulled placeholder, not masquerade as the app's real name. An
1622
+ // earlier version used `clientId.split('-')[0]`, putting the bare numeric project prefix on
1623
+ // the consent screen as if it were a name (§9 round one).
1624
+ && name.startsWith('Pulled client ') && name !== FAKE_TOKENINFO.azp.split('-')[0]
1625
+ // …and the verified flag, which drives the unverified-app interstitial, must record
1626
+ // "unknown" rather than claiming a verification tokeninfo never disclosed.
1627
+ && mapped.fields.verified === null;
1628
+ })),
1629
+ done('googleoauth.connector.rate_budget_fail_closed', 'connector', 'The client-side budget REFUSES past the ceiling with the fake\'s call count unchanged', 'connector', 'core', () =>
1630
+ withRoot(async () => {
1631
+ const dir = mkdtempSync(join(tmpdir(), 'googleoauth-budget-'));
1632
+ try {
1633
+ const counting = countingFetch(() => jsonResponse({ sub: 'x', email: 'a@b.test' }));
1634
+ const execute = liveGoogleOAuthExecute('ya29.budget', { fetchImpl: counting.impl, budgetOptions: { path: join(dir, 'ledger.json') } });
1635
+ // ceiling 60 / weight 2 = 30 calls, then the 31st must THROW without calling.
1636
+ const allowed = GOOGLEOAUTH_BUDGET_CEILING / GOOGLEOAUTH_CALL_WEIGHTS.other;
1637
+ for (let i = 0; i < allowed; i++) await execute('GET', '/v1/userinfo');
1638
+ if (counting.calls() !== allowed) return false;
1639
+ let refused = false;
1640
+ try {
1641
+ await execute('GET', '/v1/userinfo');
1642
+ } catch (e) {
1643
+ refused = e instanceof GoogleOAuthBudgetError;
1644
+ }
1645
+ // "It threw" is not proof — the UNCHANGED count is what shows nothing reached the vendor.
1646
+ return refused && counting.calls() === allowed;
1647
+ } finally {
1648
+ rmSync(dir, { recursive: true, force: true });
1649
+ }
1650
+ })),
1651
+ done('googleoauth.connector.rate_budget_persists', 'connector', 'The ledger is durable: a BRAND-NEW client gets no fresh allowance', 'connector', 'core', () =>
1652
+ withRoot(async () => {
1653
+ const dir = mkdtempSync(join(tmpdir(), 'googleoauth-budget2-'));
1654
+ const path = join(dir, 'ledger.json');
1655
+ try {
1656
+ const first = countingFetch(() => jsonResponse({}));
1657
+ const a = liveGoogleOAuthExecute('ya29.same', { fetchImpl: first.impl, budgetOptions: { path } });
1658
+ const allowed = GOOGLEOAUTH_BUDGET_CEILING / GOOGLEOAUTH_CALL_WEIGHTS.other;
1659
+ for (let i = 0; i < allowed; i++) await a('GET', '/v1/userinfo');
1660
+ const second = countingFetch(() => jsonResponse({}));
1661
+ const b = liveGoogleOAuthExecute('ya29.same', { fetchImpl: second.impl, budgetOptions: { path } });
1662
+ let refused = false;
1663
+ try {
1664
+ await b('GET', '/v1/userinfo');
1665
+ } catch (e) {
1666
+ refused = e instanceof GoogleOAuthBudgetError;
1667
+ }
1668
+ return first.calls() === allowed && refused && second.calls() === 0;
1669
+ } finally {
1670
+ rmSync(dir, { recursive: true, force: true });
1671
+ }
1672
+ })),
1673
+ done('googleoauth.connector.rate_budget_revoke_weight', 'connector', 'POST /revoke is priced at 10, so it exhausts the ceiling proportionally sooner', 'connector', 'common', () =>
1674
+ withRoot(async () => {
1675
+ const dir = mkdtempSync(join(tmpdir(), 'googleoauth-budget3-'));
1676
+ try {
1677
+ const counting = countingFetch(() => jsonResponse({}));
1678
+ const execute = liveGoogleOAuthExecute('ya29.weights', { fetchImpl: counting.impl, budgetOptions: { path: join(dir, 'ledger.json') } });
1679
+ const allowed = GOOGLEOAUTH_BUDGET_CEILING / GOOGLEOAUTH_CALL_WEIGHTS.revoke; // 6, not 30
1680
+ for (let i = 0; i < allowed; i++) await execute('POST', '/revoke');
1681
+ let refused = false;
1682
+ try {
1683
+ await execute('POST', '/revoke');
1684
+ } catch (e) {
1685
+ refused = e instanceof GoogleOAuthBudgetError;
1686
+ }
1687
+ return counting.calls() === allowed && allowed === 6 && refused;
1688
+ } finally {
1689
+ rmSync(dir, { recursive: true, force: true });
1690
+ }
1691
+ })),
1692
+ done('googleoauth.connector.budget_unbypassable', 'connector', 'The live executor REFUSES to construct around a duck-typed, subclassed or proxied budget', 'connector', 'common', () =>
1693
+ withRoot(async () => {
1694
+ const dir = mkdtempSync(join(tmpdir(), 'googleoauth-budget4-'));
1695
+ try {
1696
+ const path = join(dir, 'ledger.json');
1697
+ const refuses = (budget: unknown) => {
1698
+ try {
1699
+ liveGoogleOAuthExecute('ya29.x', { budget: budget as GoogleOAuthBudget, budgetOptions: { path } });
1700
+ return false;
1701
+ } catch {
1702
+ return true;
1703
+ }
1704
+ };
1705
+ const duck = { checkBudget: () => ({}), recordCall: () => {} };
1706
+ class Sneaky extends GoogleOAuthBudget {
1707
+ override checkBudget(): never {
1708
+ throw new Error('never called');
1709
+ }
1710
+ }
1711
+ const proxied = new Proxy(new GoogleOAuthBudget({ path }), { get: (t, k) => (k === 'checkBudget' ? () => ({}) : Reflect.get(t, k)) });
1712
+ // …and a REAL, unmodified budget is still accepted, so this is not "everything is refused".
1713
+ let acceptsReal = false;
1714
+ try {
1715
+ liveGoogleOAuthExecute('ya29.x', { budget: new GoogleOAuthBudget({ path }), budgetOptions: { path } });
1716
+ acceptsReal = true;
1717
+ } catch {
1718
+ acceptsReal = false;
1719
+ }
1720
+ return refuses(duck) && refuses(new Sneaky({ path })) && refuses(proxied) && acceptsReal;
1721
+ } finally {
1722
+ rmSync(dir, { recursive: true, force: true });
1723
+ }
1724
+ })),
1725
+ todo('googleoauth.connector.push', 'connector', 'Push local grants/clients to real Google — impossible today: there is no OAuth write API (clients are Cloud-Console objects, grants are myaccount.google.com)', 'connector', 'niche'),
1726
+ todo('googleoauth.connector.pull_granted_scopes_per_app', 'connector', 'Pull the full list of apps a user has authorised (myaccount.google.com/permissions has no API)', 'connector', 'niche'),
1727
+
1728
+ // ── Google Identity Services (the MODERN "Sign in with Google" web surface) ──────────────────
1729
+ // Enumerated top-down from developers.google.com/identity/gsi/web, NOT from what the twin built.
1730
+ // This is a whole second product on accounts.google.com that this pack does not model at all: the
1731
+ // classic authorization-code flow above is the protocol, GIS is the drop-in library most new
1732
+ // integrations actually adopt. Filing it as todos is what keeps the denominator honest — a pack
1733
+ // that enumerated only the protocol it implemented would read as near-complete while missing the
1734
+ // half of the surface a greenfield app is most likely to use.
1735
+ todo('googleoauth.gsi.client_library', 'gsi', 'GET /gsi/client — the Sign in with Google JavaScript library itself', 'api', 'core'),
1736
+ todo('googleoauth.gsi.one_tap_prompt', 'gsi', 'One Tap: google.accounts.id.prompt() and its /gsi/iframe/select surface', 'api', 'core'),
1737
+ todo('googleoauth.gsi.credential_response', 'gsi', 'The One Tap `credential` callback (a JWT id_token posted to the page)', 'api', 'core'),
1738
+ todo('googleoauth.gsi.render_button', 'gsi', 'google.accounts.id.renderButton — the rendered Sign in with Google button', 'ui', 'core'),
1739
+ todo('googleoauth.gsi.g_id_onload', 'gsi', 'The declarative `g_id_onload` / `g_id_signin` HTML element API', 'ui', 'common'),
1740
+ todo('googleoauth.gsi.login_uri_post', 'gsi', 'data-login_uri: the credential POSTed form-encoded to the app\'s own endpoint with a g_csrf_token cookie', 'api', 'common'),
1741
+ todo('googleoauth.gsi.token_client', 'gsi', 'google.accounts.oauth2.initTokenClient — the implicit token flow for browser apps', 'api', 'common'),
1742
+ todo('googleoauth.gsi.code_client', 'gsi', 'google.accounts.oauth2.initCodeClient — the popup/redirect auth-code flow', 'api', 'common'),
1743
+ todo('googleoauth.gsi.postmessage_relay', 'gsi', 'GET /o/oauth2/postmessageRelay — the popup-mode postMessage bridge', 'api', 'niche'),
1744
+ todo('googleoauth.gsi.revoke_js', 'gsi', 'google.accounts.id.revoke / oauth2.revoke and their /gsi endpoints', 'api', 'niche'),
1745
+ todo('googleoauth.gsi.intermediate_iframe', 'gsi', 'The intermediate-iframe support file for automatic One Tap cancellation', 'api', 'niche'),
1746
+ todo('googleoauth.gsi.fedcm', 'gsi', 'FedCM migration: the browser-mediated identity API GIS is moving One Tap onto', 'api', 'common'),
1747
+
1748
+ // ── Cross-Account Protection (RISC) ──────────────────────────────────────────────────────────
1749
+ todo('googleoauth.risc.register_endpoint', 'risc', 'POST risc.googleapis.com/v1beta/stream:update — register a receiver for security-event tokens (a HOST this pack does not claim in VENDOR_HOSTS, so adopting it means claiming it too)', 'api', 'niche'),
1750
+ todo('googleoauth.risc.security_event_tokens', 'risc', 'Deliver RISC SETs (sessions-revoked, account-disabled, tokens-revoked) to the app', 'connector', 'niche'),
1751
+ todo('googleoauth.risc.verify_jwt', 'risc', 'The RISC SET is a JWT signed by Google\'s RISC key set (a different JWKS)', 'api', 'niche'),
1752
+
1753
+ // ── The device / limited-input flow ──────────────────────────────────────────────────────────
1754
+ todo('googleoauth.device.code_request', 'device', 'POST /device/code — mint a device_code + user_code + verification_url', 'api', 'common'),
1755
+ todo('googleoauth.device.user_verification_screen', 'device', 'The accounts.google.com/device screen where a human types the user_code', 'ui', 'common'),
1756
+ todo('googleoauth.device.polling_errors', 'device', 'authorization_pending / slow_down / expired_token while the app polls', 'api', 'common'),
1757
+
1758
+ // ── Remaining protocol surface ───────────────────────────────────────────────────────────────
1759
+ todo('googleoauth.endpoints.legacy_revoke_path', 'errors', 'GET /o/oauth2/revoke — the legacy accounts.google.com revocation path', 'api', 'niche'),
1760
+ todo('googleoauth.endpoints.userinfo_v2', 'userinfo', 'GET /oauth2/v2/userinfo — the oldest userinfo alias (a different field set)', 'api', 'niche'),
1761
+ todo('googleoauth.authorize.approval_prompt_legacy', 'authorize', 'approval_prompt=force — the pre-`prompt` legacy parameter still in the wild', 'api', 'niche'),
1762
+ todo('googleoauth.authorize.include_granted_scopes_incremental_ui', 'authorize', 'The consent screen shows only the NEW scopes during incremental authorization', 'ui', 'common'),
1763
+ todo('googleoauth.accounts.multi_login_authuser', 'authorize', 'The `authuser` index and multi-login sessions (several accounts signed in at once)', 'api', 'common'),
1764
+ todo('googleoauth.token.client_assertion_auth', 'token', 'private_key_jwt client authentication (client_assertion / client_assertion_type)', 'api', 'niche'),
1765
+
1766
+ // ── Conformance ──────────────────────────────────────────────────────────────────────────────
1767
+ done('googleoauth.conformance.endpoint_probe', 'conformance', 'Every claimed endpoint answers a real probe with the OUTCOME a live handler produces', 'api', 'core', async () => {
1768
+ const report = await checkGoogleOAuthConformance();
1769
+ return report.ok && report.endpointsProbed === report.endpointsChecked && report.endpointsChecked >= 12 && report.violations.length === 0;
1770
+ }),
1771
+ ];
1772
+
1773
+ export async function googleoauthCapabilities(): Promise<CapabilityReport> {
1774
+ return checkCapabilities('googleoauth', GOOGLEOAUTH_CAPABILITIES);
1775
+ }