@ssheleg/sheleg-dev 0.11.8 → 0.13.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 +47 -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 +35 -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 +39 -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 +62 -23
  12. package/plugins/sheleg-dev/skills/google-auth/SKILL.md +22 -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 +64 -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 +26 -9
  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
@@ -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
@@ -228,8 +228,7 @@ from pydantic import BaseModel
228
228
 
229
229
  class GoogleLoginRequest(BaseModel):
230
230
  credential: str
231
- g_csrf_token: str | None = None
232
- nonce: str | None = None
231
+ g_csrf_token: str | None = None # no nonce field — the server holds the expectation
233
232
 
234
233
  @router.post("/google")
235
234
  async def google_login(request: Request, body: GoogleLoginRequest):
@@ -265,38 +264,54 @@ def google_login():
265
264
 
266
265
  ## Nonce Verification
267
266
 
268
- Pass a nonce to `google.accounts.id.initialize()` on the client, then verify it server-side against the token's `nonce` claim.
267
+ **The server issues the nonce; the client never invents one.** A
268
+ client-generated nonce sent back beside the credential proves nothing: the
269
+ "expected" value would be read out of the very JWT it is meant to check, so a
270
+ stolen token replays with its own nonce. The contract (shared with
271
+ `google-signin` — one contract, both skills):
272
+
273
+ 1. `GET /api/auth/google/nonce` — the server mints a random value, stores it
274
+ with a TTL bound to a **pre-auth HttpOnly session cookie**, returns it.
275
+ 2. The client passes that value to `google.accounts.id.initialize({nonce})`.
276
+ 3. On the callback the server **pops the expectation (one-time consume) and
277
+ requires the token's `nonce` claim to equal it exactly.** Missing claim,
278
+ missing expectation, expiry and re-use all reject; the POST body carries
279
+ no nonce and plays no part in the check.
269
280
 
270
281
  **Client-side:**
271
282
 
272
283
  ```js
273
- const nonce = crypto.randomUUID(); // or any random string
284
+ const nonce = (await (await fetch('/api/auth/google/nonce')).json()).nonce;
274
285
  google.accounts.id.initialize({
275
286
  client_id: CLIENT_ID,
276
- callback: handleResponse,
277
- nonce: nonce
287
+ callback: handleResponse, // POSTs { credential } only
288
+ nonce
278
289
  });
279
- // Send nonce alongside credential to your backend
280
290
  ```
281
291
 
282
292
  **Server-side (Node.js):**
283
293
 
284
294
  ```js
295
+ const sid = req.cookies.preauth || '';
296
+ const expected = await nonceStore.pop(sid); // one-time consume, with TTL
285
297
  const payload = ticket.getPayload();
286
- if (payload.nonce !== expectedNonce) {
287
- throw new Error('Nonce mismatch');
298
+ if (!expected || payload.nonce !== expected) {
299
+ throw new Error('Nonce missing, expired, replayed or not server-issued');
288
300
  }
289
301
  ```
290
302
 
291
303
  **Server-side (Python):**
292
304
 
293
305
  ```python
294
- if body.nonce:
295
- token_nonce = payload.get('nonce')
296
- if not token_nonce or token_nonce != body.nonce:
297
- raise ValueError('Nonce mismatch')
306
+ sid = request.cookies.get('preauth') or ''
307
+ expected = nonce_store.pop(sid) # one-time consume, with TTL
308
+ if not expected or payload.get('nonce') != expected:
309
+ raise ValueError('Nonce missing, expired, replayed or not server-issued')
298
310
  ```
299
311
 
312
+ First sign-in passes; a replay from a new session meets a different
313
+ expectation; a replay after consume meets none.
314
+
300
315
  ## ID Token Payload Fields
301
316
 
302
317
  | Field | Description |
@@ -474,8 +489,7 @@ CLIENT_ID = settings.google_client_id
474
489
 
475
490
  class GoogleLoginRequest(BaseModel):
476
491
  credential: str
477
- g_csrf_token: str | None = None
478
- nonce: str | None = None
492
+ g_csrf_token: str | None = None # no nonce field — the server holds the expectation
479
493
 
480
494
  @router.post("/auth/google")
481
495
  async def google_login(request: Request, body: GoogleLoginRequest):
@@ -494,10 +508,13 @@ async def google_login(request: Request, body: GoogleLoginRequest):
494
508
  if not payload.get("email_verified", False):
