@ssheleg/sheleg-dev 0.11.7 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -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 -24
- package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +0 -13
- package/plugins/sheleg-dev/skills/ad-tracking/references/gtag-api.md +0 -17
- package/plugins/sheleg-dev/skills/ad-tracking/references/performance-security.md +0 -11
- 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/error-tracking/references/setup.md +0 -1
- 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 -16
- package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +150 -79
- package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +36 -33
- package/plugins/sheleg-dev/skills/google-auth/references/workload-identity.md +0 -13
- 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
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
## Contents
|
|
5
5
|
|
|
6
|
-
- [Table of Contents](#table-of-contents)
|
|
7
6
|
- [Prerequisites](#prerequisites)
|
|
8
7
|
- [Step 1: Set Authorization Parameters](#step-1-set-authorization-parameters)
|
|
9
8
|
- [Step 2: Redirect to Google](#step-2-redirect-to-google)
|
|
@@ -18,20 +17,6 @@
|
|
|
18
17
|
- [Complete FastAPI Example](#complete-fastapi-example)
|
|
19
18
|
- [Scopes Reference](#scopes-reference)
|
|
20
19
|
|
|
21
|
-
## Table of Contents
|
|
22
|
-
- [Prerequisites](#prerequisites)
|
|
23
|
-
- [Step 1: Set Authorization Parameters](#step-1-set-authorization-parameters)
|
|
24
|
-
- [Step 2: Redirect to Google](#step-2-redirect-to-google)
|
|
25
|
-
- [Step 3: Handle Callback](#step-3-handle-callback)
|
|
26
|
-
- [Step 4: Use Access Token](#step-4-use-access-token)
|
|
27
|
-
- [Refreshing Tokens](#refreshing-tokens)
|
|
28
|
-
- [Revoking Tokens](#revoking-tokens)
|
|
29
|
-
- [Incremental Authorization](#incremental-authorization)
|
|
30
|
-
- [Error Reference](#error-reference)
|
|
31
|
-
- [Complete Express Example](#complete-express-example)
|
|
32
|
-
- [Complete Flask Example](#complete-flask-example)
|
|
33
|
-
- [Complete FastAPI Example](#complete-fastapi-example)
|
|
34
|
-
|
|
35
20
|
## Prerequisites
|
|
36
21
|
|
|
37
22
|
1. Enable APIs in [Google Cloud Console](https://console.developers.google.com/apis/library)
|
|
@@ -57,20 +42,30 @@ pip install google-auth google-auth-oauthlib google-api-python-client flask
|
|
|
57
42
|
|
|
58
43
|
### Node.js
|
|
59
44
|
|
|
45
|
+
**One OAuth2 client PER request, never a shared singleton.** A module-level
|
|
46
|
+
`oauth2Client` whose `setCredentials(tokens)` runs on every callback is mutated
|
|
47
|
+
by every concurrent login — two users signing in at once end up with each
|
|
48
|
+
other's tokens. Build the client inside the handler; the client id/secret are
|
|
49
|
+
shared config, the CREDENTIALS are per principal and never assigned to a
|
|
50
|
+
process-wide object.
|
|
51
|
+
|
|
60
52
|
```js
|
|
61
53
|
const {google} = require('googleapis');
|
|
62
54
|
const crypto = require('crypto');
|
|
63
55
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
YOUR_CLIENT_SECRET,
|
|
67
|
-
|
|
68
|
-
);
|
|
56
|
+
// A fresh client per request — the credentials it will hold are this user's.
|
|
57
|
+
function newOAuthClient() {
|
|
58
|
+
return new google.auth.OAuth2(YOUR_CLIENT_ID, YOUR_CLIENT_SECRET, YOUR_REDIRECT_URL);
|
|
59
|
+
}
|
|
69
60
|
|
|
61
|
+
const oauthClient = newOAuthClient();
|
|
62
|
+
|
|
63
|
+
// State is REQUIRED, crypto-random, single-use with a TTL, and bound to the
|
|
64
|
+
// server session — not a value the client can echo back to itself.
|
|
70
65
|
const state = crypto.randomBytes(32).toString('hex');
|
|
71
|
-
req.session.
|
|
66
|
+
req.session.oauthState = { value: state, expires: Date.now() + 10 * 60 * 1000 };
|
|
72
67
|
|
|
73
|
-
const authorizationUrl =
|
|
68
|
+
const authorizationUrl = oauthClient.generateAuthUrl({
|
|
74
69
|
access_type: 'offline', // 'online' (default) or 'offline' (gets refresh_token)
|
|
75
70
|
scope: [
|
|
76
71
|
'https://www.googleapis.com/auth/drive.metadata.readonly',
|
|
@@ -88,6 +83,7 @@ const authorizationUrl = oauth2Client.generateAuthUrl({
|
|
|
88
83
|
```python
|
|
89
84
|
from google_auth_oauthlib.flow import Flow
|
|
90
85
|
import secrets
|
|
86
|
+
import time
|
|
91
87
|
|
|
92
88
|
flow = Flow.from_client_secrets_file(
|
|
93
89
|
'client_secret.json',
|
|
@@ -99,7 +95,8 @@ flow = Flow.from_client_secrets_file(
|
|
|
99
95
|
)
|
|
100
96
|
|
|
101
97
|
state = secrets.token_hex(32)
|
|
102
|
-
|
|
98
|
+
# Server-bound, random, with a TTL: the callback consumes it atomically.
|
|
99
|
+
session['oauth_state'] = {'value': state, 'expires': time.time() + 600}
|
|
103
100
|
|
|
104
101
|
authorization_url, state = flow.authorization_url(
|
|
105
102
|
access_type='offline', # gets refresh_token
|
|
@@ -140,6 +137,41 @@ return redirect(authorization_url)
|
|
|
140
137
|
|
|
141
138
|
## Step 3: Handle Callback
|
|
142
139
|
|
|
140
|
+
### The session cookie is opaque — credentials live server-side, encrypted
|
|
141
|
+
|
|
142
|
+
**A signed cookie is not an encrypted one.** Flask's default session and
|
|
143
|
+
Starlette's `SessionMiddleware` SIGN the cookie (tamper-evident) but do NOT
|
|
144
|
+
encrypt it — anyone holding the cookie can base64-decode and read every value
|
|
145
|
+
in it, which the official docs confirm. So the cookie carries **only a random
|
|
146
|
+
opaque session id**; the Google credentials (`token`, `refresh_token`,
|
|
147
|
+
`client_secret`) go into a **server-side encrypted store**, fetched by
|
|
148
|
+
`(session_id, principal)` — never into the cookie, the redirect URL, or the
|
|
149
|
+
response body.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# The cookie holds ONLY an opaque id. Credentials are server-side, encrypted.
|
|
153
|
+
session['sid'] = session.get('sid') or secrets.token_urlsafe(32)
|
|
154
|
+
credential_store.put( # encrypted at rest, keyed by (sid, principal)
|
|
155
|
+
sid=session['sid'], principal=userinfo['sub'],
|
|
156
|
+
credentials={'token': credentials.token,
|
|
157
|
+
'refresh_token': credentials.refresh_token,
|
|
158
|
+
'client_secret': credentials.client_secret, ...})
|
|
159
|
+
# Never: session['credentials'] = {...} ← readable in the cookie
|
|
160
|
+
# Never: log or return the tokens ← no console.log(tokens.access_token)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Four more, because a leaked secret does not announce itself: **never log a
|
|
164
|
+
token or a credential** (no `console.log(tokens.access_token)`; sanitize logs
|
|
165
|
+
by allow-list, so no token, `client_secret` or `refresh_token` reaches a line,
|
|
166
|
+
a stack trace or an error message); the session signing secret is a **required
|
|
167
|
+
production secret with NO dev fallback** (a hardcoded default signs every
|
|
168
|
+
deployment's cookies with a key in the repo — missing in production is
|
|
169
|
+
**fail-closed**, refuse to boot); the auth cookie is **`Secure`, and the
|
|
170
|
+
callback refuses plain HTTP** — an OAuth code or token over `http://` is a code
|
|
171
|
+
or token on the wire; and **a credential-store read or write that FAILS is an
|
|
172
|
+
auth failure** — re-prompt the user or return 503, never proceed as if the
|
|
173
|
+
credentials loaded (a silent empty read logs the user in as nobody).
|
|
174
|
+
|
|
143
175
|
### Node.js
|
|
144
176
|
|
|
145
177
|
```js
|
|
@@ -149,16 +181,21 @@ app.get('/oauth2callback', async (req, res) => {
|
|
|
149
181
|
const q = url.parse(req.url, true).query;
|
|
150
182
|
|
|
151
183
|
if (q.error) {
|
|
152
|
-
|
|
153
|
-
return res.status(400).send('Authorization failed');
|
|
184
|
+
return res.status(400).send('Authorization failed'); // never log the raw error/token
|
|
154
185
|
}
|
|
155
186
|
|
|
156
|
-
|
|
157
|
-
|
|
187
|
+
// Validate state BEFORE the token exchange: it must be PRESENT on both sides
|
|
188
|
+
// (undefined === undefined must NOT pass), unexpired, and consumed atomically
|
|
189
|
+
// so a replay of the same callback cannot reuse it.
|
|
190
|
+
const saved = req.session.oauthState;
|
|
191
|
+
delete req.session.oauthState; // single-use: consume it now
|
|
192
|
+
if (!q.state || !saved || saved.value !== q.state || Date.now() > saved.expires) {
|
|
193
|
+
return res.status(403).send('State invalid, missing, expired or already used');
|
|
158
194
|
}
|
|
159
195
|
|
|
160
|
-
const
|
|
161
|
-
|
|
196
|
+
const oauthClient = newOAuthClient(); // a client for THIS request
|
|
197
|
+
const {tokens} = await oauthClient.getToken(q.code);
|
|
198
|
+
oauthClient.setCredentials(tokens); // credentials stay on the local client
|
|
162
199
|
|
|
163
200
|
// tokens.access_token — short-lived access token
|
|
164
201
|
// tokens.refresh_token — long-lived (only on first auth!)
|
|
@@ -177,14 +214,20 @@ def oauth2callback():
|
|
|
177
214
|
if request.args.get('error'):
|
|
178
215
|
return 'Authorization failed', 400
|
|
179
216
|
|
|
180
|
-
|
|
181
|
-
|
|
217
|
+
# Validate state BEFORE the token exchange, and consume it atomically:
|
|
218
|
+
# pop() removes it in the same step, so a replayed callback finds nothing.
|
|
219
|
+
# Presence is required on BOTH sides — None == None must NOT pass.
|
|
220
|
+
saved = session.pop('oauth_state', None)
|
|
221
|
+
got = request.args.get('state')
|
|
222
|
+
if (not got or not saved or saved['value'] != got
|
|
223
|
+
or time.time() > saved['expires']):
|
|
224
|
+
abort(403, 'State invalid, missing, expired or already used')
|
|
182
225
|
|
|
183
226
|
flow = Flow.from_client_secrets_file(
|
|
184
227
|
'client_secret.json',
|
|
185
228
|
scopes=SCOPES,
|
|
186
229
|
redirect_uri=YOUR_REDIRECT_URL,
|
|
187
|
-
state=
|
|
230
|
+
state=saved['value']
|
|
188
231
|
)
|
|
189
232
|
flow.fetch_token(authorization_response=request.url)
|
|
190
233
|
|
|
@@ -194,20 +237,26 @@ def oauth2callback():
|
|
|
194
237
|
# credentials.expiry — expiration datetime
|
|
195
238
|
# credentials.scopes — granted scopes
|
|
196
239
|
|
|
197
|
-
|
|
240
|
+
# Cookie: opaque id only. Credentials: server-side, encrypted, keyed by
|
|
241
|
+
# (session id, principal) — never serialized into the signed cookie.
|
|
242
|
+
session['sid'] = session.get('sid') or secrets.token_urlsafe(32)
|
|
243
|
+
credential_store.put(session['sid'], principal=userinfo['sub'], credentials={
|
|
198
244
|
'token': credentials.token,
|
|
199
245
|
'refresh_token': credentials.refresh_token,
|
|
200
246
|
'token_uri': credentials.token_uri,
|
|
201
247
|
'client_id': credentials.client_id,
|
|
202
248
|
'client_secret': credentials.client_secret,
|
|
203
|
-
'scopes': list(credentials.scopes)
|
|
204
|
-
}
|
|
249
|
+
'scopes': list(credentials.scopes),
|
|
250
|
+
})
|
|
205
251
|
return redirect('/profile')
|
|
206
252
|
```
|
|
207
253
|
|
|
208
254
|
### Python (FastAPI)
|
|
209
255
|
|
|
210
256
|
```python
|
|
257
|
+
import secrets
|
|
258
|
+
import time
|
|
259
|
+
|
|
211
260
|
from fastapi import APIRouter, Request
|
|
212
261
|
from fastapi.responses import RedirectResponse
|
|
213
262
|
from google_auth_oauthlib.flow import Flow
|
|
@@ -217,15 +266,19 @@ async def oauth2callback(request: Request):
|
|
|
217
266
|
if request.query_params.get('error'):
|
|
218
267
|
raise HTTPException(400, 'Authorization failed')
|
|
219
268
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
269
|
+
# Consume atomically (pop) and validate BEFORE the exchange; a wrong,
|
|
270
|
+
# expired or replayed state must never start a token exchange.
|
|
271
|
+
saved = request.session.pop('oauth_state', None)
|
|
272
|
+
got = request.query_params.get('state')
|
|
273
|
+
if (not got or not saved or saved['value'] != got
|
|
274
|
+
or time.time() > saved['expires']):
|
|
275
|
+
raise HTTPException(403, 'State invalid, missing, expired or already used')
|
|
223
276
|
|
|
224
277
|
flow = Flow.from_client_secrets_file(
|
|
225
278
|
'client_secret.json',
|
|
226
279
|
scopes=SCOPES,
|
|
227
280
|
redirect_uri=YOUR_REDIRECT_URL,
|
|
228
|
-
state=
|
|
281
|
+
state=saved['value']
|
|
229
282
|
)
|
|
230
283
|
flow.fetch_token(code=request.query_params.get('code'))
|
|
231
284
|
credentials = flow.credentials
|
|
@@ -277,7 +330,8 @@ oauth2Client.on('tokens', (tokens) => {
|
|
|
277
330
|
if (tokens.refresh_token) {
|
|
278
331
|
// Store in database — only sent once!
|
|
279
332
|
}
|
|
280
|
-
|
|
333
|
+
// Never log a token or a credential (FIX-DV-07) — a token in a log is a
|
|
334
|
+
// token anyone with log access holds.
|
|
281
335
|
});
|
|
282
336
|
```
|
|
283
337
|
|
|
@@ -392,19 +446,20 @@ const {google} = require('googleapis');
|
|
|
392
446
|
const app = express();
|
|
393
447
|
app.use(session({secret: 'your-secret', resave: false, saveUninitialized: false}));
|
|
394
448
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
);
|
|
449
|
+
// A client per request — client id/secret are config, credentials are per user.
|
|
450
|
+
function newOAuthClient() {
|
|
451
|
+
return new google.auth.OAuth2(
|
|
452
|
+
process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET,
|
|
453
|
+
'http://localhost:3000/oauth2callback');
|
|
454
|
+
}
|
|
400
455
|
|
|
401
456
|
const SCOPES = ['https://www.googleapis.com/auth/userinfo.profile'];
|
|
402
457
|
|
|
403
458
|
app.get('/auth', (req, res) => {
|
|
404
459
|
const state = crypto.randomBytes(32).toString('hex');
|
|
405
|
-
req.session.
|
|
460
|
+
req.session.oauthState = { value: state, expires: Date.now() + 10 * 60 * 1000 };
|
|
406
461
|
|
|
407
|
-
const url =
|
|
462
|
+
const url = newOAuthClient().generateAuthUrl({
|
|
408
463
|
access_type: 'offline',
|
|
409
464
|
scope: SCOPES,
|
|
410
465
|
state,
|
|
@@ -415,18 +470,22 @@ app.get('/auth', (req, res) => {
|
|
|
415
470
|
|
|
416
471
|
app.get('/oauth2callback', async (req, res) => {
|
|
417
472
|
if (req.query.error) return res.redirect('/error');
|
|
418
|
-
|
|
473
|
+
const saved = req.session.oauthState;
|
|
474
|
+
delete req.session.oauthState; // single-use
|
|
475
|
+
if (!req.query.state || !saved || saved.value !== req.query.state
|
|
476
|
+
|| Date.now() > saved.expires) return res.status(403).send('CSRF');
|
|
419
477
|
|
|
420
|
-
const {tokens} = await
|
|
478
|
+
const {tokens} = await newOAuthClient().getToken(req.query.code);
|
|
421
479
|
req.session.tokens = tokens;
|
|
422
480
|
res.redirect('/profile');
|
|
423
481
|
});
|
|
424
482
|
|
|
425
483
|
app.get('/profile', async (req, res) => {
|
|
426
484
|
if (!req.session.tokens) return res.redirect('/auth');
|
|
427
|
-
|
|
485
|
+
const oauthClient = newOAuthClient(); // local to this request
|
|
486
|
+
oauthClient.setCredentials(req.session.tokens);
|
|
428
487
|
|
|
429
|
-
const oauth2 = google.oauth2({version: 'v2', auth:
|
|
488
|
+
const oauth2 = google.oauth2({version: 'v2', auth: oauthClient});
|
|
430
489
|
const {data} = await oauth2.userinfo.get();
|
|
431
490
|
res.json(data);
|
|
432
491
|
});
|
|
@@ -467,7 +526,7 @@ def auth():
|
|
|
467
526
|
redirect_uri=REDIRECT_URI
|
|
468
527
|
)
|
|
469
528
|
state = secrets.token_hex(32)
|
|
470
|
-
session['
|
|
529
|
+
session['oauth_state'] = {'value': state, 'expires': time.time() + 600}
|
|
471
530
|
authorization_url, _ = flow.authorization_url(
|
|
472
531
|
access_type='offline',
|
|
473
532
|
include_granted_scopes='true',
|
|
@@ -480,8 +539,11 @@ def auth():
|
|
|
480
539
|
def oauth2callback():
|
|
481
540
|
if request.args.get('error'):
|
|
482
541
|
return 'Authorization failed', 400
|
|
483
|
-
|
|
484
|
-
|
|
542
|
+
saved = session.pop('oauth_state', None) # single-use: consume BEFORE exchange
|
|
543
|
+
got = request.args.get('state')
|
|
544
|
+
if (not got or not saved or saved['value'] != got
|
|
545
|
+
or time.time() > saved['expires']):
|
|
546
|
+
abort(403, 'State invalid, missing, expired or already used')
|
|
485
547
|
|
|
486
548
|
flow = Flow.from_client_config(
|
|
487
549
|
{
|
|
@@ -494,26 +556,28 @@ def oauth2callback():
|
|
|
494
556
|
},
|
|
495
557
|
scopes=SCOPES,
|
|
496
558
|
redirect_uri=REDIRECT_URI,
|
|
497
|
-
state=
|
|
559
|
+
state=saved['value']
|
|
498
560
|
)
|
|
499
561
|
flow.fetch_token(authorization_response=request.url)
|
|
500
562
|
creds = flow.credentials
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
'
|
|
506
|
-
'
|
|
507
|
-
'scopes': list(creds.scopes or [])
|
|
508
|
-
}
|
|
563
|
+
userinfo = build('oauth2', 'v2', credentials=creds).userinfo().get().execute()
|
|
564
|
+
# Opaque id in the cookie; credentials in the server-side encrypted store.
|
|
565
|
+
session['sid'] = session.get('sid') or secrets.token_urlsafe(32)
|
|
566
|
+
credential_store.put(session['sid'], principal=userinfo['id'], credentials={
|
|
567
|
+
'token': creds.token, 'refresh_token': creds.refresh_token,
|
|
568
|
+
'token_uri': creds.token_uri, 'client_id': creds.client_id,
|
|
569
|
+
'client_secret': creds.client_secret, 'scopes': list(creds.scopes or []),
|
|
570
|
+
})
|
|
571
|
+
session['principal'] = userinfo['id']
|
|
509
572
|
return redirect('/profile')
|
|
510
573
|
|
|
511
574
|
|
|
512
575
|
@app.route('/profile')
|
|
513
576
|
def profile():
|
|
514
|
-
|
|
577
|
+
stored = credential_store.get(session.get('sid'), session.get('principal'))
|
|
578
|
+
if not stored:
|
|
515
579
|
return redirect('/auth')
|
|
516
|
-
creds = Credentials(**
|
|
580
|
+
creds = Credentials(**stored)
|
|
517
581
|
service = build('oauth2', 'v2', credentials=creds)
|
|
518
582
|
user_info = service.userinfo().get().execute()
|
|
519
583
|
return jsonify(user_info)
|
|
@@ -559,7 +623,7 @@ CLIENT_CONFIG = {
|
|
|
559
623
|
async def auth(request: Request):
|
|
560
624
|
flow = Flow.from_client_config(CLIENT_CONFIG, scopes=SCOPES, redirect_uri=REDIRECT_URI)
|
|
561
625
|
state = secrets.token_hex(32)
|
|
562
|
-
request.session['
|
|
626
|
+
request.session['oauth_state'] = {'value': state, 'expires': time.time() + 600}
|
|
563
627
|
authorization_url, _ = flow.authorization_url(
|
|
564
628
|
access_type='offline',
|
|
565
629
|
include_granted_scopes='true',
|
|
@@ -572,31 +636,38 @@ async def auth(request: Request):
|
|
|
572
636
|
async def oauth2callback(request: Request):
|
|
573
637
|
if request.query_params.get('error'):
|
|
574
638
|
raise HTTPException(400, 'Authorization failed')
|
|
575
|
-
|
|
576
|
-
|
|
639
|
+
saved = request.session.pop('oauth_state', None) # single-use: consume BEFORE exchange
|
|
640
|
+
got = request.query_params.get('state')
|
|
641
|
+
if (not got or not saved or saved['value'] != got
|
|
642
|
+
or time.time() > saved['expires']):
|
|
643
|
+
raise HTTPException(403, 'State invalid, missing, expired or already used')
|
|
577
644
|
|
|
578
645
|
flow = Flow.from_client_config(
|
|
579
646
|
CLIENT_CONFIG, scopes=SCOPES, redirect_uri=REDIRECT_URI,
|
|
580
|
-
state=
|
|
647
|
+
state=saved['value']
|
|
581
648
|
)
|
|
582
649
|
flow.fetch_token(code=request.query_params.get('code'))
|
|
583
650
|
creds = flow.credentials
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
'
|
|
591
|
-
|
|
651
|
+
userinfo = build('oauth2', 'v2', credentials=creds).userinfo().get().execute()
|
|
652
|
+
# Opaque id in the cookie; credentials in the server-side encrypted store.
|
|
653
|
+
sid = request.session.get('sid') or secrets.token_urlsafe(32)
|
|
654
|
+
request.session['sid'] = sid
|
|
655
|
+
request.session['principal'] = userinfo['id']
|
|
656
|
+
credential_store.put(sid, principal=userinfo['id'], credentials={
|
|
657
|
+
'token': creds.token, 'refresh_token': creds.refresh_token,
|
|
658
|
+
'token_uri': creds.token_uri, 'client_id': creds.client_id,
|
|
659
|
+
'client_secret': creds.client_secret, 'scopes': list(creds.scopes or []),
|
|
660
|
+
})
|
|
592
661
|
return RedirectResponse('/profile')
|
|
593
662
|
|
|
594
663
|
|
|
595
664
|
@app.get('/profile')
|
|
596
665
|
async def profile(request: Request):
|
|
597
|
-
|
|
666
|
+
stored = credential_store.get(request.session.get('sid'),
|
|
667
|
+
request.session.get('principal'))
|
|
668
|
+
if not stored:
|
|
598
669
|
return RedirectResponse('/auth')
|
|
599
|
-
creds = Credentials(**
|
|
670
|
+
creds = Credentials(**stored)
|
|
600
671
|
service = build('oauth2', 'v2', credentials=creds)
|
|
601
672
|
user_info = service.userinfo().get().execute()
|
|
602
673
|
return user_info
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
## Contents
|
|
5
5
|
|
|
6
|
-
- [Table of Contents](#table-of-contents)
|
|
7
6
|
- [Overview](#overview)
|
|
8
7
|
- [Client-Side Integration](#client-side-integration)
|
|
9
8
|
- [Server-Side ID Token Verification](#server-side-id-token-verification)
|
|
@@ -17,19 +16,6 @@
|
|
|
17
16
|
- [FedCM — mandatory, not a migration you are planning](#fedcm--mandatory-not-a-migration-you-are-planning)
|
|
18
17
|
- [Sign Out](#sign-out)
|
|
19
18
|
|
|
20
|
-
## Table of Contents
|
|
21
|
-
- [Overview](#overview)
|
|
22
|
-
- [Client-Side Integration](#client-side-integration)
|
|
23
|
-
- [Server-Side ID Token Verification](#server-side-id-token-verification)
|
|
24
|
-
- [CSRF Protection](#csrf-protection)
|
|
25
|
-
- [Nonce Verification](#nonce-verification)
|
|
26
|
-
- [ID Token Payload Fields](#id-token-payload-fields)
|
|
27
|
-
- [Account Linking Flows](#account-linking-flows)
|
|
28
|
-
- [Next.js / React Integration Pattern](#nextjs--react-integration-pattern)
|
|
29
|
-
- [FastAPI Integration Pattern](#fastapi-integration-pattern)
|
|
30
|
-
- [Flask Integration Pattern](#flask-integration-pattern)
|
|
31
|
-
- [FedCM Migration](#fedcm-migration)
|
|
32
|
-
|
|
33
19
|
## Overview
|
|
34
20
|
|
|
35
21
|
Sign In with Google (part of Google Identity Services / GIS) provides:
|
|
@@ -242,8 +228,7 @@ from pydantic import BaseModel
|
|
|
242
228
|
|
|
243
229
|
class GoogleLoginRequest(BaseModel):
|
|
244
230
|
credential: str
|
|
245
|
-
g_csrf_token: str | None = None
|
|
246
|
-
nonce: str | None = None
|
|
231
|
+
g_csrf_token: str | None = None # no nonce field — the server holds the expectation
|
|
247
232
|
|
|
248
233
|
@router.post("/google")
|
|
249
234
|
async def google_login(request: Request, body: GoogleLoginRequest):
|
|
@@ -279,38 +264,54 @@ def google_login():
|
|
|
279
264
|
|
|
280
265
|
## Nonce Verification
|
|
281
266
|
|
|
282
|
-
|
|
267
|
+
**The server issues the nonce; the client never invents one.** A
|
|
268
|
+
client-generated nonce sent back beside the credential proves nothing: the
|
|
269
|
+
"expected" value would be read out of the very JWT it is meant to check, so a
|
|
270
|
+
stolen token replays with its own nonce. The contract (shared with
|
|
271
|
+
`google-signin` — one contract, both skills):
|
|
272
|
+
|
|
273
|
+
1. `GET /api/auth/google/nonce` — the server mints a random value, stores it
|
|
274
|
+
with a TTL bound to a **pre-auth HttpOnly session cookie**, returns it.
|
|
275
|
+
2. The client passes that value to `google.accounts.id.initialize({nonce})`.
|
|
276
|
+
3. On the callback the server **pops the expectation (one-time consume) and
|
|
277
|
+
requires the token's `nonce` claim to equal it exactly.** Missing claim,
|
|
278
|
+
missing expectation, expiry and re-use all reject; the POST body carries
|
|
279
|
+
no nonce and plays no part in the check.
|
|
283
280
|
|
|
284
281
|
**Client-side:**
|
|
285
282
|
|
|
286
283
|
```js
|
|
287
|
-
const nonce =
|
|
284
|
+
const nonce = (await (await fetch('/api/auth/google/nonce')).json()).nonce;
|
|
288
285
|
google.accounts.id.initialize({
|
|
289
286
|
client_id: CLIENT_ID,
|
|
290
|
-
callback: handleResponse,
|
|
291
|
-
nonce
|
|
287
|
+
callback: handleResponse, // POSTs { credential } only
|
|
288
|
+
nonce
|
|
292
289
|
});
|
|
293
|
-
// Send nonce alongside credential to your backend
|
|
294
290
|
```
|
|
295
291
|
|
|
296
292
|
**Server-side (Node.js):**
|
|
297
293
|
|
|
298
294
|
```js
|
|
295
|
+
const sid = req.cookies.preauth || '';
|
|
296
|
+
const expected = await nonceStore.pop(sid); // one-time consume, with TTL
|
|
299
297
|
const payload = ticket.getPayload();
|
|
300
|
-
if (payload.nonce !==
|
|
301
|
-
throw new Error('Nonce
|
|
298
|
+
if (!expected || payload.nonce !== expected) {
|
|
299
|
+
throw new Error('Nonce missing, expired, replayed or not server-issued');
|
|
302
300
|
}
|
|
303
301
|
```
|
|
304
302
|
|
|
305
303
|
**Server-side (Python):**
|
|
306
304
|
|
|
307
305
|
```python
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
306
|
+
sid = request.cookies.get('preauth') or ''
|
|
307
|
+
expected = nonce_store.pop(sid) # one-time consume, with TTL
|
|
308
|
+
if not expected or payload.get('nonce') != expected:
|
|
309
|
+
raise ValueError('Nonce missing, expired, replayed or not server-issued')
|
|
312
310
|
```
|
|
313
311
|
|
|
312
|
+
First sign-in passes; a replay from a new session meets a different
|
|
313
|
+
expectation; a replay after consume meets none.
|
|
314
|
+
|
|
314
315
|
## ID Token Payload Fields
|
|
315
316
|
|
|
316
317
|
| Field | Description |
|
|
@@ -488,8 +489,7 @@ CLIENT_ID = settings.google_client_id
|
|
|
488
489
|
|
|
489
490
|
class GoogleLoginRequest(BaseModel):
|
|
490
491
|
credential: str
|
|
491
|
-
g_csrf_token: str | None = None
|
|
492
|
-
nonce: str | None = None
|
|
492
|
+
g_csrf_token: str | None = None # no nonce field — the server holds the expectation
|
|
493
493
|
|
|
494
494
|
@router.post("/auth/google")
|
|
495
495
|
async def google_login(request: Request, body: GoogleLoginRequest):
|
|
@@ -508,10 +508,13 @@ async def google_login(request: Request, body: GoogleLoginRequest):
|
|
|
508
508
|
if not payload.get("email_verified", False):
|
|
509
509
|
raise HTTPException(status_code=401, detail="Email not verified")
|
|
510
510
|
|
|
511
|
-
# 3. Verify nonce
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
511
|
+
# 3. Verify nonce — MANDATORY, against the server's own expectation.
|
|
512
|
+
# pop() consumes it in the same step (one-time); the body carries no nonce.
|
|
513
|
+
sid = request.cookies.get("preauth") or ""
|
|
514
|
+
expected = nonce_store.pop(sid) # server-issued, TTL'd (see §Nonce Verification)
|
|
515
|
+
if not expected or payload.get("nonce") != expected:
|
|
516
|
+
raise HTTPException(status_code=401,
|
|
517
|
+
detail="Nonce missing, expired, replayed or not server-issued")
|
|
515
518
|
|
|
516
519
|
# 4. Find or create user, issue app JWT...
|
|
517
520
|
user, is_new = await find_or_create_google_user(db, payload)
|
|
@@ -3,19 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
## Contents
|
|
5
5
|
|
|
6
|
-
- [Table of Contents](#table-of-contents)
|
|
7
|
-
- [Overview](#overview)
|
|
8
|
-
- [AWS Federation](#aws-federation)
|
|
9
|
-
- [Azure Federation](#azure-federation)
|
|
10
|
-
- [OIDC Provider Federation](#oidc-provider-federation)
|
|
11
|
-
- [Custom Subject Token Supplier](#custom-subject-token-supplier)
|
|
12
|
-
- [Workforce Identity Federation](#workforce-identity-federation)
|
|
13
|
-
- [Executable-Sourced Credentials](#executable-sourced-credentials)
|
|
14
|
-
- [Using External Identities](#using-external-identities)
|
|
15
|
-
- [Configurable Token Lifetime](#configurable-token-lifetime)
|
|
16
|
-
- [Security Considerations](#security-considerations)
|
|
17
|
-
|
|
18
|
-
## Table of Contents
|
|
19
6
|
- [Overview](#overview)
|
|
20
7
|
- [AWS Federation](#aws-federation)
|
|
21
8
|
- [Azure Federation](#azure-federation)
|