@ssheleg/sheleg-dev 0.11.7 → 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 (29) hide show
  1. package/CHANGELOG.md +37 -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 -24
  6. package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +0 -13
  7. package/plugins/sheleg-dev/skills/ad-tracking/references/gtag-api.md +0 -17
  8. package/plugins/sheleg-dev/skills/ad-tracking/references/performance-security.md +0 -11
  9. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +24 -26
  10. package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +52 -0
  11. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +42 -23
  12. package/plugins/sheleg-dev/skills/error-tracking/SKILL.md +34 -6
  13. package/plugins/sheleg-dev/skills/error-tracking/references/scrubbing.md +65 -2
  14. package/plugins/sheleg-dev/skills/error-tracking/references/setup.md +0 -1
  15. package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +51 -23
  16. package/plugins/sheleg-dev/skills/google-auth/SKILL.md +13 -1
  17. package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +13 -16
  18. package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +150 -79
  19. package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +36 -33
  20. package/plugins/sheleg-dev/skills/google-auth/references/workload-identity.md +0 -13
  21. package/plugins/sheleg-dev/skills/google-signin/SKILL.md +55 -23
  22. package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +150 -49
  23. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +9 -7
  24. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +17 -1
  25. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +171 -5
  26. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +333 -198
  27. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +259 -35
  28. package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +149 -28
  29. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +76 -5
@@ -3,7 +3,6 @@
3
3
 
4
4
  ## Contents
5
5
 
