@ssheleg/sheleg-dev 0.4.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 +172 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/SECURITY.md +63 -0
- package/bin/sheleg-dev.js +99 -0
- package/package.json +45 -0
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +28 -0
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +442 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +257 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +201 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/gtag-api.md +389 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +103 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/performance-security.md +288 -0
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +384 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +1357 -0
- package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +147 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/accessibility-perf.md +172 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/css-performance.md +180 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/nextjs.md +271 -0
- package/plugins/sheleg-dev/skills/google-auth/SKILL.md +275 -0
- package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +464 -0
- package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +615 -0
- package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +593 -0
- package/plugins/sheleg-dev/skills/google-auth/references/workload-identity.md +376 -0
- package/plugins/sheleg-dev/skills/google-signin/SKILL.md +121 -0
- package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +421 -0
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +458 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/price-integrity.md +201 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/stripe-agent-toolchain.md +173 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +294 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +214 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +211 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# Google Sign-In (GIS) — Complete Guide: How It Works, How to Set It Up
|
|
2
|
+
|
|
3
|
+
> Self-contained, shareable guide. Explains the Google Identity Services (GIS)
|
|
4
|
+
> ID-token flow end-to-end, how to configure it from scratch in any web app,
|
|
5
|
+
> and the full security checklist. The reference implementation cited
|
|
6
|
+
> throughout is Prowl (`web/auth.py`, `web/server.py`, `web/static/js/app.js`)
|
|
7
|
+
> — but every section works standalone for any stack.
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
- [1. The two Google auth flows — and which one this is](#1-the-two-google-auth-flows--and-which-one-this-is)
|
|
12
|
+
- [2. How the GIS flow works — step by step](#2-how-the-gis-flow-works--step-by-step)
|
|
13
|
+
- [3. Setup from scratch (~10 minutes)](#3-setup-from-scratch-10-minutes)
|
|
14
|
+
- [4. Security checklist (each item maps to a real attack)](#4-security-checklist-each-item-maps-to-a-real-attack)
|
|
15
|
+
- [5. Troubleshooting](#5-troubleshooting)
|
|
16
|
+
- [6. Reference implementation map (Prowl)](#6-reference-implementation-map-prowl)
|
|
17
|
+
- [7. Minimal end-to-end skeleton (copy-paste starting point, FastAPI)](#7-minimal-end-to-end-skeleton-copy-paste-starting-point-fastapi)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. The two Google auth flows — and which one this is
|
|
23
|
+
|
|
24
|
+
Google offers two distinct browser auth mechanisms. Do not mix them up:
|
|
25
|
+
|
|
26
|
+
| | **GIS ID-token flow** (this guide) | OAuth 2.0 authorization-code flow |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Purpose | *Authentication* — "who is this user?" | *Authorization* — "let me call Google APIs on the user's behalf" |
|
|
29
|
+
| What you get | One **ID token** (a JWT signed by Google) | `code` → exchanged server-side for `access_token` + `refresh_token` |
|
|
30
|
+
| Redirect URI needed | **No** (popup/One Tap posts the token to your JS callback) | Yes |
|
|
31
|
+
| Client secret needed | **No** (verification uses Google's public keys) | Yes |
|
|
32
|
+
| Google APIs callable after | None | Yes (Gmail, Drive, Calendar… per granted scopes) |
|
|
33
|
+
| Library | `https://accounts.google.com/gsi/client` | `google-auth-oauthlib` / OAuth endpoints |
|
|
34
|
+
|
|
35
|
+
If you only need "Sign in with Google" → GIS ID-token flow. Simpler, no
|
|
36
|
+
secrets, no redirect URIs, no token storage for Google APIs.
|
|
37
|
+
|
|
38
|
+
## 2. How the GIS flow works — step by step
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
Browser Google Your backend
|
|
42
|
+
─────── ────── ────────────
|
|
43
|
+
1. load gsi/client script
|
|
44
|
+
2. google.accounts.id.initialize({client_id, callback, nonce})
|
|
45
|
+
3. renderButton(#container)
|
|
46
|
+
4. user clicks button ──────► account-chooser popup
|
|
47
|
+
(user picks account,
|
|
48
|
+
consents on first use)
|
|
49
|
+
5. callback receives ◄────── ID token (JWT, RS256-signed
|
|
50
|
+
{credential: "<jwt>"} by Google's private key)
|
|
51
|
+
6. POST /api/auth/google {credential, nonce} ─────────────► 7. verify ID token:
|
|
52
|
+
- signature vs Google JWKS
|
|
53
|
+
(https://www.googleapis.com/oauth2/v3/certs)
|
|
54
|
+
- aud == YOUR client_id
|
|
55
|
+
- iss == accounts.google.com
|
|
56
|
+
- exp not passed
|
|
57
|
+
- email_verified == true
|
|
58
|
+
- nonce matches
|
|
59
|
+
8. find-or-create user,
|
|
60
|
+
link accounts
|
|
61
|
+
9. issue YOUR OWN session
|
|
62
|
+
(app JWT / cookie)
|
|
63
|
+
10. store session, user is logged in ◄─────────────────────
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Key mental model: **the Google ID token is proof of identity, not a session.**
|
|
67
|
+
You verify it once, then issue your *own* session token. Google's token is
|
|
68
|
+
never stored and never used again.
|
|
69
|
+
|
|
70
|
+
### 2.1 What's inside the ID token
|
|
71
|
+
|
|
72
|
+
A standard JWT with three base64url parts (`header.payload.signature`).
|
|
73
|
+
Payload claims you care about:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"iss": "https://accounts.google.com",
|
|
78
|
+
"aud": "1234567890-abc.apps.googleusercontent.com", // YOUR client ID
|
|
79
|
+
"sub": "10769150350006150715113082367", // stable Google user ID
|
|
80
|
+
"email": "user@gmail.com",
|
|
81
|
+
"email_verified": true,
|
|
82
|
+
"name": "Ada Lovelace",
|
|
83
|
+
"picture": "https://lh3.googleusercontent.com/…",
|
|
84
|
+
"nonce": "d9b2d63d-…", // echoed from initialize() — replay defense
|
|
85
|
+
"iat": 1719410000,
|
|
86
|
+
"exp": 1719413600 // ~1 hour lifetime
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- **`sub` is the permanent user key.** Emails can change; `sub` never does.
|
|
91
|
+
Store `sub` as `google_id` and match returning users by it, not by email.
|
|
92
|
+
- **`aud` must equal your client ID** — otherwise a token minted for another
|
|
93
|
+
site would log users into yours.
|
|
94
|
+
- The signature is RS256 over Google's rotating public keys (JWKS). Client
|
|
95
|
+
libraries fetch and cache those keys for you.
|
|
96
|
+
|
|
97
|
+
### 2.2 Frontend (reference: `web/static/js/app.js:16-110`, `web/static/index.html:246-296`)
|
|
98
|
+
|
|
99
|
+
```html
|
|
100
|
+
<!-- index.html -->
|
|
101
|
+
<meta name="google-client-id" content="__GOOGLE_CLIENT_ID__"> <!-- injected server-side from env -->
|
|
102
|
+
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
|
103
|
+
<div id="google-signin-btn"></div>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
// app.js (essentials)
|
|
108
|
+
var _googleNonce = '';
|
|
109
|
+
|
|
110
|
+
function _renderGoogleButton() {
|
|
111
|
+
var clientId = _getGoogleClientId(); // read from the <meta> tag
|
|
112
|
+
if (!clientId) return; // Google auth not configured → hide, degrade honestly
|
|
113
|
+
if (typeof google === 'undefined' || !google.accounts) {
|
|
114
|
+
// gsi/client loads async — retry up to 20 × 150 ms instead of dropping the button
|
|
115
|
+
if (_googleRenderRetries++ < 20) setTimeout(_renderGoogleButton, 150);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
_googleNonce = crypto.randomUUID(); // fresh nonce per render — replay defense
|
|
119
|
+
|
|
120
|
+
google.accounts.id.initialize({
|
|
121
|
+
client_id: clientId,
|
|
122
|
+
callback: handleGoogleCredential,
|
|
123
|
+
auto_select: false,
|
|
124
|
+
cancel_on_tap_outside: true,
|
|
125
|
+
nonce: _googleNonce,
|
|
126
|
+
});
|
|
127
|
+
google.accounts.id.renderButton(
|
|
128
|
+
document.getElementById('google-signin-btn'),
|
|
129
|
+
{ theme: 'outline', size: 'large', type: 'standard', text: 'continue_with', width: 308 },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function handleGoogleCredential(response) {
|
|
134
|
+
// response.credential = the Google-signed ID token (JWT)
|
|
135
|
+
var resp = await fetch('/api/auth/google', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ credential: response.credential, nonce: _googleNonce }),
|
|
139
|
+
});
|
|
140
|
+
var data = await resp.json();
|
|
141
|
+
if (!resp.ok) { showError(data.detail); return; }
|
|
142
|
+
// backend has set an HttpOnly session cookie; store display fields only
|
|
143
|
+
AppState.saveCredentials(data.access_token, data.email, data.user_id, /* … */);
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
On logout, also call `google.accounts.id.disableAutoSelect()` so One Tap
|
|
148
|
+
doesn't silently re-login the user.
|
|
149
|
+
|
|
150
|
+
**Why serve the client ID via a server-injected `<meta>` tag:** one source of
|
|
151
|
+
truth (the `GOOGLE_CLIENT_ID` env var), no hardcoded IDs in JS bundles, and
|
|
152
|
+
environments (dev/staging/prod) can use different OAuth clients without a
|
|
153
|
+
rebuild. The client ID is public — not a secret — but drift between
|
|
154
|
+
environments is a classic outage.
|
|
155
|
+
|
|
156
|
+
### 2.3 Backend verification (reference: `web/auth.py:230-255`)
|
|
157
|
+
|
|
158
|
+
Never decode the JWT yourself and trust the payload. Use Google's library —
|
|
159
|
+
it fetches/caches JWKS, checks signature, `aud`, `iss`, `exp`:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
# pip install google-auth
|
|
163
|
+
from google.oauth2 import id_token
|
|
164
|
+
from google.auth.transport import requests as google_requests
|
|
165
|
+
|
|
166
|
+
_transport = google_requests.Request() # reuse one instance (connection pooling)
|
|
167
|
+
|
|
168
|
+
def verify_google_token(credential: str) -> dict:
|
|
169
|
+
if not GOOGLE_CLIENT_ID:
|
|
170
|
+
raise ValueError("Google Sign-In is not configured (GOOGLE_CLIENT_ID missing)")
|
|
171
|
+
try:
|
|
172
|
+
payload = id_token.verify_oauth2_token(credential, _transport, GOOGLE_CLIENT_ID)
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
raise ValueError(f"Invalid Google token: {exc}") from exc
|
|
175
|
+
if not payload.get("email_verified"):
|
|
176
|
+
raise ValueError("Google account email is not verified")
|
|
177
|
+
return payload
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Node equivalent: `google-auth-library` → `client.verifyIdToken({ idToken, audience: CLIENT_ID })`.
|
|
181
|
+
|
|
182
|
+
### 2.4 Account linking — the three-way branch (reference: `web/auth.py:358-435`)
|
|
183
|
+
|
|
184
|
+
Every Google login resolves to exactly one of three cases:
|
|
185
|
+
|
|
186
|
+
1. **Returning Google user** — a record with this `google_id` (`sub`) exists
|
|
187
|
+
→ issue session. Refresh `name`/`picture` if they changed.
|
|
188
|
+
2. **Existing email/password account, no `google_id`** — link Google to it
|
|
189
|
+
(set `google_id`, mark `email_verified=True`) → issue session.
|
|
190
|
+
⚠️ **Pre-hijacking guard** (see §4.3): only auto-link if the existing
|
|
191
|
+
account's email ownership was verified. Otherwise refuse with "sign in
|
|
192
|
+
with your password".
|
|
193
|
+
3. **Brand-new user** — create the account with `email_verified=True` (the
|
|
194
|
+
Google token already proved inbox ownership), empty password hash → issue
|
|
195
|
+
session.
|
|
196
|
+
|
|
197
|
+
Then issue your own session. Prowl issues an HS256 app JWT (72 h, `ver` claim
|
|
198
|
+
for global revocation) delivered as an **HttpOnly, Secure, SameSite=Strict
|
|
199
|
+
cookie** — out of reach of JS/XSS, never sent cross-site.
|
|
200
|
+
|
|
201
|
+
## 3. Setup from scratch (~10 minutes)
|
|
202
|
+
|
|
203
|
+
### 3.1 Google Cloud Console
|
|
204
|
+
|
|
205
|
+
1. <https://console.cloud.google.com/> → create/pick a project.
|
|
206
|
+
2. **APIs & Services → OAuth consent screen**: user type **External**, fill
|
|
207
|
+
app name / support email / developer email. Scopes: none needed beyond the
|
|
208
|
+
defaults (GIS only uses `openid email profile`). Publish the app (while
|
|
209
|
+
"Testing", only allowlisted test users can sign in).
|
|
210
|
+
3. **APIs & Services → Credentials → Create credentials → OAuth client ID**:
|
|
211
|
+
- Application type: **Web application**.
|
|
212
|
+
- **Authorized JavaScript origins**: every origin the button renders on —
|
|
213
|
+
e.g. `https://yourapp.com`, `https://www.yourapp.com`, and for dev
|
|
214
|
+
`http://localhost:8000` (origin = scheme+host+port, **no path, no
|
|
215
|
+
trailing slash**).
|
|
216
|
+
- **Authorized redirect URIs: leave empty** — the GIS popup flow doesn't
|
|
217
|
+
redirect.
|
|
218
|
+
4. Copy the **Client ID** (`…apps.googleusercontent.com`). There is a client
|
|
219
|
+
secret on that page — the GIS ID-token flow **never uses it**; don't ship it.
|
|
220
|
+
|
|
221
|
+
### 3.2 App configuration
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
# .env
|
|
225
|
+
GOOGLE_CLIENT_ID=1234567890-abc123.apps.googleusercontent.com
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- Inject it into the page server-side (Prowl: `server.py` replaces
|
|
229
|
+
`__GOOGLE_CLIENT_ID__` in the served HTML with the env value).
|
|
230
|
+
- If the env var is empty → the button silently doesn't render and the
|
|
231
|
+
backend returns "not configured". Honest degradation, no crash.
|
|
232
|
+
|
|
233
|
+
### 3.3 Content-Security-Policy
|
|
234
|
+
|
|
235
|
+
If you set a CSP, GIS needs (reference: `web/server.py:625-642`):
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
script-src … https://accounts.google.com;
|
|
239
|
+
connect-src … https://accounts.google.com;
|
|
240
|
+
frame-src … https://accounts.google.com;
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### 3.4 Dependencies
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
pip install google-auth # Python
|
|
247
|
+
npm i google-auth-library # Node
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## 4. Security checklist (each item maps to a real attack)
|
|
251
|
+
|
|
252
|
+
### 4.1 Token verification — non-negotiable
|
|
253
|
+
|
|
254
|
+
- ☑ Verify **signature** against Google JWKS (the library does it). Never
|
|
255
|
+
`jwt.decode(..., verify=False)`.
|
|
256
|
+
- ☑ Verify **`aud` == your client ID** — blocks tokens minted for other apps.
|
|
257
|
+
- ☑ Verify **`exp`** (library) — blocks stale tokens.
|
|
258
|
+
- ☑ Require **`email_verified == true`** — Google accounts can exist with
|
|
259
|
+
unverified emails (e.g. Workspace-imported); skipping this lets an attacker
|
|
260
|
+
claim someone else's address.
|
|
261
|
+
|
|
262
|
+
### 4.2 Nonce — replay defense
|
|
263
|
+
|
|
264
|
+
Generate a fresh random nonce per button render, pass it to
|
|
265
|
+
`google.accounts.id.initialize({nonce})`, send it alongside the credential,
|
|
266
|
+
compare server-side with the token's `nonce` claim
|
|
267
|
+
(`web/auth.py:375-376`). A stolen/logged ID token can't be replayed later
|
|
268
|
+
because the nonce won't match the new session's nonce.
|
|
269
|
+
|
|
270
|
+
### 4.3 Account pre-hijacking guard (P3-01 in Prowl)
|
|
271
|
+
|
|
272
|
+
Attack: attacker registers `victim@gmail.com` with a password *before* the
|
|
273
|
+
victim ever visits. Later the victim clicks "Sign in with Google". Naïve
|
|
274
|
+
auto-linking attaches victim's Google to the **attacker's** record — attacker
|
|
275
|
+
keeps password access to the merged account (its data and wallet).
|
|
276
|
+
|
|
277
|
+
Defense (`web/auth.py:392-417`): when a Google login matches an existing
|
|
278
|
+
**password** account whose email was **never verified**, refuse to auto-link;
|
|
279
|
+
tell the user to sign in with the password. Link only when email ownership of
|
|
280
|
+
the existing record is proven.
|
|
281
|
+
|
|
282
|
+
### 4.4 Login-CSRF (P3-03 in Prowl)
|
|
283
|
+
|
|
284
|
+
Attack: attacker's page force-POSTs the **attacker's own** Google credential
|
|
285
|
+
to your `/api/auth/google`; the victim's browser gets logged into the
|
|
286
|
+
attacker's account (victim then unknowingly feeds data/payments into it).
|
|
287
|
+
|
|
288
|
+
Defense (`web/server.py:1657-1686`), covering both possible delivery flows:
|
|
289
|
+
|
|
290
|
+
- **Form-POST flow** (GIS `login_uri` auto-POST): GIS double-submits
|
|
291
|
+
`g_csrf_token` in body *and* cookie — compare both, constant-time
|
|
292
|
+
(`hmac.compare_digest`). Reject if either is missing.
|
|
293
|
+
- **JS-fetch flow** (this app): no `g_csrf_token`, so enforce same-origin via
|
|
294
|
+
**Fetch-Metadata** (`Sec-Fetch-Site: same-origin`) with an Origin-allowlist
|
|
295
|
+
fallback. These headers are browser-set and unforgeable by page script.
|
|
296
|
+
|
|
297
|
+
### 4.5 Session hygiene
|
|
298
|
+
|
|
299
|
+
- ☑ Session cookie: **HttpOnly + Secure + SameSite=Strict** (XSS-exfil and
|
|
300
|
+
CSRF resistant). `web/server.py:1604-1620`.
|
|
301
|
+
- ☑ Your app JWT carries a **version claim** (`ver`) checked against the
|
|
302
|
+
user's stored `auth_version` → bumping it revokes all outstanding sessions
|
|
303
|
+
("log out everywhere").
|
|
304
|
+
- ☑ Rate-limit `/api/auth/*` (unauthenticated, internet-reachable).
|
|
305
|
+
- ☑ Log auth events (success/failure, user id prefix) — never log the
|
|
306
|
+
credential/JWT itself.
|
|
307
|
+
|
|
308
|
+
### 4.6 What is public vs secret
|
|
309
|
+
|
|
310
|
+
| Value | Status |
|
|
311
|
+
|---|---|
|
|
312
|
+
| `GOOGLE_CLIENT_ID` | Public (visible in page source — fine) |
|
|
313
|
+
| Google client secret | **Unused** in GIS flow — don't put it in the app at all |
|
|
314
|
+
| Your app JWT secret (`WEB_JWT_SECRET`) | Secret — rotate via runbook; refuse to boot in prod with a default |
|
|
315
|
+
| Google ID token | Sensitive in transit; verify once, never store, never log |
|
|
316
|
+
|
|
317
|
+
## 5. Troubleshooting
|
|
318
|
+
|
|
319
|
+
| Symptom | Cause → fix |
|
|
320
|
+
|---|---|
|
|
321
|
+
| Button never renders | `gsi/client` not loaded yet (retry loop handles it); or empty client ID meta; or CSP blocks `accounts.google.com` |
|
|
322
|
+
| `[GSI_LOGGER] The given origin is not allowed for the given client ID` | Current origin missing from **Authorized JavaScript origins** (check port and `www.` exactly). Changes take minutes to propagate |
|
|
323
|
+
| `Invalid Google token: Token used too early/expired` | Server clock skew → sync NTP |
|
|
324
|
+
| `Wrong recipient / audience mismatch` | Frontend and backend use different client IDs — single env source of truth |
|
|
325
|
+
| 403 `access_denied` at consent | OAuth consent screen still in **Testing** and the user isn't a test user → publish the app |
|
|
326
|
+
| Works locally, fails in prod | Prod origin not in Authorized origins; or cookie has `Secure` on plain HTTP (Prowl dev: `PROWL_INSECURE_COOKIES=1`) |
|
|
327
|
+
| One Tap doesn't show / FedCM warnings | Third-party-cookie phase-out: GIS auto-migrates to FedCM; keep `gsi/client` unmodified and current — the rendered button flow is unaffected |
|
|
328
|
+
| User "lost password" but signed up via Google | Expected: Google-only accounts have empty password hash. Recovery = sign in with Google using the same email |
|
|
329
|
+
|
|
330
|
+
## 6. Reference implementation map (Prowl)
|
|
331
|
+
|
|
332
|
+
| Concern | Where |
|
|
333
|
+
|---|---|
|
|
334
|
+
| Client ID config | `web/auth.py:72` (`GOOGLE_CLIENT_ID` env) |
|
|
335
|
+
| Token verification | `web/auth.py:230-255` (`verify_google_token`) |
|
|
336
|
+
| Three-way linking + pre-hijack guard | `web/auth.py:358-435` (`google_auth_user`) |
|
|
337
|
+
| Endpoint + CSRF (both flows) | `web/server.py:1657-1690` (`/api/auth/google`) |
|
|
338
|
+
| Cookie policy | `web/server.py:1596-1620` |
|
|
339
|
+
| CSP allowances | `web/server.py:625-642` |
|
|
340
|
+
| Frontend button + nonce + callback | `web/static/js/app.js:16-110` |
|
|
341
|
+
| GIS script + meta + container | `web/static/index.html:246-296` |
|
|
342
|
+
| App JWT issue/verify/revocation | `web/auth.py:151-209` |
|
|
343
|
+
| Email canonicalization (anti-abuse) | `web/auth.py:116-145` |
|
|
344
|
+
|
|
345
|
+
## 7. Minimal end-to-end skeleton (copy-paste starting point, FastAPI)
|
|
346
|
+
|
|
347
|
+
```python
|
|
348
|
+
# --- backend ---
|
|
349
|
+
import os, hmac
|
|
350
|
+
from fastapi import FastAPI, HTTPException, Request, Response
|
|
351
|
+
from pydantic import BaseModel
|
|
352
|
+
from google.oauth2 import id_token
|
|
353
|
+
from google.auth.transport import requests as google_requests
|
|
354
|
+
|
|
355
|
+
GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
|
|
356
|
+
_transport = google_requests.Request()
|
|
357
|
+
app = FastAPI()
|
|
358
|
+
|
|
359
|
+
class GoogleAuthRequest(BaseModel):
|
|
360
|
+
credential: str
|
|
361
|
+
nonce: str | None = None
|
|
362
|
+
g_csrf_token: str | None = None
|
|
363
|
+
|
|
364
|
+
@app.post("/api/auth/google")
|
|
365
|
+
async def google_auth(req: Request, data: GoogleAuthRequest, response: Response):
|
|
366
|
+
# CSRF: double-submit for form-POST flow, Fetch-Metadata for JS flow
|
|
367
|
+
if data.g_csrf_token:
|
|
368
|
+
cookie = req.cookies.get("g_csrf_token") or ""
|
|
369
|
+
if not hmac.compare_digest(cookie, data.g_csrf_token):
|
|
370
|
+
raise HTTPException(403, "CSRF token mismatch")
|
|
371
|
+
elif req.headers.get("sec-fetch-site", "same-origin") not in ("same-origin", "none"):
|
|
372
|
+
raise HTTPException(403, "Cross-site request rejected")
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
payload = id_token.verify_oauth2_token(data.credential, _transport, GOOGLE_CLIENT_ID)
|
|
376
|
+
except Exception as exc:
|
|
377
|
+
raise HTTPException(401, f"Invalid Google token: {exc}")
|
|
378
|
+
if not payload.get("email_verified"):
|
|
379
|
+
raise HTTPException(401, "Google account email is not verified")
|
|
380
|
+
if data.nonce and payload.get("nonce") != data.nonce:
|
|
381
|
+
raise HTTPException(401, "Nonce mismatch")
|
|
382
|
+
|
|
383
|
+
user = await find_or_create_user( # your 3-way linking here (§2.4, §4.3)
|
|
384
|
+
google_id=payload["sub"], email=payload["email"].lower().strip(),
|
|
385
|
+
name=payload.get("name"), picture=payload.get("picture"),
|
|
386
|
+
)
|
|
387
|
+
session_jwt = issue_app_session(user) # your own JWT/session
|
|
388
|
+
response.set_cookie("auth", session_jwt, httponly=True, secure=True,
|
|
389
|
+
samesite="strict", max_age=72 * 3600, path="/")
|
|
390
|
+
return {"user_id": user.id, "email": user.email}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
```html
|
|
394
|
+
<!-- --- frontend --- -->
|
|
395
|
+
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
|
396
|
+
<div id="gbtn"></div>
|
|
397
|
+
<script>
|
|
398
|
+
const NONCE = crypto.randomUUID();
|
|
399
|
+
window.onload = () => {
|
|
400
|
+
google.accounts.id.initialize({
|
|
401
|
+
client_id: "YOUR_CLIENT_ID.apps.googleusercontent.com",
|
|
402
|
+
nonce: NONCE,
|
|
403
|
+
callback: async ({ credential }) => {
|
|
404
|
+
const r = await fetch("/api/auth/google", {
|
|
405
|
+
method: "POST",
|
|
406
|
+
headers: { "Content-Type": "application/json" },
|
|
407
|
+
body: JSON.stringify({ credential, nonce: NONCE }),
|
|
408
|
+
});
|
|
409
|
+
if (r.ok) location.href = "/app";
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
google.accounts.id.renderButton(document.getElementById("gbtn"),
|
|
413
|
+
{ theme: "outline", size: "large", text: "continue_with" });
|
|
414
|
+
};
|
|
415
|
+
</script>
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
*Last verified against the Prowl codebase on 2026-07-08. If line numbers
|
|
421
|
+
drift, search for the function names — they are stable.*
|