@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
@@ -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
 
@@ -41,21 +41,25 @@ secrets, no redirect URIs, no token storage for Google APIs.
41
41
  Browser Google Your backend
42
42
  ─────── ────── ────────────
43
43
  1. load gsi/client script
44
- 2. google.accounts.id.initialize({client_id, callback, nonce})
44
+ 0. GET /api/auth/google/nonce ────────────────────────────► server issues nonce,
45
+ (pre-auth HttpOnly cookie set) stores it w/ TTL
46
+ 2. google.accounts.id.initialize({client_id, callback, nonce: serverIssued})
45
47
  3. renderButton(#container)
46
48
  4. user clicks button ──────► account-chooser popup
47
49
  (user picks account,
48
50
  consents on first use)
49
51
  5. callback receives ◄────── ID token (JWT, RS256-signed
50
52
  {credential: "<jwt>"} by Google's private key)
51
- 6. POST /api/auth/google {credential, nonce} ─────────────► 7. verify ID token:
53
+ 6. POST /api/auth/google {credential} ────────────────────► 7. verify ID token:
52
54
  - signature vs Google JWKS
53
55
  (https://www.googleapis.com/oauth2/v3/certs)
54
56
  - aud == YOUR client_id
55
57
  - iss == accounts.google.com
56
58
  - exp not passed
57
59
  - email_verified == true
58
- - nonce matches
60
+ - nonce claim == server
61
+ expectation (popped:
62
+ one-time consume)
59
63
  8. find-or-create user,
60
64
  link accounts
61
65
  9. issue YOUR OWN session
@@ -81,7 +85,7 @@ Payload claims you care about:
81
85
  "email_verified": true,
82
86
  "name": "Ada Lovelace",
83
87
  "picture": "https://lh3.googleusercontent.com/…",
84
- "nonce": "d9b2d63d-…", // echoed from initialize() — replay defense
88
+ "nonce": "d9b2d63d-…", // the SERVER-issued value passed to initialize()
85
89
  "iat": 1719410000,
86
90
  "exp": 1719413600 // ~1 hour lifetime
87
91
  }
@@ -107,7 +111,7 @@ Payload claims you care about:
107
111
  // app.js (essentials)
108
112
  var _googleNonce = '';
109
113
 
110
- function _renderGoogleButton() {
114
+ async function _renderGoogleButton() {
111
115
  var clientId = _getGoogleClientId(); // read from the <meta> tag
112
116
  if (!clientId) return; // Google auth not configured → hide, degrade honestly
113
117
  if (typeof google === 'undefined' || !google.accounts) {
@@ -115,7 +119,10 @@ function _renderGoogleButton() {
115
119
  if (_googleRenderRetries++ < 20) setTimeout(_renderGoogleButton, 150);
116
120
  return;
117
121
  }
118
- _googleNonce = crypto.randomUUID(); // fresh nonce per render — replay defense
122
+ // SERVER-issued nonce: the backend stores it (TTL, pre-auth HttpOnly cookie)
123
+ // and will pop it on the callback — the client never invents the value.
124
+ var nr = await fetch('/api/auth/google/nonce');
125
+ _googleNonce = (await nr.json()).nonce;
119
126
 
120
127
  google.accounts.id.initialize({
121
128
  client_id: clientId,
@@ -135,7 +142,7 @@ async function handleGoogleCredential(response) {
135
142
  var resp = await fetch('/api/auth/google', {
136
143
  method: 'POST',
137
144
  headers: { 'Content-Type': 'application/json' },
138
- body: JSON.stringify({ credential: response.credential, nonce: _googleNonce }),
145
+ body: JSON.stringify({ credential: response.credential }),
139
146
  });
140
147
  var data = await resp.json();
141
148
  if (!resp.ok) { showError(data.detail); return; }
@@ -185,14 +192,21 @@ Every Google login resolves to exactly one of three cases:
185
192
 
186
193
  1. **Returning Google user** — a record with this `google_id` (`sub`) exists
187
194
  → issue session. Refresh `name`/`picture` if they changed.
188
- 2. **Existing email/password account, no `google_id`** — link Google to it
189
- (set `google_id`, mark `email_verified=True`) → issue session.
190
- ⚠️ **Pre-hijacking guard** (see §4.3): only auto-link if the existing
191
- account's email ownership was verified. Otherwise refuse with "sign in
192
- with your password".
193
- 3. **Brand-new user** — create the account with `email_verified=True` (the
194
- Google token already proved inbox ownership), empty password hash → issue
195
- session.
195
+ 2. **Existing account, no `google_id`** — `email_verified` is NOT authority to
196
+ attach Google to it. It means Google verified the inbox once; it does not
197
+ mean this person owns the LOCAL account, and for a third-party address it
198
+ does not even mean they still own the inbox. Default: link only after a
199
+ **fresh re-auth of the existing local account** (§4.3). Auto-link without
200
+ re-auth is permitted only when `google_authoritative(payload)` — the address
201
+ is `@gmail.com` or the token carries an `hd` Workspace claim — AND the
202
+ existing record's own email ownership was proven; for any other address run
203
+ an **independent inbox challenge** first. If the existing account is a
204
+ password account whose email was **never verified**, route to **safe
205
+ account recovery** (§4.3), never to the existing password.
206
+ 3. **Brand-new user** — create the account, empty password hash. Mark
207
+ `email_verified=True` only when `google_authoritative(payload)`; for a
208
+ third-party address (non-Gmail, no `hd`) create it UNVERIFIED and challenge
209
+ the inbox before anything trusts the email → issue session.
196
210
 
197
211
  Then issue your own session. Prowl issues an HS256 app JWT (72 h, `ver` claim
198
212
  for global revocation) delivered as an **HttpOnly, Secure, SameSite=Strict
@@ -261,11 +275,17 @@ npm i google-auth-library # Node
261
275
 
262
276
  ### 4.2 Nonce — replay defense
263
277
 
264
- Generate a fresh random nonce per button render, pass it to
265
- `google.accounts.id.initialize({nonce})`, send it alongside the credential,
266
- compare server-side with the token's `nonce` claim
267
- (`web/auth.py:375-376`). A stolen/logged ID token can't be replayed later
268
- because the nonce won't match the new session's nonce.
278
+ **The server issues the nonce; the client never invents one.** A
279
+ client-generated nonce POSTed back beside the credential proves nothing — the
280
+ "expected" value would be read out of the very JWT it is meant to check, so a
281
+ stolen token replays with its own nonce forever. Fetch the nonce from
282
+ `GET /api/auth/google/nonce` (the server stores it with a TTL, bound to a
283
+ pre-auth HttpOnly session cookie), pass it to
284
+ `google.accounts.id.initialize({nonce})`, and on the callback the server POPs
285
+ the expectation (one-time consume) and requires the token's `nonce` claim to
286
+ equal it exactly. Missing claim, missing expectation, expiry and re-use all
287
+ reject. First sign-in passes; a replay from a new session meets a different
288
+ expectation; a replay in the same session meets a consumed one.
269
289
 
270
290
  ### 4.3 Account pre-hijacking guard (P3-01 in Prowl)
271
291
 
@@ -274,10 +294,31 @@ victim ever visits. Later the victim clicks "Sign in with Google". Naïve
274
294
  auto-linking attaches victim's Google to the **attacker's** record — attacker
275
295
  keeps password access to the merged account (its data and wallet).
276
296
 
277
- Defense (`web/auth.py:392-417`): when a Google login matches an existing
278
- **password** account whose email was **never verified**, refuse to auto-link;
279
- tell the user to sign in with the password. Link only when email ownership of
280
- the existing record is proven.
297
+ Defense: `email_verified` is not proof of ownership OF THE LOCAL ACCOUNT, and
298
+ Google is only authoritative for the *address* when it is Gmail or carries an
299
+ `hd` Workspace claim — for a third-party email, ownership can have changed
300
+ since Google verified it (Google's own guidance excludes non-Gmail without
301
+ `hd`). So:
302
+
303
+ ```python
304
+ def google_authoritative(payload):
305
+ email = (payload.get("email") or "").lower()
306
+ return bool(payload.get("email_verified")
307
+ and (email.endswith("@gmail.com") or payload.get("hd")))
308
+ ```
309
+
310
+ - **Default** — link only after a **fresh re-auth of the existing local
311
+ account** (its password or existing factor). Proving you hold the account is
312
+ the only thing that authorizes attaching a new identity to it.
313
+ - **Auto-link without re-auth** — permitted only when
314
+ `google_authoritative(payload)` AND the existing record's email ownership was
315
+ already proven. For any other address, run an **independent challenge** (a
316
+ verification link to that inbox) first.
317
+ - **Unverified pre-registration** — when the existing password account's email
318
+ was never verified, DO NOT tell the victim to "sign in with the password":
319
+ that password may be the attacker's. Route them into a **safe
320
+ account-recovery flow** (reset via a fresh inbox challenge), which takes the
321
+ account away from whoever pre-registered it rather than handing it over.
281
322
 
282
323
  ### 4.4 Login-CSRF (P3-03 in Prowl)
283
324
 
@@ -287,12 +328,22 @@ attacker's account (victim then unknowingly feeds data/payments into it).
287
328
 
288
329
  Defense (`web/server.py:1657-1686`), covering both possible delivery flows:
289
330
 
290
- - **Form-POST flow** (GIS `login_uri` auto-POST): GIS double-submits
291
- `g_csrf_token` in body *and* cookie — compare both, constant-time
292
- (`hmac.compare_digest`). Reject if either is missing.
293
- - **JS-fetch flow** (this app): no `g_csrf_token`, so enforce same-origin via
294
- **Fetch-Metadata** (`Sec-Fetch-Site: same-origin`) with an Origin-allowlist
295
- fallback. These headers are browser-set and unforgeable by page script.
331
+ The two flows are SEPARATE, explicitly typed paths chosen by Content-Type. A
332
+ handler that claims form support but binds only a JSON body never runs the form
333
+ contract — the CSRF check for the form flow (`g_csrf_token`) is on a request
334
+ shape that flow never sends.
335
+
336
+ - **Form-POST flow** (`login_uri` auto-POST, `application/x-www-form-urlencoded`):
337
+ GIS double-submits `g_csrf_token` in the FORM BODY and the cookie — require
338
+ BOTH present and equal, constant-time (`hmac.compare_digest`). Reject if
339
+ either is missing.
340
+ - **JS-fetch flow** (`application/json`): no `g_csrf_token`, so require a
341
+ trusted same-origin signal — `Sec-Fetch-Site: same-origin`, OR (only when
342
+ that header is ABSENT) an exact `Origin` in an allowlist. A **missing
343
+ `Sec-Fetch-Site` is NOT assumed same-origin**; `cross-site` and `none` are
344
+ rejected; with neither a trusted metadata value nor an allowed Origin the
345
+ request **fails closed**. These headers are browser-set and unforgeable by
346
+ page script. The CSRF decision runs BEFORE token verification.
296
347
 
297
348
  ### 4.5 Session hygiene
298
349
 
@@ -346,8 +397,8 @@ Defense (`web/server.py:1657-1686`), covering both possible delivery flows:
346
397
 
347
398
  ```python
348
399
  # --- backend ---
349
- import os, hmac
350
- from fastapi import FastAPI, HTTPException, Request, Response
400
+ import os, hmac, secrets, time
401
+ from fastapi import FastAPI, Form, HTTPException, Request, Response
351
402
  from pydantic import BaseModel
352
403
  from google.oauth2 import id_token
353
404
  from google.auth.transport import requests as google_requests
@@ -356,29 +407,79 @@ GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
356
407
  _transport = google_requests.Request()
357
408
  app = FastAPI()
358
409
 
410
+ NONCE_TTL = 600
411
+ _nonce_store: dict[str, tuple[str, float]] = {} # sid -> (nonce, expires); use Redis in prod
412
+
413
+ ALLOWED_ORIGINS = {"https://app.example.com"} # your own origin(s)
414
+
415
+
359
416
  class GoogleAuthRequest(BaseModel):
360
417
  credential: str
361
- nonce: str | None = None
362
- g_csrf_token: str | None = None
363
-
364
- @app.post("/api/auth/google")
418
+ g_csrf_token: str | None = None # no nonce in the body — the server holds it
419
+
420
+
421
+ def _csrf_ok_form(cookie_token, form_token):
422
+ # The form flow's whole authorization: both present and equal, constant-time.
423
+ if not form_token or not cookie_token:
424
+ return False
425
+ return hmac.compare_digest(cookie_token, form_token)
426
+
427
+
428
+ def _csrf_ok_json(sec_fetch_site, origin):
429
+ # Trusted metadata first; Origin allowlist ONLY as an explicit fallback when
430
+ # the header is absent. A missing Sec-Fetch-Site is not same-origin, and
431
+ # `none`/`cross-site` are rejected — fail closed when neither proves origin.
432
+ if sec_fetch_site == "same-origin":
433
+ return True
434
+ if sec_fetch_site in ("cross-site", "same-site", "none"):
435
+ return False
436
+ if sec_fetch_site is None: # header absent → exact Origin, allowlisted
437
+ return bool(origin) and origin in ALLOWED_ORIGINS
438
+ return False
439
+
440
+ @app.get("/api/auth/google/nonce")
441
+ async def google_nonce(req: Request, response: Response):
442
+ sid = req.cookies.get("preauth") or secrets.token_urlsafe(32)
443
+ nonce = secrets.token_urlsafe(32)
444
+ _nonce_store[sid] = (nonce, time.time() + NONCE_TTL)
445
+ response.set_cookie("preauth", sid, httponly=True, secure=True,
446
+ samesite="lax", max_age=NONCE_TTL, path="/")
447
+ return {"nonce": nonce}
448
+
449
+ # Two explicitly typed routes. Content-Type picks the flow; each runs ONLY its
450
+ # own CSRF contract; both fail closed; the check is before any token call.
451
+
452
+ @app.post("/api/auth/google/form") # application/x-www-form-urlencoded
453
+ async def google_auth_form(req: Request, response: Response,
454
+ credential: str = Form(...),
455
+ g_csrf_token: str = Form(None)):
456
+ if not _csrf_ok_form(req.cookies.get("g_csrf_token"), g_csrf_token):
457
+ raise HTTPException(403, "CSRF: g_csrf_token missing or mismatched")
458
+ return await _finish_google_auth(req, response, credential)
459
+
460
+
461
+ @app.post("/api/auth/google") # application/json (JS fetch)
365
462
  async def google_auth(req: Request, data: GoogleAuthRequest, response: Response):
366
- # CSRF: double-submit for form-POST flow, Fetch-Metadata for JS flow
367
- if data.g_csrf_token:
368
- cookie = req.cookies.get("g_csrf_token") or ""
369
- if not hmac.compare_digest(cookie, data.g_csrf_token):
370
- raise HTTPException(403, "CSRF token mismatch")
371
- elif req.headers.get("sec-fetch-site", "same-origin") not in ("same-origin", "none"):
372
- raise HTTPException(403, "Cross-site request rejected")
463
+ sec = req.headers.get("sec-fetch-site") # None when the header is absent
464
+ if not _csrf_ok_json(sec, req.headers.get("origin")):
465
+ raise HTTPException(403, "CSRF: request origin not proven (fail closed)")
466
+ return await _finish_google_auth(req, response, data.credential)
373
467
 
468
+
469
+ async def _finish_google_auth(req: Request, response: Response, credential: str):
374
470
  try:
375
- payload = id_token.verify_oauth2_token(data.credential, _transport, GOOGLE_CLIENT_ID)
471
+ payload = id_token.verify_oauth2_token(credential, _transport, GOOGLE_CLIENT_ID)
376
472
  except Exception as exc:
377
473
  raise HTTPException(401, f"Invalid Google token: {exc}")
378
474
  if not payload.get("email_verified"):
379
475
  raise HTTPException(401, "Google account email is not verified")
380
- if data.nonce and payload.get("nonce") != data.nonce:
381
- raise HTTPException(401, "Nonce mismatch")
476
+ # One-time consume BEFORE any account work: pop() removes the expectation
477
+ # in the same step, so a replayed callback finds nothing. Mandatory — a
478
+ # missing claim or a missing expectation rejects; the body carries no nonce.
479
+ sid = req.cookies.get("preauth") or ""
480
+ expected, expires = _nonce_store.pop(sid, (None, 0.0))
481
+ if not expected or time.time() > expires or payload.get("nonce") != expected:
482
+ raise HTTPException(401, "Nonce missing, expired, replayed or not server-issued")
382
483
 
383
484
  user = await find_or_create_user( # your 3-way linking here (§2.4, §4.3)
384
485
  google_id=payload["sub"], email=payload["email"].lower().strip(),
@@ -395,8 +496,8 @@ async def google_auth(req: Request, data: GoogleAuthRequest, response: Response)
395
496
  <script src="https://accounts.google.com/gsi/client" async defer></script>
396
497
  <div id="gbtn"></div>
397
498
  <script>
398
- const NONCE = crypto.randomUUID();
399
- window.onload = () => {
499
+ window.onload = async () => {
500
+ const NONCE = (await (await fetch("/api/auth/google/nonce")).json()).nonce;
400
501
  google.accounts.id.initialize({
401
502
  client_id: "YOUR_CLIENT_ID.apps.googleusercontent.com",
402
503
  nonce: NONCE,
@@ -404,7 +505,7 @@ async def google_auth(req: Request, data: GoogleAuthRequest, response: Response)
404
505
  const r = await fetch("/api/auth/google", {
405
506
  method: "POST",
406
507
  headers: { "Content-Type": "application/json" },
407
- body: JSON.stringify({ credential, nonce: NONCE }),
508
+ body: JSON.stringify({ credential }),
408
509
  });
409
510
  if (r.ok) location.href = "/app";
410
511
  },
@@ -198,7 +198,8 @@ export async function POST(request: Request) {
198
198
  }
199
199
 
200
200
  const claim = await claimEvent(event.id); // INSERT on a primary key
201
- if (claim === "duplicate") return json({ received: true, duplicate: true });
201
+ if (claim === "completed") return json({ received: true, duplicate: true });
202
+ if (claim === "in_flight") return json({ error: "in flight" }, 500); // retry later
202
203
 
203
204
  try {
204
205
  await handle(event);
@@ -215,16 +216,17 @@ export async function POST(request: Request) {
215
216
  - **Exempt this path — and only this path — from CSRF and session auth**, by
216
217
  exact match: a prefix match over `/api/billing` exempts checkout too, which is
217
218
  where the money is.
218
- - **Claim before working.** `SELECT` then `INSERT` is a race; two deliveries
219
- 40 ms apart both pass the read and both credit. Release the claim if
220
- processing throws, or the retry finds the event "processed" and the work is
221
- lost forever.
219
+ - **Claim before working — a claim is a receipt, not completion.** `SELECT`
220
+ then `INSERT` is a race; two deliveries 40 ms apart both credit. The row
221
+ stays `processing` until the grant's transaction marks it `completed` —
222
+ states, expiry, takeover and release:
223
+ [`references/webhook-events.md`](references/webhook-events.md).
222
224
  - **Answer honestly.** 200 handled or duplicate, 400 bad signature, 5xx try
223
225
  again, 200 for types you do not handle. Never 200 on failure to stop retries —
224
226
  that discards a payment quietly.
225
227
  - **Order inside a handler:** fallible external calls first, then one
226
- transaction, then side effects that must not run twice (emails, provisioning)
227
- after it commits.
228
+ transaction — entitlement, dedup marker, completion, outbox rows — then side
229
+ effects drain from the outbox, each under its own consumer key.
228
230
 
229
231
  Per-event detail: [`references/webhook-events.md`](references/webhook-events.md).
230
232
 
@@ -62,7 +62,17 @@ Run `node assert-money-invariants.mjs` and each line names the invariant it hold
62
62
  | `proration-invoice-grants-nothing` | cycle + proration | gives a month of product for a $40 proration invoice |
63
63
  | `sequential-redelivery-grants-once` | cycle + redelivery | answers `received` twice and sends the renewal notice and the server-side conversion twice |
64
64
  | `concurrent-redelivery-grants-once` | cycle + redelivery, in flight together | both deliveries pass the marker's `SELECT` and both credit |
65
+ | `crash-after-receipt-is-retryable` | cycle (worker dies) + redelivery | tells the retry "duplicate" about work that never finished — the payment is swallowed forever |
66
+ | `in-flight-claim-answers-retry-later` | cycle (worker dies) + redelivery | answers `duplicate` to a retry while the first worker may still be mid-grant |
67
+ | `completion-is-recorded-with-the-grant` | cycle | leaves the claim row saying `processing` after answering done — every later retry reads it as a corpse |
68
+ | `duplicate-of-completed-never-regrants` | cycle + redelivery, aged past the claim expiry | takes the completed row over as if the worker had died, and grants again |
69
+ | `transaction-rolls-back-whole` | cycle (crash inside the transaction) | leaves the grant applied with no completion — half a payment no retry can see |
70
+ | `crash-before-commit-applies-nothing` | cycle (crash) + redelivery | the retry double-applies or never applies what the crash interrupted |
71
+ | `committed-retry-sends-once` | cycle, outbox redelivered | sends "your renewal" twice and counts the revenue twice |
65
72
  | `sequential-redelivery-grants-once-by-count-alone` | cycle + redelivery | *nothing* — kept because that is the finding; see below |
73
+ | `concurrent-entry-points-grant-once` | cycle, webhook + reconciler in flight together | both pass the marker's read and one period is granted twice |
74
+ | `serialization-conflict-does-not-lose-the-renewal` | cycle, one injected conflict | drops the conflicted transaction and answers 200 — the renewal silently never lands |
75
+ | `exhausted-retries-answer-5xx-and-the-redelivery-lands` | cycle + redelivery, conflicts past the bound | swallows the conflict instead of answering 5xx, so nothing ever retries |
66
76
  | `reconciliation-does-not-regrant` | cycle | the nightly repair grants a period the webhook already granted |
67
77
  | `out-of-order-pair-does-not-rewind-state` | February then January | stores the older period, so the renewal date points at a period that ended |
68
78
  | `refund-total-is-cumulative` | both refunds | claws back $130 against a $90 charge |
@@ -80,7 +90,13 @@ a line a generated handler routinely omits:
80
90
 
81
91
  | Rule | The code it removes | The generated handler that ships without it |
82
92
  |---|---|---|
83
- | `claim` | `if (!store.claimEvent(event.id)) return duplicate` | reads the processed table, then writes it — two deliveries 40 ms apart both pass the read |
93
+ | `claim` | the whole claim block at the top of `deliver` | reads the processed table, then writes it — two deliveries 40 ms apart both pass the read |
94
+ | `claim-completion` | `store.completeEvent(event.id)` after the grant commits | leaves every row at `processing`, so a retry past the claim expiry reprocesses a completed event |
95
+ | `claim-expiry` | the takeover of a `processing` row older than `CLAIM_TTL_MS` | answers "in flight" forever for a worker that died — received quietly read as completed |
96
+ | `atomic-application` | the snapshot/restore around the business application | applies the grant and loses the completion when a crash lands mid-way |
97
+ | `outbox-consumer-key` | the consumer's own dedup across redelivered rows | sends every redelivered outbox row again |
98
+ | `grant-key-atomic` | the keyed INSERT claim on the period grant | falls back to read-then-mark — two concurrent grants of one period both pass the read |
99
+ | `tx-retry-bounded` | the bounded serialization-retry loop | a conflicted transaction is dropped and the route answers 200 |
84
100
  | `billing-reason` | `if (invoice.billing_reason !== 'subscription_cycle') return` | handles `invoice.paid` as "a payment arrived" |
85
101
  | `grant-marker` | `if (granted.has(period.start)) return` | grants whenever an event says paid, including from the reconciliation job |
86
102
  | `ordering` | `if (period.start > mirror.periodStart)` around the mirror write | writes whatever arrived last |