@ssheleg/sheleg-dev 0.11.8 → 0.12.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 (24) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +4 -3
  3. package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
  4. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +24 -8
  5. package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +57 -12
  6. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +24 -26
  7. package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +52 -0
  8. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +42 -23
  9. package/plugins/sheleg-dev/skills/error-tracking/SKILL.md +34 -6
  10. package/plugins/sheleg-dev/skills/error-tracking/references/scrubbing.md +65 -2
  11. package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +51 -23
  12. package/plugins/sheleg-dev/skills/google-auth/SKILL.md +13 -1
  13. package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +13 -3
  14. package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +150 -64
  15. package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +36 -19
  16. package/plugins/sheleg-dev/skills/google-signin/SKILL.md +55 -23
  17. package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +150 -49
  18. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +9 -7
  19. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +17 -1
  20. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +171 -5
  21. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +333 -198
  22. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +259 -35
  23. package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +149 -28
  24. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +76 -5
@@ -28,24 +28,39 @@ Optimize frontend applications for maximum Lighthouse scores and real-user perfo
28
28
 
29
29
  ## Core Web Vitals, and the diagnostics beside them
30
30
 
31
- **Three metrics are Core Web Vitals. The other three are not**, and the difference
32
- is not pedantry: only the first three are what Google reports and ranks on, and
33
- only they are field-measurable. TBT is a *lab* metric — web.dev says it "is not
34
- part of the Core Web Vitals set because they are not field-measurable" — and it
35
- stands in for INP when you have no field data. Telling a client their Speed Index
36
- is a failing Core Web Vital is telling them about a thing Google does not measure.
37
-
38
- | Core Web Vital | Good | Needs Work | Poor |
39
- |--------|------|------------|------|
40
- | LCP (Largest Contentful Paint) | < 2.5s | 2.5-4.0s | > 4.0s |
41
- | INP (Interaction to Next Paint) | < 200ms | 200-500ms | > 500ms |
42
- | CLS (Cumulative Layout Shift) | < 0.1 | 0.1-0.25 | > 0.25 |
43
-
44
- | Lab diagnostic | Good | Needs Work | Poor | Stands in for |
45
- |--------|------|------------|------|---|
46
- | FCP (First Contentful Paint) | < 1.8s | 1.8-3.0s | > 3.0s | early LCP signal |
47
- | TBT (Total Blocking Time) | < 200ms | 200-600ms | > 600ms | INP, in the lab |
48
- | SI (Speed Index) | < 3.4s | 3.4-5.8s | > 5.8s | perceived load |
31
+ **Two INDEPENDENT axes, and conflating them is the common error.** Axis one:
32
+ **is it a Core Web Vital** — what Google reports and ranks on (LCP, INP, CLS)
33
+ — or not. Axis two: **where can you measure it** — in the FIELD (real users,
34
+ CrUX) or only in the LAB (a synthetic run). These do not line up: a metric can
35
+ be not-CWV and still field-measurable. **FCP is the case that breaks the
36
+ one-axis story — it is NOT a Core Web Vital, yet it IS field-measurable**
37
+ (CrUX reports FCP; web.dev lists both lab and field tools for it). The earlier
38
+ claim that "only the three CWV are field-measurable" was wrong.
39
+
40
+ | Metric | Core Web Vital? | Field-measurable? | Good | Needs Work | Poor |
41
+ |---|---|---|---|---|---|
42
+ | LCP | yes | yes (CrUX) | < 2.5s | 2.5-4.0s | > 4.0s |
43
+ | INP | yes | yes (CrUX) | < 200ms | 200-500ms | > 500ms |
44
+ | CLS | yes | yes (CrUX) | < 0.1 | 0.1-0.25 | > 0.25 |
45
+ | FCP | no | **yes (CrUX)** | < 1.8s | 1.8-3.0s | > 3.0s |
46
+ | TBT | no | no (lab only) | < 200ms | 200-600ms | > 600ms |
47
+ | SI | no | no (lab only) | < 3.4s | 3.4-5.8s | > 5.8s |
48
+
49
+ **A field claim carries its provenance, always: p75** (the 75th-percentile
50
+ value Google classifies on, never a mean), the **device/cohort** (phone vs
51
+ desktop, and which population), the **period** (CrUX is a 28-day trailing
52
+ window), and the **sample size** (a thin origin has no field data — that is
53
+ `unknown`, not `good`). A number without these four is a lab number wearing a
54
+ field label.
55
+
56
+ **TBT is a diagnostic CORRELATE of INP, never a substitute for its evidence.**
57
+ A good lab TBT is a hint that INP may be fine; it does NOT PROVE INP, because
58
+ INP is measured on real interactions the lab did not perform. So a page with
59
+ CrUX FCP data and a Lighthouse TBT but **no field INP** reports **FCP =
60
+ field-supported, INP = unknown** — never "INP passed by TBT". Telling a client
61
+ their Speed Index is a failing Core Web Vital is telling them about a thing
62
+ Google does not rank; telling them INP passed because TBT did is telling them
63
+ about a measurement nobody took.
49
64
 
