@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.
- package/CHANGELOG.md +16 -0
- package/package.json +4 -3
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +24 -8
- package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +57 -12
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +24 -26
- package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +52 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +42 -23
- package/plugins/sheleg-dev/skills/error-tracking/SKILL.md +34 -6
- package/plugins/sheleg-dev/skills/error-tracking/references/scrubbing.md +65 -2
- package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +51 -23
- package/plugins/sheleg-dev/skills/google-auth/SKILL.md +13 -1
- package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +13 -3
- package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +150 -64
- package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +36 -19
- package/plugins/sheleg-dev/skills/google-signin/SKILL.md +55 -23
- package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +150 -49
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +9 -7
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +17 -1
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +171 -5
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +333 -198
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +259 -35
- package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +149 -28
- 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
|
-
|
|
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 =
|
|
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
|
|
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 !==
|
|
287
|
-
throw new Error('Nonce
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
(
|
|
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
|
|
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
|
|
48
|
-
|
|
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` →
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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-…", //
|
|
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
|
-
|
|
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
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
if
|
|
368
|
-
|
|
369
|
-
|
|
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(
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
|
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 === "
|
|
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
|
|
219
|
-
|
|
220
|
-
processing
|
|
221
|
-
|
|
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
|
|
227
|
-
|
|
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` |
|
|
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 |
|