@ssheleg/sheleg-dev 0.11.8 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -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 +35 -8
- package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +57 -12
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +39 -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 +62 -23
- package/plugins/sheleg-dev/skills/google-auth/SKILL.md +22 -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 +64 -23
- package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +150 -49
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +26 -9
- 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
|
@@ -42,20 +42,30 @@ pip install google-auth google-auth-oauthlib google-api-python-client flask
|
|
|
42
42
|
|
|
43
43
|
### Node.js
|
|
44
44
|
|
|
45
|
+
**One OAuth2 client PER request, never a shared singleton.** A module-level
|
|
46
|
+
`oauth2Client` whose `setCredentials(tokens)` runs on every callback is mutated
|
|
47
|
+
by every concurrent login — two users signing in at once end up with each
|
|
48
|
+
other's tokens. Build the client inside the handler; the client id/secret are
|
|
49
|
+
shared config, the CREDENTIALS are per principal and never assigned to a
|
|
50
|
+
process-wide object.
|
|
51
|
+
|
|
45
52
|
```js
|
|
46
53
|
const {google} = require('googleapis');
|
|
47
54
|
const crypto = require('crypto');
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
YOUR_CLIENT_SECRET,
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
// A fresh client per request — the credentials it will hold are this user's.
|
|
57
|
+
function newOAuthClient() {
|
|
58
|
+
return new google.auth.OAuth2(YOUR_CLIENT_ID, YOUR_CLIENT_SECRET, YOUR_REDIRECT_URL);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const oauthClient = newOAuthClient();
|
|
54
62
|
|
|
63
|
+
// State is REQUIRED, crypto-random, single-use with a TTL, and bound to the
|
|
64
|
+
// server session — not a value the client can echo back to itself.
|
|
55
65
|
const state = crypto.randomBytes(32).toString('hex');
|
|
56
|
-
req.session.
|
|
66
|
+
req.session.oauthState = { value: state, expires: Date.now() + 10 * 60 * 1000 };
|
|
57
67
|
|
|
58
|
-
const authorizationUrl =
|
|
68
|
+
const authorizationUrl = oauthClient.generateAuthUrl({
|
|
59
69
|
access_type: 'offline', // 'online' (default) or 'offline' (gets refresh_token)
|
|
60
70
|
scope: [
|
|
61
71
|
'https://www.googleapis.com/auth/drive.metadata.readonly',
|
|
@@ -73,6 +83,7 @@ const authorizationUrl = oauth2Client.generateAuthUrl({
|
|
|
73
83
|
```python
|
|
74
84
|
from google_auth_oauthlib.flow import Flow
|
|
75
85
|
import secrets
|
|
86
|
+
import time
|
|
76
87
|
|
|
77
88
|
flow = Flow.from_client_secrets_file(
|
|
78
89
|
'client_secret.json',
|
|
@@ -84,7 +95,8 @@ flow = Flow.from_client_secrets_file(
|
|
|
84
95
|
)
|
|
85
96
|
|
|
86
97
|
state = secrets.token_hex(32)
|
|
87
|
-
|
|
98
|
+
# Server-bound, random, with a TTL: the callback consumes it atomically.
|
|
99
|
+
session['oauth_state'] = {'value': state, 'expires': time.time() + 600}
|
|
88
100
|
|
|
89
101
|
authorization_url, state = flow.authorization_url(
|
|
90
102
|
access_type='offline', # gets refresh_token
|
|
@@ -125,6 +137,41 @@ return redirect(authorization_url)
|
|
|
125
137
|
|
|
126
138
|
## Step 3: Handle Callback
|
|
127
139
|
|
|
140
|
+
### The session cookie is opaque — credentials live server-side, encrypted
|
|
141
|
+
|
|
142
|
+
**A signed cookie is not an encrypted one.** Flask's default session and
|
|
143
|
+
Starlette's `SessionMiddleware` SIGN the cookie (tamper-evident) but do NOT
|
|
144
|
+
encrypt it — anyone holding the cookie can base64-decode and read every value
|
|
145
|
+
in it, which the official docs confirm. So the cookie carries **only a random
|
|
146
|
+
opaque session id**; the Google credentials (`token`, `refresh_token`,
|
|
147
|
+
`client_secret`) go into a **server-side encrypted store**, fetched by
|
|
148
|
+
`(session_id, principal)` — never into the cookie, the redirect URL, or the
|
|
149
|
+
response body.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# The cookie holds ONLY an opaque id. Credentials are server-side, encrypted.
|
|
153
|
+
session['sid'] = session.get('sid') or secrets.token_urlsafe(32)
|
|
154
|
+
credential_store.put( # encrypted at rest, keyed by (sid, principal)
|
|
155
|
+
sid=session['sid'], principal=userinfo['sub'],
|
|
156
|
+
credentials={'token': credentials.token,
|
|
157
|
+
'refresh_token': credentials.refresh_token,
|
|
158
|
+
'client_secret': credentials.client_secret, ...})
|
|
159
|
+
# Never: session['credentials'] = {...} ← readable in the cookie
|
|
160
|
+
# Never: log or return the tokens ← no console.log(tokens.access_token)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Four more, because a leaked secret does not announce itself: **never log a
|
|
164
|
+
token or a credential** (no `console.log(tokens.access_token)`; sanitize logs
|
|
165
|
+
by allow-list, so no token, `client_secret` or `refresh_token` reaches a line,
|
|
166
|
+
a stack trace or an error message); the session signing secret is a **required
|
|
167
|
+
production secret with NO dev fallback** (a hardcoded default signs every
|
|
168
|
+
deployment's cookies with a key in the repo — missing in production is
|
|
169
|
+
**fail-closed**, refuse to boot); the auth cookie is **`Secure`, and the
|
|
170
|
+
callback refuses plain HTTP** — an OAuth code or token over `http://` is a code
|
|
171
|
+
or token on the wire; and **a credential-store read or write that FAILS is an
|
|
172
|
+
auth failure** — re-prompt the user or return 503, never proceed as if the
|
|
173
|
+
credentials loaded (a silent empty read logs the user in as nobody).
|
|
174
|
+
|
|
128
175
|
### Node.js
|
|
129
176
|
|
|
130
177
|
```js
|
|
@@ -134,16 +181,21 @@ app.get('/oauth2callback', async (req, res) => {
|
|
|
134
181
|
const q = url.parse(req.url, true).query;
|
|
135
182
|
|
|
136
183
|
if (q.error) {
|
|
137
|
-
|
|
138
|
-
return res.status(400).send('Authorization failed');
|
|
184
|
+
return res.status(400).send('Authorization failed'); // never log the raw error/token
|
|
139
185
|
}
|
|
140
186
|
|
|
141
|
-
|
|
142
|
-
|
|
187
|
+
// Validate state BEFORE the token exchange: it must be PRESENT on both sides
|
|
188
|
+
// (undefined === undefined must NOT pass), unexpired, and consumed atomically
|
|
189
|
+
// so a replay of the same callback cannot reuse it.
|
|
190
|
+
const saved = req.session.oauthState;
|
|
191
|
+
delete req.session.oauthState; // single-use: consume it now
|
|
192
|
+
if (!q.state || !saved || saved.value !== q.state || Date.now() > saved.expires) {
|
|
193
|
+
return res.status(403).send('State invalid, missing, expired or already used');
|
|
143
194
|
}
|
|
144
195
|
|
|
145
|
-
const
|
|
146
|
-
|
|
196
|
+
const oauthClient = newOAuthClient(); // a client for THIS request
|
|
197
|
+
const {tokens} = await oauthClient.getToken(q.code);
|
|
198
|
+
oauthClient.setCredentials(tokens); // credentials stay on the local client
|
|
147
199
|
|
|
148
200
|
// tokens.access_token — short-lived access token
|
|
149
201
|
// tokens.refresh_token — long-lived (only on first auth!)
|
|
@@ -162,14 +214,20 @@ def oauth2callback():
|
|
|
162
214
|
if request.args.get('error'):
|
|
163
215
|
return 'Authorization failed', 400
|
|
164
216
|
|
|
165
|
-
|
|
166
|
-
|
|
217
|
+
# Validate state BEFORE the token exchange, and consume it atomically:
|
|
218
|
+
# pop() removes it in the same step, so a replayed callback finds nothing.
|
|
219
|
+
# Presence is required on BOTH sides — None == None must NOT pass.
|
|
220
|
+
saved = session.pop('oauth_state', None)
|
|
221
|
+
got = request.args.get('state')
|
|
222
|
+
if (not got or not saved or saved['value'] != got
|
|
223
|
+
or time.time() > saved['expires']):
|
|
224
|
+
abort(403, 'State invalid, missing, expired or already used')
|
|
167
225
|
|
|
168
226
|
flow = Flow.from_client_secrets_file(
|
|
169
227
|
'client_secret.json',
|
|
170
228
|
scopes=SCOPES,
|
|
171
229
|
redirect_uri=YOUR_REDIRECT_URL,
|
|
172
|
-
state=
|
|
230
|
+
state=saved['value']
|
|
173
231
|
)
|
|
174
232
|
flow.fetch_token(authorization_response=request.url)
|
|
175
233
|
|
|
@@ -179,20 +237,26 @@ def oauth2callback():
|
|
|
179
237
|
# credentials.expiry — expiration datetime
|
|
180
238
|
# credentials.scopes — granted scopes
|
|
181
239
|
|
|
182
|
-
|
|
240
|
+
# Cookie: opaque id only. Credentials: server-side, encrypted, keyed by
|
|
241
|
+
# (session id, principal) — never serialized into the signed cookie.
|
|
242
|
+
session['sid'] = session.get('sid') or secrets.token_urlsafe(32)
|
|
243
|
+
credential_store.put(session['sid'], principal=userinfo['sub'], credentials={
|
|
183
244
|
'token': credentials.token,
|
|
184
245
|
'refresh_token': credentials.refresh_token,
|
|
185
246
|
'token_uri': credentials.token_uri,
|
|
186
247
|
'client_id': credentials.client_id,
|
|
187
248
|
'client_secret': credentials.client_secret,
|
|
188
|
-
'scopes': list(credentials.scopes)
|
|
189
|
-
}
|
|
249
|
+
'scopes': list(credentials.scopes),
|
|
250
|
+
})
|
|
190
251
|
return redirect('/profile')
|
|
191
252
|
```
|
|
192
253
|
|
|
193
254
|
### Python (FastAPI)
|
|
194
255
|
|
|
195
256
|
```python
|
|
257
|
+
import secrets
|
|
258
|
+
import time
|
|
259
|
+
|
|
196
260
|
from fastapi import APIRouter, Request
|
|
197
261
|
from fastapi.responses import RedirectResponse
|
|
198
262
|
from google_auth_oauthlib.flow import Flow
|
|
@@ -202,15 +266,19 @@ async def oauth2callback(request: Request):
|
|
|
202
266
|
if request.query_params.get('error'):
|
|
203
267
|
raise HTTPException(400, 'Authorization failed')
|
|
204
268
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
269
|
+
# Consume atomically (pop) and validate BEFORE the exchange; a wrong,
|
|
270
|
+
# expired or replayed state must never start a token exchange.
|
|
271
|
+
saved = request.session.pop('oauth_state', None)
|
|
272
|
+
got = request.query_params.get('state')
|
|
273
|
+
if (not got or not saved or saved['value'] != got
|
|
274
|
+
or time.time() > saved['expires']):
|
|
275
|
+
raise HTTPException(403, 'State invalid, missing, expired or already used')
|
|
208
276
|
|
|
209
277
|
flow = Flow.from_client_secrets_file(
|
|
210
278
|
'client_secret.json',
|
|
211
279
|
scopes=SCOPES,
|
|
212
280
|
redirect_uri=YOUR_REDIRECT_URL,
|
|
213
|
-
state=
|
|
281
|
+
state=saved['value']
|
|
214
282
|
)
|
|
215
283
|
flow.fetch_token(code=request.query_params.get('code'))
|
|
216
284
|
credentials = flow.credentials
|
|
@@ -262,7 +330,8 @@ oauth2Client.on('tokens', (tokens) => {
|
|
|
262
330
|
if (tokens.refresh_token) {
|
|
263
331
|
// Store in database — only sent once!
|
|
264
332
|
}
|
|
265
|
-
|
|
333
|
+
// Never log a token or a credential (FIX-DV-07) — a token in a log is a
|
|
334
|
+
// token anyone with log access holds.
|
|
266
335
|
});
|
|
267
336
|
```
|
|
268
337
|
|
|
@@ -377,19 +446,20 @@ const {google} = require('googleapis');
|
|
|
377
446
|
const app = express();
|
|
378
447
|
app.use(session({secret: 'your-secret', resave: false, saveUninitialized: false}));
|
|
379
448
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
);
|
|
449
|
+
// A client per request — client id/secret are config, credentials are per user.
|
|
450
|
+
function newOAuthClient() {
|
|
451
|
+
return new google.auth.OAuth2(
|
|
452
|
+
process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET,
|
|
453
|
+
'http://localhost:3000/oauth2callback');
|
|
454
|
+
}
|
|
385
455
|
|
|
386
456
|
const SCOPES = ['https://www.googleapis.com/auth/userinfo.profile'];
|
|
387
457
|
|
|
388
458
|
app.get('/auth', (req, res) => {
|
|
389
459
|
const state = crypto.randomBytes(32).toString('hex');
|
|
390
|
-
req.session.
|
|
460
|
+
req.session.oauthState = { value: state, expires: Date.now() + 10 * 60 * 1000 };
|
|
391
461
|
|
|
392
|
-
const url =
|
|
462
|
+
const url = newOAuthClient().generateAuthUrl({
|
|
393
463
|
access_type: 'offline',
|
|
394
464
|
scope: SCOPES,
|
|
395
465
|
state,
|
|
@@ -400,18 +470,22 @@ app.get('/auth', (req, res) => {
|
|
|
400
470
|
|
|
401
471
|
app.get('/oauth2callback', async (req, res) => {
|
|
402
472
|
if (req.query.error) return res.redirect('/error');
|
|
403
|
-
|
|
473
|
+
const saved = req.session.oauthState;
|
|
474
|
+
delete req.session.oauthState; // single-use
|
|
475
|
+
if (!req.query.state || !saved || saved.value !== req.query.state
|
|
476
|
+
|| Date.now() > saved.expires) return res.status(403).send('CSRF');
|
|
404
477
|
|
|
405
|
-
const {tokens} = await
|
|
478
|
+
const {tokens} = await newOAuthClient().getToken(req.query.code);
|
|
406
479
|
req.session.tokens = tokens;
|
|
407
480
|
res.redirect('/profile');
|
|
408
481
|
});
|
|
409
482
|
|
|
410
483
|
app.get('/profile', async (req, res) => {
|
|
411
484
|
if (!req.session.tokens) return res.redirect('/auth');
|
|
412
|
-
|
|
485
|
+
const oauthClient = newOAuthClient(); // local to this request
|
|
486
|
+
oauthClient.setCredentials(req.session.tokens);
|
|
413
487
|
|
|
414
|
-
const oauth2 = google.oauth2({version: 'v2', auth:
|
|
488
|
+
const oauth2 = google.oauth2({version: 'v2', auth: oauthClient});
|
|
415
489
|
const {data} = await oauth2.userinfo.get();
|
|
416
490
|
res.json(data);
|
|
417
491
|
});
|
|
@@ -452,7 +526,7 @@ def auth():
|
|
|
452
526
|
redirect_uri=REDIRECT_URI
|
|
453
527
|
)
|
|
454
528
|
state = secrets.token_hex(32)
|
|
455
|
-
session['
|
|
529
|
+
session['oauth_state'] = {'value': state, 'expires': time.time() + 600}
|
|
456
530
|
authorization_url, _ = flow.authorization_url(
|
|
457
531
|
access_type='offline',
|
|
458
532
|
include_granted_scopes='true',
|
|
@@ -465,8 +539,11 @@ def auth():
|
|
|
465
539
|
def oauth2callback():
|
|
466
540
|
if request.args.get('error'):
|
|
467
541
|
return 'Authorization failed', 400
|
|
468
|
-
|
|
469
|
-
|
|
542
|
+
saved = session.pop('oauth_state', None) # single-use: consume BEFORE exchange
|
|
543
|
+
got = request.args.get('state')
|
|
544
|
+
if (not got or not saved or saved['value'] != got
|
|
545
|
+
or time.time() > saved['expires']):
|
|
546
|
+
abort(403, 'State invalid, missing, expired or already used')
|
|
470
547
|
|
|
471
548
|
flow = Flow.from_client_config(
|
|
472
549
|
{
|
|
@@ -479,26 +556,28 @@ def oauth2callback():
|
|
|
479
556
|
},
|
|
480
557
|
scopes=SCOPES,
|
|
481
558
|
redirect_uri=REDIRECT_URI,
|
|
482
|
-
state=
|
|
559
|
+
state=saved['value']
|
|
483
560
|
)
|
|
484
561
|
flow.fetch_token(authorization_response=request.url)
|
|
485
562
|
creds = flow.credentials
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
'
|
|
491
|
-
'
|
|
492
|
-
'scopes': list(creds.scopes or [])
|
|
493
|
-
}
|
|
563
|
+
userinfo = build('oauth2', 'v2', credentials=creds).userinfo().get().execute()
|
|
564
|
+
# Opaque id in the cookie; credentials in the server-side encrypted store.
|
|
565
|
+
session['sid'] = session.get('sid') or secrets.token_urlsafe(32)
|
|
566
|
+
credential_store.put(session['sid'], principal=userinfo['id'], credentials={
|
|
567
|
+
'token': creds.token, 'refresh_token': creds.refresh_token,
|
|
568
|
+
'token_uri': creds.token_uri, 'client_id': creds.client_id,
|
|
569
|
+
'client_secret': creds.client_secret, 'scopes': list(creds.scopes or []),
|
|
570
|
+
})
|
|
571
|
+
session['principal'] = userinfo['id']
|
|
494
572
|
return redirect('/profile')
|
|
495
573
|
|
|
496
574
|
|
|
497
575
|
@app.route('/profile')
|
|
498
576
|
def profile():
|
|
499
|
-
|
|
577
|
+
stored = credential_store.get(session.get('sid'), session.get('principal'))
|
|
578
|
+
if not stored:
|
|
500
579
|
return redirect('/auth')
|
|
501
|
-
creds = Credentials(**
|
|
580
|
+
creds = Credentials(**stored)
|
|
502
581
|
service = build('oauth2', 'v2', credentials=creds)
|
|
503
582
|
user_info = service.userinfo().get().execute()
|
|
504
583
|
return jsonify(user_info)
|
|
@@ -544,7 +623,7 @@ CLIENT_CONFIG = {
|
|
|
544
623
|
async def auth(request: Request):
|
|
545
624
|
flow = Flow.from_client_config(CLIENT_CONFIG, scopes=SCOPES, redirect_uri=REDIRECT_URI)
|
|
546
625
|
state = secrets.token_hex(32)
|
|
547
|
-
request.session['
|
|
626
|
+
request.session['oauth_state'] = {'value': state, 'expires': time.time() + 600}
|
|
548
627
|
authorization_url, _ = flow.authorization_url(
|
|
549
628
|
access_type='offline',
|
|
550
629
|
include_granted_scopes='true',
|
|
@@ -557,31 +636,38 @@ async def auth(request: Request):
|
|
|
557
636
|
async def oauth2callback(request: Request):
|
|
558
637
|
if request.query_params.get('error'):
|
|
559
638
|
raise HTTPException(400, 'Authorization failed')
|
|
560
|
-
|
|
561
|
-
|
|
639
|
+
saved = request.session.pop('oauth_state', None) # single-use: consume BEFORE exchange
|
|
640
|
+
got = request.query_params.get('state')
|
|
641
|
+
if (not got or not saved or saved['value'] != got
|
|
642
|
+
or time.time() > saved['expires']):
|
|
643
|
+
raise HTTPException(403, 'State invalid, missing, expired or already used')
|
|
562
644
|
|
|
563
645
|
flow = Flow.from_client_config(
|
|
564
646
|
CLIENT_CONFIG, scopes=SCOPES, redirect_uri=REDIRECT_URI,
|
|
565
|
-
state=
|
|
647
|
+
state=saved['value']
|
|
566
648
|
)
|
|
567
649
|
flow.fetch_token(code=request.query_params.get('code'))
|
|
568
650
|
creds = flow.credentials
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
'
|
|
576
|
-
|
|
651
|
+
userinfo = build('oauth2', 'v2', credentials=creds).userinfo().get().execute()
|
|
652
|
+
# Opaque id in the cookie; credentials in the server-side encrypted store.
|
|
653
|
+
sid = request.session.get('sid') or secrets.token_urlsafe(32)
|
|
654
|
+
request.session['sid'] = sid
|
|
655
|
+
request.session['principal'] = userinfo['id']
|
|
656
|
+
credential_store.put(sid, principal=userinfo['id'], credentials={
|
|
657
|
+
'token': creds.token, 'refresh_token': creds.refresh_token,
|
|
658
|
+
'token_uri': creds.token_uri, 'client_id': creds.client_id,
|
|
659
|
+
'client_secret': creds.client_secret, 'scopes': list(creds.scopes or []),
|
|
660
|
+
})
|
|
577
661
|
return RedirectResponse('/profile')
|
|
578
662
|
|
|
579
663
|
|
|
580
664
|
@app.get('/profile')
|
|
581
665
|
async def profile(request: Request):
|
|
582
|
-
|
|
666
|
+
stored = credential_store.get(request.session.get('sid'),
|
|
667
|
+
request.session.get('principal'))
|
|
668
|
+
if not stored:
|
|
583
669
|
return RedirectResponse('/auth')
|
|
584
|
-
creds = Credentials(**
|
|
670
|
+
creds = Credentials(**stored)
|
|
585
671
|
service = build('oauth2', 'v2', credentials=creds)
|
|
586
672
|
user_info = service.userinfo().get().execute()
|
|
587
673
|
return user_info
|
|
@@ -228,8 +228,7 @@ from pydantic import BaseModel
|
|
|
228
228
|
|
|
229
229
|
class GoogleLoginRequest(BaseModel):
|
|
230
230
|
credential: str
|
|
231
|
-
g_csrf_token: str | None = None
|
|
232
|
-
nonce: str | None = None
|
|
231
|
+
g_csrf_token: str | None = None # no nonce field — the server holds the expectation
|
|
233
232
|
|
|
234
233
|
@router.post("/google")
|
|
235
234
|
async def google_login(request: Request, body: GoogleLoginRequest):
|
|
@@ -265,38 +264,54 @@ def google_login():
|
|
|
265
264
|
|
|
266
265
|
## Nonce Verification
|
|
267
266
|
|
|
268
|
-
|
|
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
|
|
|
@@ -119,3 +151,12 @@ and silently log the victim into the attacker's account.
|
|
|
119
151
|
| `access_denied` on consent | Consent screen still in Testing → publish the app |
|
|
120
152
|
| Works locally, breaks in prod | Prod origin missing in GCP, or `Secure` cookie served over plain HTTP |
|
|
121
153
|
| One Tap missing / FedCM console warnings | FedCM is mandatory since Aug 2025 and the opt-out is gone (checked 2026-08-06). Load `gsi/client` from Google and keep it current — a vendored or pinned copy is the real break. The rendered button flow is unaffected; code branching on the old `isNotDisplayed()` moment callbacks is not |
|
|
154
|
+
|
|
155
|
+
## Degradation
|
|
156
|
+
|
|
157
|
+
- **Not Claude Code** (Cursor, Codex, the skills CLI, the API container): the pack's
|
|
158
|
+
`PreToolUse` gate does not run — the rules here hold, the refusal does not.
|
|
159
|
+
- **No browser channel**: the sign-in button and its redirect are verified by reading
|
|
160
|
+
the code, not by watching it work. Record that as the weaker claim it is.
|
|
161
|
+
- **No console access**: creating the OAuth client is a human step; state it once, do
|
|
162
|
+
the rest, and say what is waiting on it.
|