50
65
  Thresholds and the classification verified against `web.dev/articles/vitals`,
51
66
  2026-08-16.
@@ -61,15 +76,28 @@ Thresholds and the classification verified against `web.dev/articles/vitals`,
61
76
  - **Use `font-display: swap`** -- Prevents invisible text during load (FOIT).
62
77
  - **Remove unused preconnects** -- If fonts are self-hosted, `preconnect` to font CDNs is dead weight.
63
78
 
79
+ **Before any of these changes: MEASURE, don't prescribe.** The items below are
80
+ optimization HEURISTICS, not blanket rules — each is conditional on evidence,
81
+ and applied blind it can lose function for a score. Before changing anything,
82
+ check three things: the **performance trace + the scenarios** (what a low-end
83
+ device on a slow network actually waits for), the **browser-support contract**
84
+ (who must the page still work for), and the **accessibility owner** (structure
85
+ is theirs, not the score's). A change ships only with acceptance covering:
86
+ low-end/slow-network scenarios, keyboard + heading structure intact, the
87
+ supported-browser matrix still served, NO new unapproved CSP origin, and
88
+ several comparable before/after measurements showing the win **with no
89
+ functional loss**.
90
+
64
91
  ### JavaScript (TBT, FCP, LCP)
65
92
 
66
- - **Code split aggressively** -- Lazy-load below-the-fold sections. Only hero + nav in initial bundle.
93
+ - **Code split by the MEASURED waterfall** -- lazy-load what the trace shows the first paint does not need; "only hero + nav in the initial bundle" is a starting hypothesis, not a rule — a section the scenario needs above the fold stays, whatever the score says.
67
94
  - **Defer third-party scripts** -- GA, analytics, chat widgets load after interactive (`afterInteractive` or `defer`).
68
- - **Target modern browsers** -- Set `browserslist` to avoid shipping polyfills for `Array.prototype.at`, `Object.fromEntries`, etc.:
95
+ - **Target the browsers your USERS run** -- `browserslist` follows a stated user-support policy (your analytics, your contract), not a default "last 2". The `last 2` line below is an EXAMPLE; shipping it without checking who it drops is how a working browser stops being served for a polyfill saving:
69
96
  ```
97
+ # example only — replace with your measured support matrix
70
98
  last 2 Chrome versions, last 2 Firefox versions, last 2 Safari versions, last 2 Edge versions
71
99
  ```
72
- - **Tree-shake imports** -- Named imports only. No `import * as`.
100
+ - **Tree-shake imports** -- prefer named imports where the bundle analyzer shows `import * as` pulling dead weight; it is a tree-shaking aid, not a ban — a namespace import that costs nothing measured is not a defect.
73
101
  - **Analyze bundles** -- Use `@next/bundle-analyzer`, `source-map-explorer`, or `vite-plugin-visualizer`.
74
102
 
75
103
  ### CSS & Animations (CLS)
@@ -98,7 +126,7 @@ Thresholds and the classification verified against `web.dev/articles/vitals`,
98
126
 
99
127
  ### Content Security Policy (Best Practices)
100
128
 
101
- - **Whitelist all loaded origins** -- CSP blocks = console errors = lower Best Practices score.
129
+ - **Approve origins by FUNCTIONAL NECESSITY, not for the score** -- an origin enters the CSP because the page genuinely needs it, and each is reviewed; whitelisting everything the page happens to load to silence console errors trades the whole point of a CSP for a Best-Practices number. No new origin ships unapproved.
102
130
  - **Audit after every third-party change** -- Adding analytics, fonts, or CDN scripts requires CSP updates.
103
131
  - **Tighten after migration** -- If you move from external fonts to self-hosted, remove the old CSP entries.
104
132
  - **Common CSP origins**:
@@ -111,7 +139,7 @@ Thresholds and the classification verified against `web.dev/articles/vitals`,
111
139
  These directly affect the Lighthouse Accessibility score:
112
140
 
113
141
  - **Contrast ratio** -- WCAG AA requires 4.5:1 for normal text, 3:1 for large text. Avoid opacity modifiers on text colors (e.g., `text-primary/80` in Tailwind reduces contrast). Use full-opacity color tokens.
114
- - **Heading hierarchy** -- Sequential: `h1` -> `h2` -> `h3`. Never skip levels. Use non-heading elements (`<p>`) for visual-only "headings" (e.g., footer column titles).
142
+ - **Heading hierarchy follows STRUCTURE, decided with the accessibility owner** -- sequential `h1`->`h2`->`h3`, no skipped levels. Demote a heading to `<p>` only when it is genuinely NOT a section title; downgrading a real footer-section heading to `<p>` to quiet a Lighthouse audit removes structure a screen-reader user navigates by. Accessibility is the owner here, not the score.
115
143
  - **Alt text** -- Decorative images next to identical text get `alt=""`. Informative images get descriptive alt. Never duplicate adjacent text.
116
144
  - **Tap targets** -- Minimum 48x48px on mobile. Applies to buttons, links, form inputs.
117
145
 
@@ -86,7 +86,7 @@ request = google.auth.transport.requests.Request()
86
86
  credentials.refresh(request)
87
87
  ```
88
88
 
89
- ADC search order: attached service account → `gcloud auth application-default login` file → `GOOGLE_APPLICATION_CREDENTIALS` env var.
89
+ ADC search order (env var FIRST — this line was reversed): `GOOGLE_APPLICATION_CREDENTIALS` env var → `gcloud auth application-default login` file → attached service account (metadata). Print the RESOLVED principal and source (no secret) before configuring — with more than one present, only the resolved source names which you are actually using.
90
90
 
91
91
  For detailed ADC setup and service account usage, see [references/adc-and-service-accounts.md](references/adc-and-service-accounts.md).
92
92
 
@@ -259,6 +259,18 @@ if credentials.expired and credentials.refresh_token:
259
259
  - Validate external credential configurations before use (check `token_url`, `service_account_impersonation_url` point to googleapis.com)
260
260
  - Prefer Workload Identity Federation over service account keys for non-GCP environments
261
261
  - For end-user web sign-in, apply the google-signin skill's full checklist — a partial restatement here is how the two skills drifted apart once already
262
+ - **Fail closed on a missing production secret.** The session signing secret,
263
+ and any credential-store key, is REQUIRED in production with NO dev fallback —
264
+ a hardcoded default signs every deployment's cookies with a key that lives in
265
+ the repo. Missing → refuse to boot, never a warning-and-continue (DV-07).
266
+ - **Logs are sanitized; a credential never reaches one.** No token, no
267
+ `client_secret`, no `refresh_token`, no session value in a log line, a stack
268
+ trace, or an error message — a token in a log is a token anyone with log
269
+ access holds. Redact by allow-list, not by trying to strip the secret out.
270
+ - **HTTPS is mandatory and the credential store can fail.** The auth cookie is
271
+ `Secure` and the callback refuses plain HTTP (an OAuth code over `http://` is a
272
+ code on the wire); and a credential-store read/write that FAILS is an auth
273
+ failure — re-prompt or 503, never proceed as if the credentials loaded.
262
274
  - **Python-specific**: reuse a single `google.auth.transport.requests.Request()` instance across verifications for connection pooling; do not create a new one per call in hot paths
