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