@volter/twin-googleoauth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +219 -0
  2. package/client/googleoauth-consent.css +207 -0
  3. package/client/googleoauth-consent.tsx +286 -0
  4. package/dist/client/googleoauth-consent.bundle.js +237 -0
  5. package/dist/client/googleoauth-consent.css +207 -0
  6. package/dist/client/googleoauth-consent.d.ts +88 -0
  7. package/dist/client/googleoauth-consent.js +94 -0
  8. package/dist/client/googleoauth-consent.tsx +286 -0
  9. package/dist/src/cli.d.ts +2 -0
  10. package/dist/src/cli.js +42 -0
  11. package/dist/src/googleoauth-autherror.d.ts +25 -0
  12. package/dist/src/googleoauth-autherror.js +144 -0
  13. package/dist/src/googleoauth-budget.d.ts +48 -0
  14. package/dist/src/googleoauth-budget.js +121 -0
  15. package/dist/src/googleoauth-capabilities.d.ts +3 -0
  16. package/dist/src/googleoauth-capabilities.js +1651 -0
  17. package/dist/src/googleoauth-conformance.d.ts +10 -0
  18. package/dist/src/googleoauth-conformance.js +426 -0
  19. package/dist/src/googleoauth-connector.d.ts +70 -0
  20. package/dist/src/googleoauth-connector.js +244 -0
  21. package/dist/src/googleoauth-consent-client.gen.d.ts +2 -0
  22. package/dist/src/googleoauth-consent-client.gen.js +10 -0
  23. package/dist/src/googleoauth-consent-ui.d.ts +25 -0
  24. package/dist/src/googleoauth-consent-ui.js +102 -0
  25. package/dist/src/googleoauth-jwt.d.ts +78 -0
  26. package/dist/src/googleoauth-jwt.js +183 -0
  27. package/dist/src/googleoauth-scopes.d.ts +36 -0
  28. package/dist/src/googleoauth-scopes.js +92 -0
  29. package/dist/src/googleoauth-server.d.ts +34 -0
  30. package/dist/src/googleoauth-server.js +89 -0
  31. package/dist/src/googleoauth-store.d.ts +78 -0
  32. package/dist/src/googleoauth-store.js +313 -0
  33. package/dist/src/googleoauth-twin.d.ts +53 -0
  34. package/dist/src/googleoauth-twin.js +1050 -0
  35. package/dist/src/index.d.ts +16 -0
  36. package/dist/src/index.js +102 -0
  37. package/package.json +75 -0
  38. package/src/cli.ts +41 -0
  39. package/src/googleoauth-autherror.ts +150 -0
  40. package/src/googleoauth-budget.ts +147 -0
  41. package/src/googleoauth-capabilities.ts +1775 -0
  42. package/src/googleoauth-conformance.ts +472 -0
  43. package/src/googleoauth-connector.ts +266 -0
  44. package/src/googleoauth-consent-client.gen.ts +10 -0
  45. package/src/googleoauth-consent-ui.ts +124 -0
  46. package/src/googleoauth-journey.uitest.ts +296 -0
  47. package/src/googleoauth-jwt.ts +207 -0
  48. package/src/googleoauth-scopes.ts +109 -0
  49. package/src/googleoauth-server.ts +101 -0
  50. package/src/googleoauth-store.ts +359 -0
  51. package/src/googleoauth-twin.ts +1207 -0
  52. package/src/index.ts +175 -0