263
275
 
264
276
  ## Reference Files
@@ -20,9 +20,19 @@ Application Default Credentials (ADC) automatically finds credentials based on t
20
20
 
21
21
  ## ADC Search Order
22
22
 
23
- 1. **Attached service account** — via GCP metadata server (Compute Engine, Cloud Run, GKE, etc.)
24
- 2. **`gcloud auth application-default login`** — local credential file
25
- 3. **`GOOGLE_APPLICATION_CREDENTIALS`** — env var pointing to a credential JSON file
23
+ Google resolves these **in this order — env var FIRST**, and the earlier match
24
+ wins. The old text here had it reversed (attached account first), which points
25
+ you at the wrong lever when the resolved identity is not what you expect:
26
+
27
+ 1. **`GOOGLE_APPLICATION_CREDENTIALS`** — env var pointing to a credential JSON file
28
+ 2. **`gcloud auth application-default login`** — local credential file (well-known path)
29
+ 3. **Attached service account** — via GCP metadata server (Compute Engine, Cloud Run, GKE, etc.)
30
+
31
+ **Before configuring, print the ACTUALLY-resolved principal and source** — which
32
+ of the three won, and the account email — **without the secret**. On a VM with a
33
+ metadata identity AND an env var set, only the resolved source tells you which
34
+ one your code is actually using; a config change made against the wrong source
35
+ is a change that does nothing.
26
36
 
