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