@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
|
@@ -28,24 +28,39 @@ Optimize frontend applications for maximum Lighthouse scores and real-user perfo
|
|
|
28
28
|
|
|
29
29
|
## Core Web Vitals, and the diagnostics beside them
|
|
30
30
|
|
|
31
|
-
**
|
|
32
|
-
is
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
|
41
|
-
|
|
42
|
-
|
|
|
43
|
-
|
|
44
|
-
|
|
|
45
|
-
|
|
46
|
-
|
|
|
47
|
-
|
|
|
48
|
-
|
|
31
|
+
**Two INDEPENDENT axes, and conflating them is the common error.** Axis one:
|
|
32
|
+
**is it a Core Web Vital** — what Google reports and ranks on (LCP, INP, CLS)
|
|
33
|
+
— or not. Axis two: **where can you measure it** — in the FIELD (real users,
|
|
34
|
+
CrUX) or only in the LAB (a synthetic run). These do not line up: a metric can
|
|
35
|
+
be not-CWV and still field-measurable. **FCP is the case that breaks the
|
|
36
|
+
one-axis story — it is NOT a Core Web Vital, yet it IS field-measurable**
|
|
37
|
+
(CrUX reports FCP; web.dev lists both lab and field tools for it). The earlier
|
|
38
|
+
claim that "only the three CWV are field-measurable" was wrong.
|
|
39
|
+
|
|
40
|
+
| Metric | Core Web Vital? | Field-measurable? | Good | Needs Work | Poor |
|
|
41
|
+
|---|---|---|---|---|---|
|
|
42
|
+
| LCP | yes | yes (CrUX) | < 2.5s | 2.5-4.0s | > 4.0s |
|
|
43
|
+
| INP | yes | yes (CrUX) | < 200ms | 200-500ms | > 500ms |
|
|
44
|
+
| CLS | yes | yes (CrUX) | < 0.1 | 0.1-0.25 | > 0.25 |
|
|
45
|
+
| FCP | no | **yes (CrUX)** | < 1.8s | 1.8-3.0s | > 3.0s |
|
|
46
|
+
| TBT | no | no (lab only) | < 200ms | 200-600ms | > 600ms |
|
|
47
|
+
| SI | no | no (lab only) | < 3.4s | 3.4-5.8s | > 5.8s |
|
|
48
|
+
|
|
49
|
+
**A field claim carries its provenance, always: p75** (the 75th-percentile
|
|
50
|
+
value Google classifies on, never a mean), the **device/cohort** (phone vs
|
|
51
|
+
desktop, and which population), the **period** (CrUX is a 28-day trailing
|
|
52
|
+
window), and the **sample size** (a thin origin has no field data — that is
|
|
53
|
+
`unknown`, not `good`). A number without these four is a lab number wearing a
|
|
54
|
+
field label.
|
|
55
|
+
|
|
56
|
+
**TBT is a diagnostic CORRELATE of INP, never a substitute for its evidence.**
|
|
57
|
+
A good lab TBT is a hint that INP may be fine; it does NOT PROVE INP, because
|
|
58
|
+
INP is measured on real interactions the lab did not perform. So a page with
|
|
59
|
+
CrUX FCP data and a Lighthouse TBT but **no field INP** reports **FCP =
|
|
60
|
+
field-supported, INP = unknown** — never "INP passed by TBT". Telling a client
|
|
61
|
+
their Speed Index is a failing Core Web Vital is telling them about a thing
|
|
62
|
+
Google does not rank; telling them INP passed because TBT did is telling them
|
|
63
|
+
about a measurement nobody took.
|
|
49
64
|
|
|
50
65
|
Thresholds and the classification verified against `web.dev/articles/vitals`,
|
|
51
66
|
2026-08-16.
|
|
@@ -61,15 +76,28 @@ Thresholds and the classification verified against `web.dev/articles/vitals`,
|
|
|
61
76
|
- **Use `font-display: swap`** -- Prevents invisible text during load (FOIT).
|
|
62
77
|
- **Remove unused preconnects** -- If fonts are self-hosted, `preconnect` to font CDNs is dead weight.
|
|
63
78
|
|
|
79
|
+
**Before any of these changes: MEASURE, don't prescribe.** The items below are
|
|
80
|
+
optimization HEURISTICS, not blanket rules — each is conditional on evidence,
|
|
81
|
+
and applied blind it can lose function for a score. Before changing anything,
|
|
82
|
+
check three things: the **performance trace + the scenarios** (what a low-end
|
|
83
|
+
device on a slow network actually waits for), the **browser-support contract**
|
|
84
|
+
(who must the page still work for), and the **accessibility owner** (structure
|
|
85
|
+
is theirs, not the score's). A change ships only with acceptance covering:
|
|
86
|
+
low-end/slow-network scenarios, keyboard + heading structure intact, the
|
|
87
|
+
supported-browser matrix still served, NO new unapproved CSP origin, and
|
|
88
|
+
several comparable before/after measurements showing the win **with no
|
|
89
|
+
functional loss**.
|
|
90
|
+
|
|
64
91
|
### JavaScript (TBT, FCP, LCP)
|
|
65
92
|
|
|
66
|
-
- **Code split
|
|
93
|
+
- **Code split by the MEASURED waterfall** -- lazy-load what the trace shows the first paint does not need; "only hero + nav in the initial bundle" is a starting hypothesis, not a rule — a section the scenario needs above the fold stays, whatever the score says.
|
|
67
94
|
- **Defer third-party scripts** -- GA, analytics, chat widgets load after interactive (`afterInteractive` or `defer`).
|
|
68
|
-
- **Target
|
|
95
|
+
- **Target the browsers your USERS run** -- `browserslist` follows a stated user-support policy (your analytics, your contract), not a default "last 2". The `last 2` line below is an EXAMPLE; shipping it without checking who it drops is how a working browser stops being served for a polyfill saving:
|
|
69
96
|
```
|
|
97
|
+
# example only — replace with your measured support matrix
|
|
70
98
|
last 2 Chrome versions, last 2 Firefox versions, last 2 Safari versions, last 2 Edge versions
|
|
71
99
|
```
|
|
72
|
-
- **Tree-shake imports** --
|
|
100
|
+
- **Tree-shake imports** -- prefer named imports where the bundle analyzer shows `import * as` pulling dead weight; it is a tree-shaking aid, not a ban — a namespace import that costs nothing measured is not a defect.
|
|
73
101
|
- **Analyze bundles** -- Use `@next/bundle-analyzer`, `source-map-explorer`, or `vite-plugin-visualizer`.
|
|
74
102
|
|
|
75
103
|
### CSS & Animations (CLS)
|
|
@@ -98,7 +126,7 @@ Thresholds and the classification verified against `web.dev/articles/vitals`,
|
|
|
98
126
|
|
|
99
127
|
### Content Security Policy (Best Practices)
|
|
100
128
|
|
|
101
|
-
- **
|
|
129
|
+
- **Approve origins by FUNCTIONAL NECESSITY, not for the score** -- an origin enters the CSP because the page genuinely needs it, and each is reviewed; whitelisting everything the page happens to load to silence console errors trades the whole point of a CSP for a Best-Practices number. No new origin ships unapproved.
|
|
102
130
|
- **Audit after every third-party change** -- Adding analytics, fonts, or CDN scripts requires CSP updates.
|
|
103
131
|
- **Tighten after migration** -- If you move from external fonts to self-hosted, remove the old CSP entries.
|
|
104
132
|
- **Common CSP origins**:
|
|
@@ -111,7 +139,7 @@ Thresholds and the classification verified against `web.dev/articles/vitals`,
|
|
|
111
139
|
These directly affect the Lighthouse Accessibility score:
|
|
112
140
|
|
|
113
141
|
- **Contrast ratio** -- WCAG AA requires 4.5:1 for normal text, 3:1 for large text. Avoid opacity modifiers on text colors (e.g., `text-primary/80` in Tailwind reduces contrast). Use full-opacity color tokens.
|
|
114
|
-
- **Heading hierarchy** --
|
|
142
|
+
- **Heading hierarchy follows STRUCTURE, decided with the accessibility owner** -- sequential `h1`->`h2`->`h3`, no skipped levels. Demote a heading to `<p>` only when it is genuinely NOT a section title; downgrading a real footer-section heading to `<p>` to quiet a Lighthouse audit removes structure a screen-reader user navigates by. Accessibility is the owner here, not the score.
|
|
115
143
|
- **Alt text** -- Decorative images next to identical text get `alt=""`. Informative images get descriptive alt. Never duplicate adjacent text.
|
|
116
144
|
- **Tap targets** -- Minimum 48x48px on mobile. Applies to buttons, links, form inputs.
|
|
117
145
|
|
|
@@ -86,7 +86,7 @@ request = google.auth.transport.requests.Request()
|
|
|
86
86
|
credentials.refresh(request)
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
ADC search order:
|
|
89
|
+
ADC search order (env var FIRST — this line was reversed): `GOOGLE_APPLICATION_CREDENTIALS` env var → `gcloud auth application-default login` file → attached service account (metadata). Print the RESOLVED principal and source (no secret) before configuring — with more than one present, only the resolved source names which you are actually using.
|
|
90
90
|
|
|
91
91
|
For detailed ADC setup and service account usage, see [references/adc-and-service-accounts.md](references/adc-and-service-accounts.md).
|
|
92
92
|
|
|
@@ -259,6 +259,18 @@ if credentials.expired and credentials.refresh_token:
|
|
|
259
259
|
- Validate external credential configurations before use (check `token_url`, `service_account_impersonation_url` point to googleapis.com)
|
|
260
260
|
- Prefer Workload Identity Federation over service account keys for non-GCP environments
|
|
261
261
|
- For end-user web sign-in, apply the google-signin skill's full checklist — a partial restatement here is how the two skills drifted apart once already
|
|
262
|
+
- **Fail closed on a missing production secret.** The session signing secret,
|
|
263
|
+
and any credential-store key, is REQUIRED in production with NO dev fallback —
|
|
264
|
+
a hardcoded default signs every deployment's cookies with a key that lives in
|
|
265
|
+
the repo. Missing → refuse to boot, never a warning-and-continue (DV-07).
|
|
266
|
+
- **Logs are sanitized; a credential never reaches one.** No token, no
|
|
267
|
+
`client_secret`, no `refresh_token`, no session value in a log line, a stack
|
|
268
|
+
trace, or an error message — a token in a log is a token anyone with log
|
|
269
|
+
access holds. Redact by allow-list, not by trying to strip the secret out.
|
|
270
|
+
- **HTTPS is mandatory and the credential store can fail.** The auth cookie is
|
|
271
|
+
`Secure` and the callback refuses plain HTTP (an OAuth code over `http://` is a
|
|
272
|
+
code on the wire); and a credential-store read/write that FAILS is an auth
|
|
273
|
+
failure — re-prompt or 503, never proceed as if the credentials loaded.
|
|
262
274
|
- **Python-specific**: reuse a single `google.auth.transport.requests.Request()` instance across verifications for connection pooling; do not create a new one per call in hot paths
|
|
263
275
|
|
|
264
276
|
## Reference Files
|
|
@@ -20,9 +20,19 @@ Application Default Credentials (ADC) automatically finds credentials based on t
|
|
|
20
20
|
|
|
21
21
|
## ADC Search Order
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
Google resolves these **in this order — env var FIRST**, and the earlier match
|
|
24
|
+
wins. The old text here had it reversed (attached account first), which points
|
|
25
|
+
you at the wrong lever when the resolved identity is not what you expect:
|
|
26
|
+
|
|
27
|
+
1. **`GOOGLE_APPLICATION_CREDENTIALS`** — env var pointing to a credential JSON file
|
|
28
|
+
2. **`gcloud auth application-default login`** — local credential file (well-known path)
|
|
29
|
+
3. **Attached service account** — via GCP metadata server (Compute Engine, Cloud Run, GKE, etc.)
|
|
30
|
+
|
|
31
|
+
**Before configuring, print the ACTUALLY-resolved principal and source** — which
|
|
32
|
+
of the three won, and the account email — **without the secret**. On a VM with a
|
|
33
|
+
metadata identity AND an env var set, only the resolved source tells you which
|
|
34
|
+
one your code is actually using; a config change made against the wrong source
|
|
35
|
+
is a change that does nothing.
|
|
26
36
|
|
|
27
37
|
Credential file locations from `gcloud auth application-default login`:
|
|
28
38
|
- **macOS/Linux**: `$HOME/.config/gcloud/application_default_credentials.json`
|
|
@@ -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
|