27
37
  Credential file locations from `gcloud auth application-default login`:
28
38
  - **macOS/Linux**: `$HOME/.config/gcloud/application_default_credentials.json`
@@ -42,20 +42,30 @@ pip install google-auth google-auth-oauthlib google-api-python-client flask
42
42
 
43
43
  ### Node.js
44
44
 
45
+ **One OAuth2 client PER request, never a shared singleton.** A module-level
46
+ `oauth2Client` whose `setCredentials(tokens)` runs on every callback is mutated
47
+ by every concurrent login — two users signing in at once end up with each
48
+ other's tokens. Build the client inside the handler; the client id/secret are
49
+ shared config, the CREDENTIALS are per principal and never assigned to a
50
+ process-wide object.
51
+
45
52
  ```js
46
53
  const {google} = require('googleapis');
47
54
  const crypto = require('crypto');
48
55
 
49
- const oauth2Client = new google.auth.OAuth2(
50
- YOUR_CLIENT_ID,
51
- YOUR_CLIENT_SECRET,
52
- YOUR_REDIRECT_URL
53
- );
56
+ // A fresh client per request — the credentials it will hold are this user's.
57
+ function newOAuthClient() {
58
+ return new google.auth.OAuth2(YOUR_CLIENT_ID, YOUR_CLIENT_SECRET, YOUR_REDIRECT_URL);
59
+ }
60
+
61
+ const oauthClient = newOAuthClient();
54
62
 
63
+ // State is REQUIRED, crypto-random, single-use with a TTL, and bound to the
64
+ // server session — not a value the client can echo back to itself.
55
65
  const state = crypto.randomBytes(32).toString('hex');
56
- req.session.state = state;
66
+ req.session.oauthState = { value: state, expires: Date.now() + 10 * 60 * 1000 };
57
67
 
58
- const authorizationUrl = oauth2Client.generateAuthUrl({
68
+ const authorizationUrl = oauthClient.generateAuthUrl({
59
69
  access_type: 'offline', // 'online' (default) or 'offline' (gets refresh_token)
60
70
  scope: [
61
71
  'https://www.googleapis.com/auth/drive.metadata.readonly',
@@ -73,6 +83,7 @@ const authorizationUrl = oauth2Client.generateAuthUrl({
73
83
  ```python
74
84
  from google_auth_oauthlib.flow import Flow
75
85
  import secrets
86
+ import time
76
87
 