6
- - [Table of Contents](#table-of-contents)
7
6
  - [Prerequisites](#prerequisites)
8
7
  - [Step 1: Set Authorization Parameters](#step-1-set-authorization-parameters)
9
8
  - [Step 2: Redirect to Google](#step-2-redirect-to-google)
@@ -18,20 +17,6 @@
18
17
  - [Complete FastAPI Example](#complete-fastapi-example)
19
18
  - [Scopes Reference](#scopes-reference)
20
19
 
21
- ## Table of Contents
22
- - [Prerequisites](#prerequisites)
23
- - [Step 1: Set Authorization Parameters](#step-1-set-authorization-parameters)
24
- - [Step 2: Redirect to Google](#step-2-redirect-to-google)
25
- - [Step 3: Handle Callback](#step-3-handle-callback)
26
- - [Step 4: Use Access Token](#step-4-use-access-token)
27
- - [Refreshing Tokens](#refreshing-tokens)
28
- - [Revoking Tokens](#revoking-tokens)
29
- - [Incremental Authorization](#incremental-authorization)
30
- - [Error Reference](#error-reference)
31
- - [Complete Express Example](#complete-express-example)
32
- - [Complete Flask Example](#complete-flask-example)
33
- - [Complete FastAPI Example](#complete-fastapi-example)
34
-
35
20
  ## Prerequisites
36
21
 
37
22
  1. Enable APIs in [Google Cloud Console](https://console.developers.google.com/apis/library)
@@ -57,20 +42,30 @@ pip install google-auth google-auth-oauthlib google-api-python-client flask
57
42
 
58
43
  ### Node.js
59
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
+
60
52
  ```js
61
53
  const {google} = require('googleapis');
62
54
  const crypto = require('crypto');
63
55
 
64
- const oauth2Client = new google.auth.OAuth2(
65
- YOUR_CLIENT_ID,
66
- YOUR_CLIENT_SECRET,
67
- YOUR_REDIRECT_URL
68
- );
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
+ }
69
60
 
61
+ const oauthClient = newOAuthClient();
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.
70
65
  const state = crypto.randomBytes(32).toString('hex');
71
- req.session.state = state;
66
+ req.session.oauthState = { value: state, expires: Date.now() + 10 * 60 * 1000 };
72
67
 
73
- const authorizationUrl = oauth2Client.generateAuthUrl({
68
+ const authorizationUrl = oauthClient.generateAuthUrl({
74
69
  access_type: 'offline', // 'online' (default) or 'offline' (gets refresh_token)
75
70
  scope: [
76
71
  'https://www.googleapis.com/auth/drive.metadata.readonly',
@@ -88,6 +83,7 @@ const authorizationUrl = oauth2Client.generateAuthUrl({
88
83
  ```python
89
84
  from google_auth_oauthlib.flow import Flow
90
85
  import secrets
86
+ import time
91
87
 
92
88
  flow = Flow.from_client_secrets_file(
93
89
  'client_secret.json',
@@ -99,7 +95,8 @@ flow = Flow.from_client_secrets_file(
99
95
  )
100
96
 
101
97
  state = secrets.token_hex(32)
102
- 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}
103
100
 
104
101
  authorization_url, state = flow.authorization_url(
105
102
  access_type='offline', # gets refresh_token
@@ -140,6 +137,41 @@ return redirect(authorization_url)
140
137
 
141
138
  ## Step 3: Handle Callback
142
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
+
143
175
  ### Node.js
144
176
 
145
177
  ```js
@@ -149,16 +181,21 @@ app.get('/oauth2callback', async (req, res) => {
149
181
  const q = url.parse(req.url, true).query;
150
182
 
151
183
  if (q.error) {
152
- console.log('Error: ' + q.error);
153
- return res.status(400).send('Authorization failed');
184
+ return res.status(400).send('Authorization failed'); // never log the raw error/token
154
185
  }
155
186
 
156
- if (q.state !== req.session.state) {
157
- 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');
158
194
  }
159
195
 
160
- const {tokens} = await oauth2Client.getToken(q.code);
161
- 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
162
199
 
163
200
  // tokens.access_token — short-lived access token
164
201
  // tokens.refresh_token — long-lived (only on first auth!)
@@ -177,14 +214,20 @@ def oauth2callback():
177
214
  if request.args.get('error'):
178
215
  return 'Authorization failed', 400
179
216
 
180
- if request.args.get('state') != session.get('state'):
181
- 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')
182
225
 
183
226
  flow = Flow.from_client_secrets_file(
184
227
  'client_secret.json',
185
228
  scopes=SCOPES,
186
229
  redirect_uri=YOUR_REDIRECT_URL,
187
- state=session['state']
230
+ state=saved['value']
188
231
  )
189
232
  flow.fetch_token(authorization_response=request.url)
190
233
 
@@ -194,20 +237,26 @@ def oauth2callback():
194
237
  # credentials.expiry — expiration datetime
195
238
  # credentials.scopes — granted scopes
196
239
 
197
- 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={
198
244
  'token': credentials.token,
199
245
  'refresh_token': credentials.refresh_token,
200
246
  'token_uri': credentials.token_uri,
201
247
  'client_id': credentials.client_id,
202
248
  'client_secret': credentials.client_secret,
203
- 'scopes': list(credentials.scopes)
204
- }
249
+ 'scopes': list(credentials.scopes),
250
+ })
205
251
  return redirect('/profile')
206
252
  ```
207
253
 
208
254
  ### Python (FastAPI)
209
255
 
210
256
  ```python
257
+ import secrets
258
+ import time
259
+
211
260
  from fastapi import APIRouter, Request
212
261
  from fastapi.responses import RedirectResponse
213
262
  from google_auth_oauthlib.flow import Flow
@@ -217,15 +266,19 @@ async def oauth2callback(request: Request):
217
266
  if request.query_params.get('error'):
218
267
  raise HTTPException(400, 'Authorization failed')
219
268
 
220
- state = request.session.get('state')
221
- if request.query_params.get('state') != state:
222
- 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')
223
276
 
224
277
  flow = Flow.from_client_secrets_file(
225
278
  'client_secret.json',
226
279
  scopes=SCOPES,
227
280
  redirect_uri=YOUR_REDIRECT_URL,
228
- state=state
281
+ state=saved['value']
229
282
  )
230
283
  flow.fetch_token(code=request.query_params.get('code'))
231
284
  credentials = flow.credentials
@@ -277,7 +330,8 @@ oauth2Client.on('tokens', (tokens) => {
277
330
  if (tokens.refresh_token) {
278
331
  // Store in database — only sent once!
279
332
  }
280
- 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.
281
335
  });
282
336
  ```
283
337
 
@@ -392,19 +446,20 @@ const {google} = require('googleapis');
392
446
  const app = express();
393
447
  app.use(session({secret: 'your-secret', resave: false, saveUninitialized: false}));
394
448
 
395
- const oauth2Client = new google.auth.OAuth2(
396
- process.env.GOOGLE_CLIENT_ID,
397
- process.env.GOOGLE_CLIENT_SECRET,
398
- 'http://localhost:3000/oauth2callback'
399
- );
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
+ }
400
455
 
401
456
  const SCOPES = ['https://www.googleapis.com/auth/userinfo.profile'];
402
457
 
403
458
  app.get('/auth', (req, res) => {
404
459
  const state = crypto.randomBytes(32).toString('hex');
405
- req.session.state = state;
460
+ req.session.oauthState = { value: state, expires: Date.now() + 10 * 60 * 1000 };
406
461
 
407
- const url = oauth2Client.generateAuthUrl({
462
+ const url = newOAuthClient().generateAuthUrl({
408
463
  access_type: 'offline',
409
464
  scope: SCOPES,
410
465
  state,
@@ -415,18 +470,22 @@ app.get('/auth', (req, res) => {
415
470
 
416
471
  app.get('/oauth2callback', async (req, res) => {
417
472
  if (req.query.error) return res.redirect('/error');
418
- 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');
419
477
 
420
- const {tokens} = await oauth2Client.getToken(req.query.code);
478
+ const {tokens} = await newOAuthClient().getToken(req.query.code);
421
479
  req.session.tokens = tokens;
422
480
  res.redirect('/profile');
423
481
  });
424
482
 
425
483
  app.get('/profile', async (req, res) => {
426
484
  if (!req.session.tokens) return res.redirect('/auth');
427
- oauth2Client.setCredentials(req.session.tokens);
485
+ const oauthClient = newOAuthClient(); // local to this request
486
+ oauthClient.setCredentials(req.session.tokens);
428
487
 
429
- const oauth2 = google.oauth2({version: 'v2', auth: oauth2Client});
488
+ const oauth2 = google.oauth2({version: 'v2', auth: oauthClient});
430
489
  const {data} = await oauth2.userinfo.get();
431
490
  res.json(data);
432
491
  });
@@ -467,7 +526,7 @@ def auth():
467
526
  redirect_uri=REDIRECT_URI
468
527
  )
469
528
  state = secrets.token_hex(32)
470
- session['state'] = state
529
+ session['oauth_state'] = {'value': state, 'expires': time.time() + 600}
471
530
  authorization_url, _ = flow.authorization_url(
472
531
  access_type='offline',
473
532
  include_granted_scopes='true',
@@ -480,8 +539,11 @@ def auth():
480
539
  def oauth2callback():
481
540
  if request.args.get('error'):
482
541
  return 'Authorization failed', 400
483
- if request.args.get('state') != session.get('state'):
484
- 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')
485
547
 
486
548
  flow = Flow.from_client_config(
487
549
  {
@@ -494,26 +556,28 @@ def oauth2callback():
494
556
  },
495
557
  scopes=SCOPES,
496
558
  redirect_uri=REDIRECT_URI,
497
- state=session['state']
559
+ state=saved['value']
498
560
  )
499
561
  flow.fetch_token(authorization_response=request.url)
500
562
  creds = flow.credentials
501
- session['credentials'] = {
502
- 'token': creds.token,
503
- 'refresh_token': creds.refresh_token,
504
- 'token_uri': creds.token_uri,
505
- 'client_id': creds.client_id,
506
- 'client_secret': creds.client_secret,
507
- 'scopes': list(creds.scopes or [])
508
- }
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']
509
572
  return redirect('/profile')
510
573
 
511
574
 
512
575
  @app.route('/profile')
513
576
  def profile():
514
- if 'credentials' not in session:
577
+ stored = credential_store.get(session.get('sid'), session.get('principal'))
578
+ if not stored:
515
579
  return redirect('/auth')
516
- creds = Credentials(**session['credentials'])
580
+ creds = Credentials(**stored)
517
581
  service = build('oauth2', 'v2', credentials=creds)
518
582
  user_info = service.userinfo().get().execute()
519
583
  return jsonify(user_info)
@@ -559,7 +623,7 @@ CLIENT_CONFIG = {
559
623
  async def auth(request: Request):
560
624
  flow = Flow.from_client_config(CLIENT_CONFIG, scopes=SCOPES, redirect_uri=REDIRECT_URI)
561
625
  state = secrets.token_hex(32)
562
- request.session['state'] = state
626
+ request.session['oauth_state'] = {'value': state, 'expires': time.time() + 600}
563
627
  authorization_url, _ = flow.authorization_url(
564
628
  access_type='offline',
565
629
  include_granted_scopes='true',
@@ -572,31 +636,38 @@ async def auth(request: Request):
572
636
  async def oauth2callback(request: Request):
573
637
  if request.query_params.get('error'):
574
638
  raise HTTPException(400, 'Authorization failed')
575
- if request.query_params.get('state') != request.session.get('state'):
576
- 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')
577
644
 
578
645
  flow = Flow.from_client_config(
579
646
  CLIENT_CONFIG, scopes=SCOPES, redirect_uri=REDIRECT_URI,
580
- state=request.session['state']
647
+ state=saved['value']
581
648
  )
582
649
  flow.fetch_token(code=request.query_params.get('code'))
583
650
  creds = flow.credentials
584
- request.session['credentials'] = {
585
- 'token': creds.token,
586
- 'refresh_token': creds.refresh_token,
587
- 'token_uri': creds.token_uri,
588
- 'client_id': creds.client_id,
589
- 'client_secret': creds.client_secret,
590
- 'scopes': list(creds.scopes or [])
591
- }
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
+ })
592
661
  return RedirectResponse('/profile')
593
662
 
594
663
 
595
664
  @app.get('/profile')
596
665
  async def profile(request: Request):
597
- if 'credentials' not in request.session:
666
+ stored = credential_store.get(request.session.get('sid'),
667
+ request.session.get('principal'))
668
+ if not stored:
598
669
  return RedirectResponse('/auth')
599
- creds = Credentials(**request.session['credentials'])
670
+ creds = Credentials(**stored)
600
671
  service = build('oauth2', 'v2', credentials=creds)
601
672
  user_info = service.userinfo().get().execute()
602
673
  return user_info
@@ -3,7 +3,6 @@
3
3
 
4
4
  ## Contents
5
5
 
6
- - [Table of Contents](#table-of-contents)
7
6
  - [Overview](#overview)
8
7
  - [Client-Side Integration](#client-side-integration)
9
8
  - [Server-Side ID Token Verification](#server-side-id-token-verification)
@@ -17,19 +16,6 @@
17
16
  - [FedCM — mandatory, not a migration you are planning](#fedcm--mandatory-not-a-migration-you-are-planning)
18
17
  - [Sign Out](#sign-out)
19
18
 
20
- ## Table of Contents
21
- - [Overview](#overview)
22
- - [Client-Side Integration](#client-side-integration)
23
- - [Server-Side ID Token Verification](#server-side-id-token-verification)
24
- - [CSRF Protection](#csrf-protection)
25
- - [Nonce Verification](#nonce-verification)
26
- - [ID Token Payload Fields](#id-token-payload-fields)
27
- - [Account Linking Flows](#account-linking-flows)
28
- - [Next.js / React Integration Pattern](#nextjs--react-integration-pattern)
29
- - [FastAPI Integration Pattern](#fastapi-integration-pattern)
30
- - [Flask Integration Pattern](#flask-integration-pattern)
31
- - [FedCM Migration](#fedcm-migration)
32
-
33
19
  ## Overview
34
20
 
35
21
  Sign In with Google (part of Google Identity Services / GIS) provides:
@@ -242,8 +228,7 @@ from pydantic import BaseModel
242
228
 
243
229
  class GoogleLoginRequest(BaseModel):
244
230
  credential: str
245
- g_csrf_token: str | None = None
246
- nonce: str | None = None
231
+ g_csrf_token: str | None = None # no nonce field — the server holds the expectation
247
232
 
248
233
  @router.post("/google")
249
234
  async def google_login(request: Request, body: GoogleLoginRequest):
@@ -279,38 +264,54 @@ def google_login():
279
264
 
280
265
  ## Nonce Verification
281
266
 
282
- 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.
283
280
 
284
281
  **Client-side:**
285
282
 
286
283
  ```js
287
- const nonce = crypto.randomUUID(); // or any random string
284
+ const nonce = (await (await fetch('/api/auth/google/nonce')).json()).nonce;
288
285
  google.accounts.id.initialize({
289
286
  client_id: CLIENT_ID,
290
- callback: handleResponse,
291
- nonce: nonce
287
+ callback: handleResponse, // POSTs { credential } only
288
+ nonce
292
289
  });
293
- // Send nonce alongside credential to your backend
294
290
  ```
295
291
 
296
292
  **Server-side (Node.js):**
297
293
 
298
294
  ```js
295
+ const sid = req.cookies.preauth || '';
296
+ const expected = await nonceStore.pop(sid); // one-time consume, with TTL
299
297
  const payload = ticket.getPayload();
300
- if (payload.nonce !== expectedNonce) {
301
- throw new Error('Nonce mismatch');
298
+ if (!expected || payload.nonce !== expected) {
299
+ throw new Error('Nonce missing, expired, replayed or not server-issued');
302
300
  }
303
301
  ```
304
302
 
305
303
  **Server-side (Python):**
306
304
 
307
305
  ```python
308
- if body.nonce:
309
- token_nonce = payload.get('nonce')
310
- if not token_nonce or token_nonce != body.nonce:
311
- 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')
312
310
  ```
313
311
 
312
+ First sign-in passes; a replay from a new session meets a different
313
+ expectation; a replay after consume meets none.
314
+
314
315
  ## ID Token Payload Fields
315
316
 
316
317
  | Field | Description |
@@ -488,8 +489,7 @@ CLIENT_ID = settings.google_client_id
488
489
 
489
490
  class GoogleLoginRequest(BaseModel):
490
491
  credential: str
491
- g_csrf_token: str | None = None
492
- nonce: str | None = None
492
+ g_csrf_token: str | None = None # no nonce field — the server holds the expectation
493
493
 
494
494
  @router.post("/auth/google")
495
495
  async def google_login(request: Request, body: GoogleLoginRequest):
@@ -508,10 +508,13 @@ async def google_login(request: Request, body: GoogleLoginRequest):
508
508
  if not payload.get("email_verified", False):
509
509
  raise HTTPException(status_code=401, detail="Email not verified")
510
510
 
511
- # 3. Verify nonce
512
- if body.nonce:
513
- if payload.get("nonce") != body.nonce:
514
- 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")
515
518
 
516
519
  # 4. Find or create user, issue app JWT...
517
520
  user, is_new = await find_or_create_google_user(db, payload)
@@ -3,19 +3,6 @@
3
3
 
4
4
  ## Contents
5
5
 
6
- - [Table of Contents](#table-of-contents)
7
- - [Overview](#overview)
8
- - [AWS Federation](#aws-federation)
9
- - [Azure Federation](#azure-federation)
10
- - [OIDC Provider Federation](#oidc-provider-federation)
11
- - [Custom Subject Token Supplier](#custom-subject-token-supplier)
12
- - [Workforce Identity Federation](#workforce-identity-federation)
13
- - [Executable-Sourced Credentials](#executable-sourced-credentials)
14
- - [Using External Identities](#using-external-identities)
15
- - [Configurable Token Lifetime](#configurable-token-lifetime)
16
- - [Security Considerations](#security-considerations)
17
-
18
- ## Table of Contents
19
6
  - [Overview](#overview)
20
7
  - [AWS Federation](#aws-federation)
21
8
  - [Azure Federation](#azure-federation)