495
509
  raise HTTPException(status_code=401, detail="Email not verified")
496
510
 
497
- # 3. Verify nonce
498
- if body.nonce:
499
- if payload.get("nonce") != body.nonce:
500
- raise HTTPException(status_code=401, detail="Nonce mismatch")
511
+ # 3. Verify nonce — MANDATORY, against the server's own expectation.
512
+ # pop() consumes it in the same step (one-time); the body carries no nonce.
513
+ sid = request.cookies.get("preauth") or ""
514
+ expected = nonce_store.pop(sid) # server-issued, TTL'd (see §Nonce Verification)
515
+ if not expected or payload.get("nonce") != expected:
516
+ raise HTTPException(status_code=401,
517
+ detail="Nonce missing, expired, replayed or not server-issued")
501
518
 
502
519
  # 4. Find or create user, issue app JWT...
503
520
  user, is_new = await find_or_create_google_user(db, payload)
@@ -33,19 +33,27 @@ implementation this skill ships.
33
33
 
34
34
  ## Core flow (ID-token)
35
35
 
36
- 1. Frontend: `google.accounts.id.initialize({ client_id, callback, nonce })`
37
- + `renderButton()`. Generate a **fresh random nonce per render**
38
- (`crypto.randomUUID()`). The GSI script loads async — retry rendering
39
- (e.g. 20 × 150 ms) instead of silently dropping the button.
36
+ 1. Frontend: fetch a **server-issued nonce** first (`GET /api/auth/google/nonce`
37
+ — the server stores it with a TTL, bound to a pre-auth HttpOnly cookie),
38
+ then `google.accounts.id.initialize({ client_id, callback, nonce })`
39
+ + `renderButton()`. A client-generated nonce POSTed back proves nothing:
40
+ the value would be read out of the very JWT it is meant to check. The GSI
41
+ script loads async — retry rendering (e.g. 20 × 150 ms) instead of
42
+ silently dropping the button.
40
43
  2. Callback receives `{ credential }` — a Google-signed ID token (JWT).
41
- POST it with the nonce to your backend. Never treat it as a session.
44
+ POST it alone to your backend (no nonce in the body). Never treat it as a
45
+ session.
42
46
  3. Backend: verify with the official lib — Python
43
47
  `google.oauth2.id_token.verify_oauth2_token(credential, transport, CLIENT_ID)`,
44
48
  Node `google-auth-library` `verifyIdToken({ idToken, audience })`. That
45
49
  checks signature (Google JWKS), `aud`, `iss`, `exp`. Never hand-decode
46
50
  and trust the payload.
47
- 4. Additionally require `email_verified == true` and token `nonce` claim ==
48
- submitted nonce.
51
+ 4. Additionally require `email_verified == true` and **pop the expected nonce
52
+ from the pre-auth session (one-time consume) and require the token `nonce`
53
+ claim to equal it exactly** — a missing claim, a missing expectation, an
54
+ expired or an already-consumed nonce all reject. This is what makes a
55
+ stolen token non-replayable: a replay from a new session meets a different
56
+ expectation, a replay in the same session meets a consumed one.
49
57
  5. Find-or-create the user, then issue YOUR OWN session (app JWT) as an
50
58
  **HttpOnly + Secure + SameSite=Strict cookie**. Google's token is
51
59
  verified once, never stored, never logged.