77
88
  flow = Flow.from_client_secrets_file(
78
89
  'client_secret.json',
@@ -84,7 +95,8 @@ flow = Flow.from_client_secrets_file(
84
95
  )
85
96
 
86
97
  state = secrets.token_hex(32)
87
- session['state'] = state
98
+ # Server-bound, random, with a TTL: the callback consumes it atomically.
99
+ session['oauth_state'] = {'value': state, 'expires': time.time() + 600}
88
100
 
89
101
  authorization_url, state = flow.authorization_url(
90
102
  access_type='offline', # gets refresh_token
@@ -125,6 +137,41 @@ return redirect(authorization_url)
125
137
 
126
138
  ## Step 3: Handle Callback
127
139
 
140
+ ### The session cookie is opaque — credentials live server-side, encrypted
141
+
142
+ **A signed cookie is not an encrypted one.** Flask's default session and
143
+ Starlette's `SessionMiddleware` SIGN the cookie (tamper-evident) but do NOT
144
+ encrypt it — anyone holding the cookie can base64-decode and read every value
145
+ in it, which the official docs confirm. So the cookie carries **only a random
146
+ opaque session id**; the Google credentials (`token`, `refresh_token`,
147
+ `client_secret`) go into a **server-side encrypted store**, fetched by
148
+ `(session_id, principal)` — never into the cookie, the redirect URL, or the
149
+ response body.
150
+
151
+ ```python
152
+ # The cookie holds ONLY an opaque id. Credentials are server-side, encrypted.
153
+ session['sid'] = session.get('sid') or secrets.token_urlsafe(32)
154
+ credential_store.put( # encrypted at rest, keyed by (sid, principal)
155
+ sid=session['sid'], principal=userinfo['sub'],
156
+ credentials={'token': credentials.token,
157
+ 'refresh_token': credentials.refresh_token,
158
+ 'client_secret': credentials.client_secret, ...})
159
+ # Never: session['credentials'] = {...} ← readable in the cookie
160
+ # Never: log or return the tokens ← no console.log(tokens.access_token)
161
+ ```
162
+
163
+ Four more, because a leaked secret does not announce itself: **never log a
164
+ token or a credential** (no `console.log(tokens.access_token)`; sanitize logs
165
+ by allow-list, so no token, `client_secret` or `refresh_token` reaches a line,
166
+ a stack trace or an error message); the session signing secret is a **required
167
+ production secret with NO dev fallback** (a hardcoded default signs every
168
+ deployment's cookies with a key in the repo — missing in production is
169
+ **fail-closed**, refuse to boot); the auth cookie is **`Secure`, and the
170
+ callback refuses plain HTTP** — an OAuth code or token over `http://` is a code
171
+ or token on the wire; and **a credential-store read or write that FAILS is an
172
+ auth failure** — re-prompt the user or return 503, never proceed as if the
173
+ credentials loaded (a silent empty read logs the user in as nobody).
174
+
128
175
  ### Node.js
129
176
 
130
177
  ```js
@@ -134,16 +181,21 @@ app.get('/oauth2callback', async (req, res) => {
134
181
  const q = url.parse(req.url, true).query;
135
182
 
136
183
  if (q.error) {
137
- console.log('Error: ' + q.error);
138
- return res.status(400).send('Authorization failed');
184
+ return res.status(400).send('Authorization failed'); // never log the raw error/token
139
185
  }
140
186
 
141
- if (q.state !== req.session.state) {
142
- return res.status(403).send('State mismatch. Possible CSRF attack');
187
+ // Validate state BEFORE the token exchange: it must be PRESENT on both sides
188
+ // (undefined === undefined must NOT pass), unexpired, and consumed atomically
189
+ // so a replay of the same callback cannot reuse it.
190
+ const saved = req.session.oauthState;
191
+ delete req.session.oauthState; // single-use: consume it now
192
+ if (!q.state || !saved || saved.value !== q.state || Date.now() > saved.expires) {
193
+ return res.status(403).send('State invalid, missing, expired or already used');
143
194
  }
144
195
 
145
- const {tokens} = await oauth2Client.getToken(q.code);
146
- oauth2Client.setCredentials(tokens);
196
+ const oauthClient = newOAuthClient(); // a client for THIS request
197
+ const {tokens} = await oauthClient.getToken(q.code);
198
+ oauthClient.setCredentials(tokens); // credentials stay on the local client
147
199
 
148
200
  // tokens.access_token — short-lived access token
149
201
  // tokens.refresh_token — long-lived (only on first auth!)
@@ -162,14 +214,20 @@ def oauth2callback():
162
214
  if request.args.get('error'):
163
215
  return 'Authorization failed', 400
164
216
 
165
- if request.args.get('state') != session.get('state'):
166
- abort(403, 'State mismatch. Possible CSRF attack')
217
+ # Validate state BEFORE the token exchange, and consume it atomically:
218
+ # pop() removes it in the same step, so a replayed callback finds nothing.
219
+ # Presence is required on BOTH sides — None == None must NOT pass.
220
+ saved = session.pop('oauth_state', None)
221
+ got = request.args.get('state')
222
+ if (not got or not saved or saved['value'] != got
223
+ or time.time() > saved['expires']):
224
+ abort(403, 'State invalid, missing, expired or already used')
167
225
 
168
226
  flow = Flow.from_client_secrets_file(
169
227
  'client_secret.json',
170
228
  scopes=SCOPES,
171
229
  redirect_uri=YOUR_REDIRECT_URL,
172
- state=session['state']
230
+ state=saved['value']
173
231
  )
174
232
  flow.fetch_token(authorization_response=request.url)
175
233
 
@@ -179,20 +237,26 @@ def oauth2callback():
179
237
  # credentials.expiry — expiration datetime
180
238
  # credentials.scopes — granted scopes
181
239
 
182
- session['credentials'] = {
240
+ # Cookie: opaque id only. Credentials: server-side, encrypted, keyed by
241
+ # (session id, principal) — never serialized into the signed cookie.
242
+ session['sid'] = session.get('sid') or secrets.token_urlsafe(32)
243
+ credential_store.put(session['sid'], principal=userinfo['sub'], credentials={
183
244
  'token': credentials.token,
184
245
  'refresh_token': credentials.refresh_token,
185
246
  'token_uri': credentials.token_uri,
186
247
  'client_id': credentials.client_id,
187
248
  'client_secret': credentials.client_secret,
188
- 'scopes': list(credentials.scopes)
189
- }
249
+ 'scopes': list(credentials.scopes),
250
+ })
190
251
  return redirect('/profile')
191
252
  ```
192
253
 
193
254
  ### Python (FastAPI)
194
255
 
195
256
  ```python
257
+ import secrets
258
+ import time
259
+
196
260
  from fastapi import APIRouter, Request
197
261
  from fastapi.responses import RedirectResponse
198
262
  from google_auth_oauthlib.flow import Flow
@@ -202,15 +266,19 @@ async def oauth2callback(request: Request):
202
266
  if request.query_params.get('error'):
203
267
  raise HTTPException(400, 'Authorization failed')
204
268
 
205
- state = request.session.get('state')
206
- if request.query_params.get('state') != state:
207
- raise HTTPException(403, 'State mismatch')
269
+ # Consume atomically (pop) and validate BEFORE the exchange; a wrong,
270
+ # expired or replayed state must never start a token exchange.
271
+ saved = request.session.pop('oauth_state', None)
272
+ got = request.query_params.get('state')
273
+ if (not got or not saved or saved['value'] != got
274
+ or time.time() > saved['expires']):
275
+ raise HTTPException(403, 'State invalid, missing, expired or already used')
208
276
 
209
277
  flow = Flow.from_client_secrets_file(
210
278
  'client_secret.json',
211
279
  scopes=SCOPES,
212
280
  redirect_uri=YOUR_REDIRECT_URL,
213
- state=state
281
+ state=saved['value']
214
282
  )
215
283
  flow.fetch_token(code=request.query_params.get('code'))
216
284
  credentials = flow.credentials
@@ -262,7 +330,8 @@ oauth2Client.on('tokens', (tokens) => {
262
330
  if (tokens.refresh_token) {
263
331
  // Store in database — only sent once!
264
332
  }
265
- console.log('New access_token:', tokens.access_token);
333
+ // Never log a token or a credential (FIX-DV-07) — a token in a log is a
334
+ // token anyone with log access holds.
266
335
  });
267
336
  ```
268
337
 
@@ -377,19 +446,20 @@ const {google} = require('googleapis');
377
446
  const app = express();
378
447
  app.use(session({secret: 'your-secret', resave: false, saveUninitialized: false}));
379
448
 
380
- const oauth2Client = new google.auth.OAuth2(
381
- process.env.GOOGLE_CLIENT_ID,
382
- process.env.GOOGLE_CLIENT_SECRET,
383
- 'http://localhost:3000/oauth2callback'
384
- );
449
+ // A client per request — client id/secret are config, credentials are per user.
450
+ function newOAuthClient() {
451
+ return new google.auth.OAuth2(
452
+ process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET,
453
+ 'http://localhost:3000/oauth2callback');
454
+ }
385
455
 
386
456
  const SCOPES = ['https://www.googleapis.com/auth/userinfo.profile'];
387
457
 
388
458
  app.get('/auth', (req, res) => {
389
459
  const state = crypto.randomBytes(32).toString('hex');
390
- req.session.state = state;
460
+ req.session.oauthState = { value: state, expires: Date.now() + 10 * 60 * 1000 };
391
461
 
392
- const url = oauth2Client.generateAuthUrl({
462
+ const url = newOAuthClient().generateAuthUrl({
393
463
  access_type: 'offline',
394
464
  scope: SCOPES,
395
465
  state,
@@ -400,18 +470,22 @@ app.get('/auth', (req, res) => {
400
470
 
401
471
  app.get('/oauth2callback', async (req, res) => {
402
472
  if (req.query.error) return res.redirect('/error');
403
- if (req.query.state !== req.session.state) return res.status(403).send('CSRF');
473
+ const saved = req.session.oauthState;
474
+ delete req.session.oauthState; // single-use
475
+ if (!req.query.state || !saved || saved.value !== req.query.state
476
+ || Date.now() > saved.expires) return res.status(403).send('CSRF');
404
477
 
405
- const {tokens} = await oauth2Client.getToken(req.query.code);
478
+ const {tokens} = await newOAuthClient().getToken(req.query.code);
406
479
  req.session.tokens = tokens;
407
480
  res.redirect('/profile');
408
481
  });
409
482
 
410
483
  app.get('/profile', async (req, res) => {
411
484
  if (!req.session.tokens) return res.redirect('/auth');
412
- oauth2Client.setCredentials(req.session.tokens);
485
+ const oauthClient = newOAuthClient(); // local to this request
486
+ oauthClient.setCredentials(req.session.tokens);
413
487
 
414
- const oauth2 = google.oauth2({version: 'v2', auth: oauth2Client});
488
+ const oauth2 = google.oauth2({version: 'v2', auth: oauthClient});
415
489
  const {data} = await oauth2.userinfo.get();
416
490
  res.json(data);
417
491
  });
@@ -452,7 +526,7 @@ def auth():
452
526
  redirect_uri=REDIRECT_URI
453
527
  )
454
528
  state = secrets.token_hex(32)
455
- session['state'] = state
529
+ session['oauth_state'] = {'value': state, 'expires': time.time() + 600}
456
530
  authorization_url, _ = flow.authorization_url(
457
531
  access_type='offline',
458
532
  include_granted_scopes='true',
@@ -465,8 +539,11 @@ def auth():
465
539
  def oauth2callback():
466
540
  if request.args.get('error'):
467
541
  return 'Authorization failed', 400
468
- if request.args.get('state') != session.get('state'):
469
- abort(403, 'State mismatch')
542
+ saved = session.pop('oauth_state', None) # single-use: consume BEFORE exchange
543
+ got = request.args.get('state')
544
+ if (not got or not saved or saved['value'] != got
545
+ or time.time() > saved['expires']):
546
+ abort(403, 'State invalid, missing, expired or already used')
470
547
 
471
548
  flow = Flow.from_client_config(
472
549
  {
@@ -479,26 +556,28 @@ def oauth2callback():
479
556
  },
480
557
  scopes=SCOPES,
481
558
  redirect_uri=REDIRECT_URI,
482
- state=session['state']
559
+ state=saved['value']
483
560
  )
484
561
  flow.fetch_token(authorization_response=request.url)
485
562
  creds = flow.credentials
486
- session['credentials'] = {
487
- 'token': creds.token,
488
- 'refresh_token': creds.refresh_token,
489
- 'token_uri': creds.token_uri,
490
- 'client_id': creds.client_id,
491
- 'client_secret': creds.client_secret,
492
- 'scopes': list(creds.scopes or [])
493
- }
563
+ userinfo = build('oauth2', 'v2', credentials=creds).userinfo().get().execute()
564
+ # Opaque id in the cookie; credentials in the server-side encrypted store.
565
+ session['sid'] = session.get('sid') or secrets.token_urlsafe(32)
566
+ credential_store.put(session['sid'], principal=userinfo['id'], credentials={
567
+ 'token': creds.token, 'refresh_token': creds.refresh_token,
568
+ 'token_uri': creds.token_uri, 'client_id': creds.client_id,
569
+ 'client_secret': creds.client_secret, 'scopes': list(creds.scopes or []),
570
+ })
571
+ session['principal'] = userinfo['id']
494
572
  return redirect('/profile')
495
573
 
496
574
 
497
575
  @app.route('/profile')
498
576
  def profile():
499
- if 'credentials' not in session:
577
+ stored = credential_store.get(session.get('sid'), session.get('principal'))
578
+ if not stored:
500
579
  return redirect('/auth')
501
- creds = Credentials(**session['credentials'])
580
+ creds = Credentials(**stored)
502
581
  service = build('oauth2', 'v2', credentials=creds)
503
582
  user_info = service.userinfo().get().execute()
504
583
  return jsonify(user_info)
@@ -544,7 +623,7 @@ CLIENT_CONFIG = {
544
623
  async def auth(request: Request):
545
624
  flow = Flow.from_client_config(CLIENT_CONFIG, scopes=SCOPES, redirect_uri=REDIRECT_URI)
546
625
  state = secrets.token_hex(32)
547
- request.session['state'] = state
626
+ request.session['oauth_state'] = {'value': state, 'expires': time.time() + 600}
548
627
  authorization_url, _ = flow.authorization_url(
549
628
  access_type='offline',
550
629
  include_granted_scopes='true',
@@ -557,31 +636,38 @@ async def auth(request: Request):
557
636
  async def oauth2callback(request: Request):
558
637
  if request.query_params.get('error'):
559
638
  raise HTTPException(400, 'Authorization failed')
560
- if request.query_params.get('state') != request.session.get('state'):
561
- raise HTTPException(403, 'State mismatch')
639
+ saved = request.session.pop('oauth_state', None) # single-use: consume BEFORE exchange
640
+ got = request.query_params.get('state')
641
+ if (not got or not saved or saved['value'] != got
642
+ or time.time() > saved['expires']):
643
+ raise HTTPException(403, 'State invalid, missing, expired or already used')
562
644
 
563
645
  flow = Flow.from_client_config(
564
646
  CLIENT_CONFIG, scopes=SCOPES, redirect_uri=REDIRECT_URI,
565
- state=request.session['state']
647
+ state=saved['value']
566
648
  )
567
649
  flow.fetch_token(code=request.query_params.get('code'))
568
650
  creds = flow.credentials
569
- request.session['credentials'] = {
570
- 'token': creds.token,
571
- 'refresh_token': creds.refresh_token,
572
- 'token_uri': creds.token_uri,
573
- 'client_id': creds.client_id,
574
- 'client_secret': creds.client_secret,
575
- 'scopes': list(creds.scopes or [])
576
- }
651
+ userinfo = build('oauth2', 'v2', credentials=creds).userinfo().get().execute()
652
+ # Opaque id in the cookie; credentials in the server-side encrypted store.
653
+ sid = request.session.get('sid') or secrets.token_urlsafe(32)
654
+ request.session['sid'] = sid
655
+ request.session['principal'] = userinfo['id']
656
+ credential_store.put(sid, principal=userinfo['id'], credentials={
657
+ 'token': creds.token, 'refresh_token': creds.refresh_token,
658
+ 'token_uri': creds.token_uri, 'client_id': creds.client_id,
659
+ 'client_secret': creds.client_secret, 'scopes': list(creds.scopes or []),
660
+ })
577
661
  return RedirectResponse('/profile')
578
662
 
579
663
 
580
664
  @app.get('/profile')
581
665
  async def profile(request: Request):
582
- if 'credentials' not in request.session:
666
+ stored = credential_store.get(request.session.get('sid'),
667
+ request.session.get('principal'))
668
+ if not stored:
583
669
  return RedirectResponse('/auth')
584
- creds = Credentials(**request.session['credentials'])
670
+ creds = Credentials(**stored)
585
671
  service = build('oauth2', 'v2', credentials=creds)
586
672
  user_info = service.userinfo().get().execute()
587
673
  return user_info