package/README.md ADDED
@@ -0,0 +1,219 @@
1
+ # @volter/twin-googleoauth
2
+
3
+ A local, stateful, vendor-faithful twin of **Google OAuth 2.0 / OpenID Connect** — the consent
4
+ screen, the full authorization-code round trip, and real RS256 `id_token`s. Point an unmodified
5
+ OAuth client library at it and complete a whole sign-in flow offline.
6
+
7
+ ```bash
8
+ bun run packages/twin/googleoauth/src/cli.ts mirror
9
+ # googleoauth twin (OAuth 2.0 / OIDC) at http://127.0.0.1:54321
10
+ # consent screen: http://127.0.0.1:54321/o/oauth2/v2/auth?client_id=…&redirect_uri=…
11
+ ```
12
+
13
+ ## The catalog's first browser-facing pack
14
+
15
+ Every other twin in this repo answers a **machine**. This one answers a **human**.
16
+
17
+ The authorization-code flow is *defined* by a browser redirect: the app sends a person to
18
+ `accounts.google.com`, that person reads what is being asked and decides, and the browser is bounced
19
+ back with a code. A twin that skipped the screen and 302'd straight back with a code would not be a
20
+ twin of Google OAuth — it would be a **bypass**, and every bug that lives in the consent leg would
21
+ become invisible: an unregistered `redirect_uri`, a dropped `state`, a scope the user unticked, a
22
+ refresh token that never arrived because `access_type` was `online`.
23
+
24
+ So the twin **serves HTML at the vendor's real path**. The screen is server-rendered from the
25
+ twin's own kernel projection by the **same React components** the browser bundle ships
26
+ (`client/googleoauth-consent.tsx` + `src/googleoauth-consent-ui.ts`) — one renderer, so API↔UI
27
+ parity cannot drift. The whole flow is plain `<form>` submits: **no JavaScript is required to
28
+ complete an OAuth round trip** against this twin. The bundle only adds the granular-consent
29
+ "Select all" pill into a slot that is empty server-side. The bundle and stylesheet the twin serves
30
+ are **committed text** (`src/googleoauth-consent-client.gen.ts`, written from `client/` by
31
+ `bun scripts/consent-clients.ts` and drift-gated by `scripts/consent-clients.test.ts`): the serve path
32
+ reads constants and never runs a bundler.
33
+
34
+ **This is the vendor's own product UI, not a dashboard mirror** — but the mirror *discipline* applies
35
+ in full. Every UI capability is data-coupled to real twin state, the screens are driven by a
36
+ Playwright journey (`googleoauth-journey.uitest.ts`), and the mutation gate sabotages the screen's
37
+ state builder in an isolated phase.
38
+
39
+ ## Coverage
40
+
41
+ Partial and honest. The manifest (`src/googleoauth-capabilities.ts`) is the **real vendor surface**
42
+ as the denominator — authored top-down from Google's own OIDC discovery document and the four
43
+ first-party protocol guides, **not** from what the twin has built. It currently reads
44
+ **113 done / 164 total** (51 `todo`). The `todo`s are real Google surface this twin
45
+ does not model, most notably the whole **Google Identity Services** product (One Tap, the
46
+ Sign in with Google button, `initTokenClient`/`initCodeClient`), the **device / limited-input flow**,
47
+ the **implicit and hybrid** response types, and **RISC** cross-account protection.
48
+
49
+ ### What is real here
50
+
51
+ - **The complete authorization-code round trip.** Authorization request → account chooser → consent
52
+ screen → 302 to `redirect_uri` with `code`+`state`+`scope` → the code is redeemable **exactly
53
+ once** at the token endpoint → `refresh_token` grant. Consent, redemption and refresh are all
54
+ served by **one pack against one state root**, so the code minted at the screen is honoured at the
55
+ token endpoint by construction.
56
+ - **Real crypto.** The `id_token` is a genuine RS256 JWT signed by an RSA-2048 keypair generated on
57
+ first use and **persisted in kernel state**, whose public half the twin serves at
58
+ `/oauth2/v3/certs`. `google-auth-library`'s own `verifyIdToken` fetches that JWKS and verifies the
59
+ signature, issuer, audience and expiry itself. (The `clerk` precedent: real crypto or nothing.)
60
+ - **PKCE** `S256` and `plain`, verified with real SHA-256; the transform is pinned against RFC 7636
61
+ Appendix B's published test vector.
62
+ - **Exact redirect-URI matching**, with the documented loopback-IP port exception — and *only* that
63
+ exception. `localhost` and `127.0.0.1` are different registrations, and a trailing slash is a
64
+ different URI. The most common Google integration bug is only reproducible if the twin is as
65
+ strict as the vendor.
66
+ - **Granular consent** and **incremental authorization**: a user can untick individual product
67
+ scopes, and the grant carries only what survived — a decline genuinely *removes* the scope, so a
68
+ later `include_granted_scopes=true` cannot resurrect it. The checkbox screen appears only under
69
+ Google's documented rule (a sign-in scope mixed with a product one, or two or more product
70
+ scopes), so a single-scope request is all-or-nothing exactly as it is in production.
71
+ - **Google's real error-page behaviour** (see below).
72
+ - **The service-account grant** (`urn:ietf:params:oauth:grant-type:jwt-bearer`), so one token
73
+ endpoint answers every grant Google's does.
74
+
75
+ ### Error fidelity: the page, not the redirect
76
+
77
+ The single most-misunderstood thing about this surface, and the one that refuted an RFC-shaped first
78
+ implementation of this pack. RFC 6749 §4.1.2.1 says to bounce parameter errors back to the client.
79
+ **Google does not.** It 302s to its own page:
80
+
81
+ ```
82
+ https://accounts.google.com/signin/oauth/error?authError=<base64url protobuf>&flowName=GeneralOAuthFlow&client_id=…
83
+ ```
84
+
85
+ …which renders `Access blocked: …` and the `Error <status>: <code>` line every integrator ends up
86
+ searching for. The `authError` payload is a five-field protobuf — `{1: code, 2: message, 3: doc-url,
87
+ 4: http-status, 5: echoed-param}` — reproduced in `src/googleoauth-autherror.ts`, so the twin's
88
+ browser behaviour matches: the address bar really changes to the vendor's error page.
89
+
90
+ **Only two outcomes ever reach the caller's `redirect_uri`:** `access_denied` (the user pressed
91
+ Cancel) and `interaction_required` (`prompt=none` could not be satisfied silently). Everything else —
92
+ unknown `client_id`, unregistered `redirect_uri`, a missing or invalid parameter, a repeated
93
+ parameter — renders the page. For an untrusted `redirect_uri` this is a security property, not a
94
+ style choice: bouncing there would make the twin an open redirector.
95
+
96
+ Other error facts, grounded from a read-only live capture of the real endpoints (2026-08-20) rather
97
+ than from repetition:
98
+
99
+ | | |
100
+ |---|---|
101
+ | unknown client at the token endpoint | `401 {"error":"invalid_client","error_description":"The OAuth client was not found."}` |
102
+ | wrong client secret | `401 …"The provided client secret is invalid."` — a *different* message |
103
+ | malformed jwt-bearer assertion | `400 {"error":"invalid_request","error_description":"Bad Request"}` |
104
+ | userinfo, bad credential | `openidconnect.googleapis.com` → `invalid_request`/`Invalid Credentials`; `www.googleapis.com` → `invalid_token`/`Invalid Value`. **The two hosts disagree**, and the twin keeps them disagreeing |
105
+ | tokeninfo | `exp`, `expires_in` and `email_verified` come back as JSON **strings** |
106
+
107
+ The widely-repeated claim that `invalid_grant`'s `error_description` is `"Bad Request"` is **false** —
108
+ that string belongs to a malformed `invalid_request` on the jwt-bearer grant. The twin uses Google's
109
+ documented wording for `invalid_grant` instead.
110
+
111
+ ### Host claiming (path-aware, deliberately)
112
+
113
+ `accounts.google.com` is **not an API host**: it is Google's entire sign-in web property. The
114
+ injector entry (`packages/world-core/inject.cjs`, `VENDOR_HOSTS.googleoauth`) therefore
115
+ claims only the OAuth/OIDC paths this pack serves across four hosts, and everything else refuses
116
+ **loudly** rather than being answered by a twin that cannot serve it:
117
+
118
+ | host | claimed |
119
+ |---|---|
120
+ | `accounts.google.com` | `/o/oauth2/*auth*`, `/o/oauth2/token`, `/signin/oauth/error`, `/.well-known/openid-configuration`, `/_twin/*` |
121
+ | `oauth2.googleapis.com` | `/token`, `/oauth2/v4/token`, `/revoke`, `/tokeninfo` |
122
+ | `www.googleapis.com` | `/oauth2/v1/certs`, `/oauth2/v3/{certs,userinfo}` — shared with `youtube`, split by path |
123
+ | `openidconnect.googleapis.com` | all of it |
124
+
125
+ The pack is declared **before** the pack-less `googleauth` key, so a world running both routes the
126
+ token endpoint to this pack — whose token endpoint is a strict superset (it serves
127
+ `authorization_code` and `refresh_token`, which `googleauth` has no state for, *and* the jwt-bearer
128
+ exchange `googleauth` exists for, with a real RS256 `id_token` instead of an `alg: none` stub). A
129
+ world running only the `gemini` twin is unaffected.
130
+
131
+ ### Twin-only routes (not vendor surface, not counted)
132
+
133
+ `POST|GET /_twin/consent` (the Allow/Deny form target — Google's own consent form posts to an
134
+ undocumented internal endpoint, so there is no vendor path to be faithful to), `POST /_twin/clients`
135
+ and `POST /_twin/accounts` (registration and persona seeding — Google has no API for either). They
136
+ are clearly namespaced and deliberately **absent from the capability manifest**; counting them would
137
+ pad the denominator.
138
+
139
+ ### The twin authenticates nobody
140
+
141
+ The account chooser lists personas seeded into kernel state and picking one **is** the login — said
142
+ on the screen rather than hidden. There is no password, no second factor, no risk engine and no
143
+ Google-minted session cookie, because a local twin holds no Google credential and must never appear
144
+ to. App verification is a manual multi-week Google review, so the twin models only its **result**
145
+ (the `verified` flag on a client) and shows the unverified interstitial from it.
146
+
147
+ What the SID/SSID cookies *carry* is ordinary same-origin state a twin can hold — a chooser that
148
+ remembers you, the multi-login `authuser` index — and is filed as
149
+ `googleoauth.accounts.multi_login_authuser` (todo). Verifying a service-account assertion is
150
+ likewise a todo, not an impossibility: it needs the key's *public* half, which a world's own SA key
151
+ file carries and the twin could register exactly as it registers OAuth clients, so the honest label
152
+ is "no key registry yet" (`googleoauth.serviceaccount.assertion_signature_verified`). The twin
153
+ decodes the assertion to answer in the flow the caller used, and never pretends to have verified it.
154
+
155
+ ### Known deliberate deviations
156
+
157
+ - **`/oauth2/v1/certs` serves the public key PEM, not an X.509 certificate.** Google publishes
158
+ certificates there; the twin has no CA and refuses to fabricate a chain. Filed as
159
+ `googleoauth.certs.v1_x509_certificate`.
160
+ - **The token endpoint is form-encoded only.** A JSON body is not accepted, because there is no
161
+ first-party evidence that Google accepts one and an invented tolerance produces clients that only
162
+ work against the twin. Filed as `googleoauth.token.json_body_tolerance`.
163
+ - **The error page is served HTTP 200**, with the status it *names* travelling in the `authError`
164
+ payload. The real page's own HTTP status was not captured. Filed as
165
+ `googleoauth.errors.error_page_http_status`.
166
+ - **One signing key, never rotated.** Google publishes several and rotates them. Filed as
167
+ `googleoauth.idtoken.key_rotation`.
168
+ - **`iss` is always the URL form** (`https://accounts.google.com`). Google also issues the bare
169
+ `accounts.google.com`. Filed as `googleoauth.idtoken.iss_bare_host_variant`.
170
+
171
+ ## Fidelity
172
+
173
+ `src/googleoauth-sdk.integration.test.ts` drives **`google-auth-library`'s `OAuth2Client`
174
+ unmodified** — the class `googleapis`, `@googleapis/*` and every Google Cloud SDK build their auth
175
+ on — through `generateAuthUrl` → consent → `getToken` → `verifyIdToken` → `refreshAccessToken` →
176
+ `revokeToken`, plus its own `generateCodeVerifierAsync` PKCE helper.
177
+
178
+ **Unmodified means un-patched, not un-configured.** The client is pointed at the twin through the
179
+ SDK's own public `endpoints` option — exactly what a caller pointing at a private Google-compatible
180
+ IdP does. Nothing is monkey-patched and no request is hand-rolled to dodge the SDK.
181
+
182
+ > One wiring note worth knowing for *any* Google-compatible IdP: `verifyIdToken` defaults to the
183
+ > SDK's **PEM** certificate format, so it reads `oauth2FederatedSignonPemCertsUrl` — **not** the JWK
184
+ > URL. Configure only the JWK one and the SDK quietly fetches the real
185
+ > `www.googleapis.com/oauth2/v1/certs` and fails with `No pem found for envelope`, which looks like
186
+ > an IdP bug and is really a configuration one.
187
+
188
+ The **browser leg** — a human clicking Continue — is not something an SDK can do, so it is driven by
189
+ a real headless chromium in `src/googleoauth-journey.uitest.ts`, which then redeems the code the click
190
+ produced.
191
+
192
+ ## Rate budget
193
+
194
+ Google publishes **no scalar per-minute rate limit** for these endpoints. What it publishes for this
195
+ surface is quota of a different *shape* — 100 refresh tokens per Google Account per OAuth client
196
+ (the 101st silently invalidates the oldest), a 7-day refresh-token expiry for apps in testing status,
197
+ and a reputation-based new-user authorization limit with **no published numbers** (exceeding it gives
198
+ `Error 403: rate_limit_exceeded`). A token-count cap cannot anchor a rolling-window rate, so the
199
+ declaration does **not** claim it does and does **not** out-burst the kernel's undeclared fallback:
200
+ 60 weighted units / 60s at `defaultWeight: 2` is 30 calls a minute, exactly the fallback.
201
+
202
+ `POST /revoke` is priced at 10 — it destroys the *whole* grant on a real account (every access and
203
+ refresh token, and the grant record), which is a human blast radius rather than a throttle. It is
204
+ the only call priced above the default. That is a judgement call, and is stated as one.
205
+
206
+ ## Layout
207
+
208
+ ```
209
+ src/googleoauth-twin.ts the request handler (the protocol)
210
+ src/googleoauth-store.ts kernel-backed state + vendor-shaped id minting
211
+ src/googleoauth-jwt.ts real RS256 signing, JWKS, at_hash, PKCE S256
212
+ src/googleoauth-autherror.ts Google's authError protobuf + error-page URL
213
+ src/googleoauth-scopes.ts the scope catalog (consent-screen wording)
214
+ src/googleoauth-consent-ui.ts the consent screen's state builder + server render
215
+ client/googleoauth-consent.tsx the React components (server-rendered AND bundled)
216
+ src/googleoauth-consent-client.gen.ts GENERATED: the bundle + stylesheet as committed text (scripts/consent-clients.ts)
217
+ src/googleoauth-connector.ts pull over an injected client; push is an honest gap
218
+ src/googleoauth-budget.ts the rate-budget declaration
219
+ ```
@@ -0,0 +1,207 @@
1
+ /* Google's consent screen, as close as a twin gets without shipping the vendor's assets:
2
+ the Google Sans-ish system stack, the centred card on a grey field, the blue pill button. */
3
+ :root {
4
+ --g-blue: #1a73e8;
5
+ --g-text: #202124;
6
+ --g-muted: #5f6368;
7
+ --g-border: #dadce0;
8
+ --g-bg: #f1f3f4;
9
+ --g-red: #d93025;
10
+ }
11
+
12
+ * { box-sizing: border-box; }
13
+
14
+ body {
15
+ margin: 0;
16
+ min-height: 100vh;
17
+ display: flex;
18
+ align-items: flex-start;
19
+ justify-content: center;
20
+ padding: 48px 16px;
21
+ background: var(--g-bg);
22
+ color: var(--g-text);
23
+ font-family: 'Google Sans', Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
24
+ font-size: 14px;
25
+ line-height: 1.5;
26
+ }
27
+
28
+ .card {
29
+ width: 100%;
30
+ max-width: 450px;
31
+ background: #fff;
32
+ border: 1px solid var(--g-border);
33
+ border-radius: 8px;
34
+ padding: 48px 40px 36px;
35
+ }
36
+ .card-error { max-width: 512px; }
37
+
38
+ .g-mark {
39
+ font-size: 24px;
40
+ font-weight: 500;
41
+ letter-spacing: -0.5px;
42
+ margin-bottom: 16px;
43
+ }
44
+ .g-b { color: #4285f4; }
45
+ .g-r { color: #ea4335; }
46
+ .g-y { color: #fbbc05; }
47
+ .g-g { color: #34a853; }
48
+
49
+ .title {
50
+ font-size: 24px;
51
+ font-weight: 400;
52
+ line-height: 1.3;
53
+ margin: 0 0 8px;
54
+ }
55
+ .title .app-name { font-weight: 500; }
56
+
57
+ .subtitle {
58
+ margin: 0 0 24px;
59
+ color: var(--g-muted);
60
+ }
61
+
62
+ .account-list {
63
+ display: flex;
64
+ flex-direction: column;
65
+ border-top: 1px solid var(--g-border);
66
+ margin: 8px 0 16px;
67
+ }
68
+
69
+ .account-row {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 16px;
73
+ width: 100%;
74
+ padding: 12px 8px;
75
+ background: none;
76
+ border: none;
77
+ border-bottom: 1px solid var(--g-border);
78
+ cursor: pointer;
79
+ text-align: left;
80
+ font: inherit;
81
+ color: inherit;
82
+ }
83
+ .account-row:hover { background: #f8f9fa; }
84
+
85
+ .avatar {
86
+ flex: 0 0 auto;
87
+ width: 32px;
88
+ height: 32px;
89
+ border-radius: 50%;
90
+ background: var(--g-blue);
91
+ color: #fff;
92
+ display: inline-flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ font-weight: 500;
96
+ }
97
+
98
+ .account-text { display: flex; flex-direction: column; }
99
+ .account-name { font-weight: 500; }
100
+ .account-email { color: var(--g-muted); }
101
+
102
+ .chosen-account {
103
+ display: inline-flex;
104
+ align-items: center;
105
+ gap: 8px;
106
+ border: 1px solid var(--g-border);
107
+ border-radius: 16px;
108
+ padding: 4px 12px 4px 4px;
109
+ margin-bottom: 24px;
110
+ }
111
+ .chosen-account .avatar { width: 24px; height: 24px; font-size: 12px; }
112
+
113
+ .unverified {
114
+ background: #fef7e0;
115
+ border: 1px solid #fdd663;
116
+ border-radius: 4px;
117
+ padding: 8px 12px;
118
+ margin: 0 0 16px;
119
+ color: #7f5700;
120
+ }
121
+
122
+ .scope-list {
123
+ list-style: none;
124
+ margin: 0 0 24px;
125
+ padding: 0;
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: 12px;
129
+ }
130
+ .scope-row {
131
+ display: flex;
132
+ align-items: flex-start;
133
+ gap: 12px;
134
+ }
135
+ .scope-check { margin-top: 3px; accent-color: var(--g-blue); }
136
+ .scope-label { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; cursor: pointer; }
137
+ .scope-text { flex: 1 1 auto; }
138
+
139
+ .scope-tag {
140
+ font-size: 11px;
141
+ text-transform: uppercase;
142
+ letter-spacing: 0.4px;
143
+ border-radius: 4px;
144
+ padding: 1px 6px;
145
+ background: #e8f0fe;
146
+ color: #174ea6;
147
+ }
148
+ .scope-tag-unknown { background: #fce8e6; color: #a50e0e; }
149
+
150
+ .granular-controls {
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: flex-end;
154
+ margin: -16px 0 8px;
155
+ }
156
+ .granular-controls .btn { padding: 4px 12px; }
157
+
158
+ .legal { color: var(--g-muted); margin: 0 0 24px; }
159
+ .support { color: var(--g-muted); margin: 24px 0 0; font-size: 12px; }
160
+
161
+ .actions { display: flex; justify-content: flex-end; gap: 8px; }
162
+
163
+ .btn {
164
+ font: inherit;
165
+ font-weight: 500;
166
+ border-radius: 4px;
167
+ padding: 9px 24px;
168
+ cursor: pointer;
169
+ border: 1px solid transparent;
170
+ }
171
+ .btn-text { background: none; color: var(--g-blue); }
172
+ .btn-text:hover { background: #f1f8ff; }
173
+ .btn-primary { background: var(--g-blue); color: #fff; }
174
+ .btn-primary:hover { background: #1765cc; }
175
+
176
+ .error-detail { margin: 0 0 24px; color: var(--g-muted); }
177
+ .error-code {
178
+ margin: 0;
179
+ font-family: 'Roboto Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
180
+ color: var(--g-red);
181
+ }
182
+ .error-request {
183
+ margin: 8px 0 0;
184
+ font-family: 'Roboto Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
185
+ font-size: 12px;
186
+ color: var(--g-muted);
187
+ word-break: break-all;
188
+ }
189
+
190
+ /* Google puts a "Sign in with Google" line beside the wordmark on both screens. */
191
+ .g-header {
192
+ display: flex;
193
+ align-items: baseline;
194
+ gap: 10px;
195
+ margin-bottom: 16px;
196
+ }
197
+ .g-signin { color: var(--g-muted); font-size: 13px; }
198
+
199
+ .support summary,
200
+ .error-details summary {
201
+ cursor: pointer;
202
+ color: var(--g-blue);
203
+ font-weight: 500;
204
+ }
205
+ .support p,
206
+ .error-details p { margin: 8px 0 0; }
207
+ .error-details { margin-top: 16px; }