@@ -56,25 +64,49 @@ Key the user on `sub` (stable Google user ID; store as `google_id`) — never
56
64
  on email (emails change, `sub` doesn't). On each Google login:
57
65
 
58
66
  1. Record with this `google_id` exists → login (refresh name/picture).
59
- 2. Email matches an existing account without `google_id` → link, BUT apply
60
- the **pre-hijacking guard**: if that account is password-based and its
61
- email was never verified, REFUSE the auto-link ("sign in with your
62
- password instead"). Otherwise an attacker who pre-registered the
63
- victim's email with a known password captures the victim's first Google
64
- login into the attacker's record.
65
- 3. No match → create the user with `email_verified=True`, empty password
66
- hash.
67
+ 2. Email matches an existing account without `google_id` → **do NOT auto-link
68
+ on `email_verified` alone.** `email_verified` is Google saying it verified
69
+ the inbox ONCE, not that this person owns the LOCAL account, and for a
70
+ third-party address Google is not even authoritative that they still own the
71
+ inbox. Default: link only after a **fresh re-auth of the existing local
72
+ account** (its password / existing factor), so the person proves they hold
73
+ the account Google is being attached to.
74
+ - Auto-link WITHOUT that re-auth is allowed only when Google is
75
+ **authoritative for the address** — `email_verified` AND (the address is
76
+ `@gmail.com` OR the token carries an `hd` Workspace-domain claim) — AND
77
+ the existing account's own email ownership was proven. For any other
78
+ (third-party) address, run an **independent challenge** (a link to that
79
+ inbox) before linking; Google's one-time verification is not current proof.
80
+ - If the existing account is a password account whose email was **never
81
+ verified** (a possible pre-registration hijack), send the person into a
82
+ **safe account-recovery flow** — never "sign in with the existing
83
+ password", which may be the attacker's.
84
+ 3. No match → create the user. Mark `email_verified=True` only when Google is
85
+ authoritative for the address (Gmail or `hd`); for a third-party address
86
+ record it unverified and challenge the inbox before granting anything that
87
+ trusts the email.
67
88
 
68
89
  ## Login-CSRF (cover BOTH delivery flows)
69
90
 
70
- - Form-POST flow (`login_uri` auto-POST): GIS double-submits `g_csrf_token`
71
- in body AND cookie — compare constant-time (`hmac.compare_digest`);
72
- reject if either side is missing.
73
- - JS-fetch flow: no `g_csrf_token` — enforce same-origin via Fetch-Metadata
74
- (`Sec-Fetch-Site: same-origin`) with an Origin-allowlist fallback.
75
-
76
- Without this, an attacker's page can force-POST the *attacker's* credential
77
- and silently log the victim into the attacker's account.
91
+ The two delivery flows are **separate, explicitly typed paths**, chosen by
92
+ Content-Type — not one handler that claims both. A skeleton that declares form
93
+ support but only binds a JSON body does not actually run the form contract.
94
+
95
+ - **Form-POST flow** (`login_uri` auto-POST, `application/x-www-form-urlencoded`):
96
+ GIS double-submits `g_csrf_token` in the FORM BODY and the cookie — require
97
+ BOTH present and equal, constant-time (`hmac.compare_digest`). Missing either
98
+ side → reject. This token is the whole authorization for the form path.
99
+ - **JS-fetch flow** (`application/json`): no `g_csrf_token`, so require a
100
+ trusted same-origin signal — `Sec-Fetch-Site: same-origin` (browser-set,
101
+ unforgeable), OR, only when that header is ABSENT, an exact `Origin` in an
102
+ allowlist. A **missing `Sec-Fetch-Site` is NOT assumed same-origin**;
103
+ `cross-site` and `none` are rejected; and with neither a trusted metadata
104
+ value nor an allowed Origin the request **fails closed**.
105
+
106
+ The CSRF decision runs BEFORE the token is verified — no external call on a
107
+ request that has not proven its origin. Without this, an attacker's page can
108
+ force-POST the *attacker's* credential and silently log the victim into the
109
+ attacker's account.
78
110
 
79
111
  ## Setup (GCP)
80
112
 
@@ -119,3 +151,12 @@ and silently log the victim into the attacker's account.
119
151
  | `access_denied` on consent | Consent screen still in Testing → publish the app |
120
152
  | Works locally, breaks in prod | Prod origin missing in GCP, or `Secure` cookie served over plain HTTP |
121
153
  | One Tap missing / FedCM console warnings | FedCM is mandatory since Aug 2025 and the opt-out is gone (checked 2026-08-06). Load `gsi/client` from Google and keep it current — a vendored or pinned copy is the real break. The rendered button flow is unaffected; code branching on the old `isNotDisplayed()` moment callbacks is not |
154
+
155
+ ## Degradation
156
+
157
+ - **Not Claude Code** (Cursor, Codex, the skills CLI, the API container): the pack's
158
+ `PreToolUse` gate does not run — the rules here hold, the refusal does not.
159
+ - **No browser channel**: the sign-in button and its redirect are verified by reading
160
+ the code, not by watching it work. Record that as the weaker claim it is.
161
+ - **No console access**: creating the OAuth client is a human step; state it once, do
162
+ the rest, and say what is waiting on it.