@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,593 @@
|
|
|
1
|
+
# Sign In with Google (Google Identity Services)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## Contents
|
|
5
|
+
|
|
6
|
+
- [Table of Contents](#table-of-contents)
|
|
7
|
+
- [Overview](#overview)
|
|
8
|
+
- [Client-Side Integration](#client-side-integration)
|
|
9
|
+
- [Server-Side ID Token Verification](#server-side-id-token-verification)
|
|
10
|
+
- [CSRF Protection](#csrf-protection)
|
|
11
|
+
- [Nonce Verification](#nonce-verification)
|
|
12
|
+
- [ID Token Payload Fields](#id-token-payload-fields)
|
|
13
|
+
- [Account Linking Flows](#account-linking-flows)
|
|
14
|
+
- [Next.js / React Integration Pattern](#nextjs--react-integration-pattern)
|
|
15
|
+
- [FastAPI Integration Pattern](#fastapi-integration-pattern)
|
|
16
|
+
- [Flask Integration Pattern](#flask-integration-pattern)
|
|
17
|
+
- [FedCM — mandatory, not a migration you are planning](#fedcm--mandatory-not-a-migration-you-are-planning)
|
|
18
|
+
- [Sign Out](#sign-out)
|
|
19
|
+
|
|
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
|
+
## Overview
|
|
34
|
+
|
|
35
|
+
Sign In with Google (part of Google Identity Services / GIS) provides:
|
|
36
|
+
- **Personalized Sign-In Button** — branded Google sign-in button
|
|
37
|
+
- **One Tap** — low-friction prompt for returning users
|
|
38
|
+
- **Automatic Sign-In** — silent sign-in for previously consented users
|
|
39
|
+
|
|
40
|
+
GIS separates authentication (sign-in → ID token) from authorization (API access → access token). Use GIS for sign-in; use OAuth 2.0 authorization API separately for data access.
|
|
41
|
+
|
|
42
|
+
## Client-Side Integration
|
|
43
|
+
|
|
44
|
+
### HTML API (simplest)
|
|
45
|
+
|
|
46
|
+
```html
|
|
47
|
+
<script src="https://accounts.google.com/gsi/client" async></script>
|
|
48
|
+
|
|
49
|
+
<div id="g_id_onload"
|
|
50
|
+
data-client_id="YOUR_CLIENT_ID.apps.googleusercontent.com"
|
|
51
|
+
data-context="signin"
|
|
52
|
+
data-ux_mode="popup"
|
|
53
|
+
data-callback="handleCredentialResponse"
|
|
54
|
+
data-auto_prompt="false">
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="g_id_signin"
|
|
58
|
+
data-type="standard"
|
|
59
|
+
data-shape="rectangular"
|
|
60
|
+
data-theme="outline"
|
|
61
|
+
data-text="signin_with"
|
|
62
|
+
data-size="large"
|
|
63
|
+
data-logo_alignment="left">
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<script>
|
|
67
|
+
function handleCredentialResponse(response) {
|
|
68
|
+
// response.credential = JWT ID token
|
|
69
|
+
// Send to your server for verification
|
|
70
|
+
fetch('/auth/google', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: {'Content-Type': 'application/json'},
|
|
73
|
+
body: JSON.stringify({credential: response.credential})
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### JavaScript API
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
google.accounts.id.initialize({
|
|
83
|
+
client_id: 'YOUR_CLIENT_ID.apps.googleusercontent.com',
|
|
84
|
+
callback: handleCredentialResponse,
|
|
85
|
+
auto_select: true, // automatic sign-in
|
|
86
|
+
cancel_on_tap_outside: false,
|
|
87
|
+
nonce: myNonce // optional: anti-replay nonce
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Render button
|
|
91
|
+
google.accounts.id.renderButton(
|
|
92
|
+
document.getElementById('google-signin-btn'),
|
|
93
|
+
{theme: 'outline', size: 'large', type: 'standard'}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Show One Tap prompt
|
|
97
|
+
google.accounts.id.prompt();
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Redirect Mode
|
|
101
|
+
|
|
102
|
+
```html
|
|
103
|
+
<div id="g_id_onload"
|
|
104
|
+
data-client_id="YOUR_CLIENT_ID"
|
|
105
|
+
data-ux_mode="redirect"
|
|
106
|
+
data-login_uri="https://example.com/auth/google">
|
|
107
|
+
</div>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Server-Side ID Token Verification
|
|
111
|
+
|
|
112
|
+
### Node.js
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
const {OAuth2Client} = require('google-auth-library');
|
|
116
|
+
const client = new OAuth2Client();
|
|
117
|
+
|
|
118
|
+
async function verifyGoogleToken(idToken) {
|
|
119
|
+
const ticket = await client.verifyIdToken({
|
|
120
|
+
idToken: idToken,
|
|
121
|
+
audience: WEB_CLIENT_ID,
|
|
122
|
+
// For multiple clients:
|
|
123
|
+
// audience: [CLIENT_ID_1, CLIENT_ID_2]
|
|
124
|
+
});
|
|
125
|
+
const payload = ticket.getPayload();
|
|
126
|
+
|
|
127
|
+
// payload.sub — unique Google Account ID (use as primary key)
|
|
128
|
+
// payload.email — user's email
|
|
129
|
+
// payload.email_verified — boolean
|
|
130
|
+
// payload.name — full name
|
|
131
|
+
// payload.picture — profile picture URL
|
|
132
|
+
// payload.given_name — first name
|
|
133
|
+
// payload.family_name — last name
|
|
134
|
+
// payload.hd — hosted domain (Google Workspace)
|
|
135
|
+
|
|
136
|
+
return payload;
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Python
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from google.oauth2 import id_token
|
|
144
|
+
from google.auth.transport import requests
|
|
145
|
+
|
|
146
|
+
# Reuse a single Request instance for connection pooling
|
|
147
|
+
request = requests.Request()
|
|
148
|
+
|
|
149
|
+
def verify_google_token(credential: str) -> dict:
|
|
150
|
+
payload = id_token.verify_oauth2_token(
|
|
151
|
+
credential,
|
|
152
|
+
request,
|
|
153
|
+
WEB_CLIENT_ID
|
|
154
|
+
# For multiple clients, pass a list:
|
|
155
|
+
# audience=[CLIENT_ID_1, CLIENT_ID_2]
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if not payload.get('email_verified', False):
|
|
159
|
+
raise ValueError('Google account email is not verified')
|
|
160
|
+
|
|
161
|
+
# payload['sub'] — unique Google Account ID (use as primary key)
|
|
162
|
+
# payload['email'] — user's email
|
|
163
|
+
# payload['email_verified'] — boolean
|
|
164
|
+
# payload['name'] — full name
|
|
165
|
+
# payload.get('picture') — profile picture URL
|
|
166
|
+
# payload.get('given_name') — first name
|
|
167
|
+
# payload.get('family_name') — last name
|
|
168
|
+
# payload.get('hd') — hosted domain (Google Workspace)
|
|
169
|
+
|
|
170
|
+
return payload
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Verification Checks
|
|
174
|
+
|
|
175
|
+
Both `verifyIdToken` (Node.js) and `verify_oauth2_token` (Python) automatically verify:
|
|
176
|
+
- **JWT signature** — using Google's public keys (rotated regularly)
|
|
177
|
+
- **`aud` claim** — matches your client ID
|
|
178
|
+
- **`exp` claim** — token not expired
|
|
179
|
+
- **`iss` claim** — is `accounts.google.com` or `https://accounts.google.com`
|
|
180
|
+
|
|
181
|
+
### Additional Manual Checks
|
|
182
|
+
|
|
183
|
+
**Node.js**
|
|
184
|
+
|
|
185
|
+
```js
|
|
186
|
+
const payload = ticket.getPayload();
|
|
187
|
+
|
|
188
|
+
// Restrict to Google Workspace domain
|
|
189
|
+
if (payload.hd !== 'yourdomain.com') {
|
|
190
|
+
throw new Error('Must use organization account');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Verify email is authoritative
|
|
194
|
+
const isGoogleAuthoritative = (
|
|
195
|
+
(payload.email_verified && payload.hd) || // Workspace account
|
|
196
|
+
payload.email.endsWith('@gmail.com') // Gmail account
|
|
197
|
+
);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Python**
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
# Restrict to Google Workspace domain
|
|
204
|
+
if payload.get('hd') != 'yourdomain.com':
|
|
205
|
+
raise ValueError('Must use organization account')
|
|
206
|
+
|
|
207
|
+
# Verify email is authoritative
|
|
208
|
+
is_google_authoritative = (
|
|
209
|
+
(payload.get('email_verified') and payload.get('hd'))
|
|
210
|
+
or payload['email'].endswith('@gmail.com')
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## CSRF Protection
|
|
215
|
+
|
|
216
|
+
GIS uses double-submit-cookie pattern.
|
|
217
|
+
|
|
218
|
+
### Node.js (Express)
|
|
219
|
+
|
|
220
|
+
```js
|
|
221
|
+
app.post('/auth/google', (req, res) => {
|
|
222
|
+
const csrfTokenCookie = req.cookies['g_csrf_token'];
|
|
223
|
+
const csrfTokenBody = req.body.g_csrf_token;
|
|
224
|
+
|
|
225
|
+
if (!csrfTokenCookie || !csrfTokenBody) {
|
|
226
|
+
return res.status(400).send('Missing CSRF token');
|
|
227
|
+
}
|
|
228
|
+
if (csrfTokenCookie !== csrfTokenBody) {
|
|
229
|
+
return res.status(400).send('CSRF token mismatch');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const payload = await verifyGoogleToken(req.body.credential);
|
|
233
|
+
// ... create session
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Python (FastAPI)
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from fastapi import APIRouter, Request, HTTPException
|
|
241
|
+
from pydantic import BaseModel
|
|
242
|
+
|
|
243
|
+
class GoogleLoginRequest(BaseModel):
|
|
244
|
+
credential: str
|
|
245
|
+
g_csrf_token: str | None = None
|
|
246
|
+
nonce: str | None = None
|
|
247
|
+
|
|
248
|
+
@router.post("/google")
|
|
249
|
+
async def google_login(request: Request, body: GoogleLoginRequest):
|
|
250
|
+
# CSRF: match cookie against body (skip if cookie absent, e.g. cross-origin)
|
|
251
|
+
if body.g_csrf_token:
|
|
252
|
+
cookie_token = request.cookies.get("g_csrf_token")
|
|
253
|
+
if cookie_token and cookie_token != body.g_csrf_token:
|
|
254
|
+
raise HTTPException(status_code=403, detail="CSRF token mismatch")
|
|
255
|
+
|
|
256
|
+
payload = verify_google_token(body.credential)
|
|
257
|
+
# ... create session
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Python (Flask)
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
from flask import Flask, request, jsonify, abort
|
|
264
|
+
|
|
265
|
+
@app.route('/auth/google', methods=['POST'])
|
|
266
|
+
def google_login():
|
|
267
|
+
body = request.get_json()
|
|
268
|
+
csrf_cookie = request.cookies.get('g_csrf_token')
|
|
269
|
+
csrf_body = body.get('g_csrf_token')
|
|
270
|
+
|
|
271
|
+
if not csrf_cookie or not csrf_body:
|
|
272
|
+
abort(400, 'Missing CSRF token')
|
|
273
|
+
if csrf_cookie != csrf_body:
|
|
274
|
+
abort(400, 'CSRF token mismatch')
|
|
275
|
+
|
|
276
|
+
payload = verify_google_token(body['credential'])
|
|
277
|
+
# ... create session
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Nonce Verification
|
|
281
|
+
|
|
282
|
+
Pass a nonce to `google.accounts.id.initialize()` on the client, then verify it server-side against the token's `nonce` claim.
|
|
283
|
+
|
|
284
|
+
**Client-side:**
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
const nonce = crypto.randomUUID(); // or any random string
|
|
288
|
+
google.accounts.id.initialize({
|
|
289
|
+
client_id: CLIENT_ID,
|
|
290
|
+
callback: handleResponse,
|
|
291
|
+
nonce: nonce
|
|
292
|
+
});
|
|
293
|
+
// Send nonce alongside credential to your backend
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**Server-side (Node.js):**
|
|
297
|
+
|
|
298
|
+
```js
|
|
299
|
+
const payload = ticket.getPayload();
|
|
300
|
+
if (payload.nonce !== expectedNonce) {
|
|
301
|
+
throw new Error('Nonce mismatch');
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
**Server-side (Python):**
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
if body.nonce:
|
|
309
|
+
token_nonce = payload.get('nonce')
|
|
310
|
+
if not token_nonce or token_nonce != body.nonce:
|
|
311
|
+
raise ValueError('Nonce mismatch')
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## ID Token Payload Fields
|
|
315
|
+
|
|
316
|
+
| Field | Description |
|
|
317
|
+
|-------|-------------|
|
|
318
|
+
| `sub` | Unique Google Account ID — **use as primary key** |
|
|
319
|
+
| `email` | User's email address |
|
|
320
|
+
| `email_verified` | Whether email is verified |
|
|
321
|
+
| `name` | Full name |
|
|
322
|
+
| `given_name` | First name |
|
|
323
|
+
| `family_name` | Last name |
|
|
324
|
+
| `picture` | Profile picture URL |
|
|
325
|
+
| `hd` | Hosted domain (Google Workspace only) |
|
|
326
|
+
| `iss` | Issuer (`accounts.google.com`) |
|
|
327
|
+
| `aud` | Your client ID |
|
|
328
|
+
| `exp` | Expiration time (Unix timestamp) |
|
|
329
|
+
| `iat` | Issued at time |
|
|
330
|
+
| `nonce` | Anti-replay nonce (if provided during initialization) |
|
|
331
|
+
| `locale` | User's locale |
|
|
332
|
+
|
|
333
|
+
**Important**: Use `sub` (not `email`) as the unique identifier. Email can be changed by the user.
|
|
334
|
+
|
|
335
|
+
## Account Linking Flows
|
|
336
|
+
|
|
337
|
+
After verifying the ID token, determine user state:
|
|
338
|
+
|
|
339
|
+
### Node.js
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
async function handleGoogleSignIn(payload) {
|
|
343
|
+
const googleId = payload.sub;
|
|
344
|
+
const email = payload.email;
|
|
345
|
+
|
|
346
|
+
// 1. Check if user exists by Google ID
|
|
347
|
+
let user = await db.findUserByGoogleId(googleId);
|
|
348
|
+
if (user) {
|
|
349
|
+
return createSession(user);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 2. Check if email matches existing account — link Google
|
|
353
|
+
user = await db.findUserByEmail(email);
|
|
354
|
+
if (user) {
|
|
355
|
+
await db.linkGoogleAccount(user.id, googleId);
|
|
356
|
+
return createSession(user);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 3. New user — create account
|
|
360
|
+
user = await db.createUser({
|
|
361
|
+
googleId,
|
|
362
|
+
email,
|
|
363
|
+
name: payload.name,
|
|
364
|
+
picture: payload.picture
|
|
365
|
+
});
|
|
366
|
+
return createSession(user);
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Python (SQLAlchemy)
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
async def find_or_create_google_user(session, payload: dict):
|
|
374
|
+
google_id = payload['sub']
|
|
375
|
+
email = payload['email'].lower().strip()
|
|
376
|
+
name = payload.get('name', '') or email.split('@')[0]
|
|
377
|
+
picture = payload.get('picture')
|
|
378
|
+
|
|
379
|
+
# 1. Match by google_id
|
|
380
|
+
user = await session.scalar(select(User).where(User.google_id == google_id))
|
|
381
|
+
if user:
|
|
382
|
+
return user, False # existing user
|
|
383
|
+
|
|
384
|
+
# 2. Match by email — link Google account
|
|
385
|
+
user = await session.scalar(select(User).where(User.email == email))
|
|
386
|
+
if user:
|
|
387
|
+
user.google_id = google_id
|
|
388
|
+
user.auth_provider = 'google'
|
|
389
|
+
user.picture_url = picture
|
|
390
|
+
await session.commit()
|
|
391
|
+
return user, False # linked
|
|
392
|
+
|
|
393
|
+
# 3. New user
|
|
394
|
+
user = User(
|
|
395
|
+
email=email,
|
|
396
|
+
display_name=name,
|
|
397
|
+
auth_provider='google',
|
|
398
|
+
google_id=google_id,
|
|
399
|
+
picture_url=picture,
|
|
400
|
+
password_hash=None,
|
|
401
|
+
)
|
|
402
|
+
session.add(user)
|
|
403
|
+
await session.commit()
|
|
404
|
+
return user, True # created
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Next.js / React Integration Pattern
|
|
408
|
+
|
|
409
|
+
### Client Component
|
|
410
|
+
|
|
411
|
+
```tsx
|
|
412
|
+
'use client';
|
|
413
|
+
import {useEffect, useRef, useCallback} from 'react';
|
|
414
|
+
|
|
415
|
+
declare global {
|
|
416
|
+
interface Window { google: any; }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function GoogleSignInButton({onSuccess}: {onSuccess: (credential: string, nonce: string) => void}) {
|
|
420
|
+
const nonceRef = useRef(crypto.randomUUID());
|
|
421
|
+
|
|
422
|
+
const handleResponse = useCallback((response: {credential: string}) => {
|
|
423
|
+
onSuccess(response.credential, nonceRef.current);
|
|
424
|
+
nonceRef.current = crypto.randomUUID();
|
|
425
|
+
}, [onSuccess]);
|
|
426
|
+
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
const script = document.createElement('script');
|
|
429
|
+
script.src = 'https://accounts.google.com/gsi/client';
|
|
430
|
+
script.async = true;
|
|
431
|
+
script.onload = () => {
|
|
432
|
+
window.google.accounts.id.initialize({
|
|
433
|
+
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
|
|
434
|
+
callback: handleResponse,
|
|
435
|
+
nonce: nonceRef.current
|
|
436
|
+
});
|
|
437
|
+
window.google.accounts.id.renderButton(
|
|
438
|
+
document.getElementById('google-signin-btn'),
|
|
439
|
+
{theme: 'outline', size: 'large', width: 300}
|
|
440
|
+
);
|
|
441
|
+
};
|
|
442
|
+
document.head.appendChild(script);
|
|
443
|
+
return () => { script.remove(); };
|
|
444
|
+
}, [handleResponse]);
|
|
445
|
+
|
|
446
|
+
return <div id="google-signin-btn" />;
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Server Action / API Route (Node.js)
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
// app/api/auth/google/route.ts
|
|
454
|
+
import {OAuth2Client} from 'google-auth-library';
|
|
455
|
+
import {NextRequest, NextResponse} from 'next/server';
|
|
456
|
+
|
|
457
|
+
const client = new OAuth2Client();
|
|
458
|
+
|
|
459
|
+
export async function POST(req: NextRequest) {
|
|
460
|
+
const {credential, nonce} = await req.json();
|
|
461
|
+
|
|
462
|
+
const ticket = await client.verifyIdToken({
|
|
463
|
+
idToken: credential,
|
|
464
|
+
audience: process.env.GOOGLE_CLIENT_ID,
|
|
465
|
+
});
|
|
466
|
+
const payload = ticket.getPayload()!;
|
|
467
|
+
|
|
468
|
+
if (nonce && payload.nonce !== nonce) {
|
|
469
|
+
return NextResponse.json({error: 'Nonce mismatch'}, {status: 401});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Create/find user, create session...
|
|
473
|
+
return NextResponse.json({success: true, user: {email: payload.email, name: payload.name}});
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
## FastAPI Integration Pattern
|
|
478
|
+
|
|
479
|
+
```python
|
|
480
|
+
from fastapi import APIRouter, Request, HTTPException
|
|
481
|
+
from pydantic import BaseModel
|
|
482
|
+
from google.oauth2 import id_token
|
|
483
|
+
from google.auth.transport import requests as google_requests
|
|
484
|
+
|
|
485
|
+
router = APIRouter()
|
|
486
|
+
_request = google_requests.Request()
|
|
487
|
+
CLIENT_ID = settings.google_client_id
|
|
488
|
+
|
|
489
|
+
class GoogleLoginRequest(BaseModel):
|
|
490
|
+
credential: str
|
|
491
|
+
g_csrf_token: str | None = None
|
|
492
|
+
nonce: str | None = None
|
|
493
|
+
|
|
494
|
+
@router.post("/auth/google")
|
|
495
|
+
async def google_login(request: Request, body: GoogleLoginRequest):
|
|
496
|
+
# 1. CSRF check
|
|
497
|
+
if body.g_csrf_token:
|
|
498
|
+
cookie_token = request.cookies.get("g_csrf_token")
|
|
499
|
+
if cookie_token and cookie_token != body.g_csrf_token:
|
|
500
|
+
raise HTTPException(status_code=403, detail="CSRF token mismatch")
|
|
501
|
+
|
|
502
|
+
# 2. Verify Google ID token
|
|
503
|
+
try:
|
|
504
|
+
payload = id_token.verify_oauth2_token(body.credential, _request, CLIENT_ID)
|
|
505
|
+
except ValueError:
|
|
506
|
+
raise HTTPException(status_code=401, detail="Invalid Google token")
|
|
507
|
+
|
|
508
|
+
if not payload.get("email_verified", False):
|
|
509
|
+
raise HTTPException(status_code=401, detail="Email not verified")
|
|
510
|
+
|
|
511
|
+
# 3. Verify nonce
|
|
512
|
+
if body.nonce:
|
|
513
|
+
if payload.get("nonce") != body.nonce:
|
|
514
|
+
raise HTTPException(status_code=401, detail="Nonce mismatch")
|
|
515
|
+
|
|
516
|
+
# 4. Find or create user, issue app JWT...
|
|
517
|
+
user, is_new = await find_or_create_google_user(db, payload)
|
|
518
|
+
token = create_jwt(user.id, user.email)
|
|
519
|
+
return {"token": token, "user": serialize_user(user)}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
## Flask Integration Pattern
|
|
523
|
+
|
|
524
|
+
```python
|
|
525
|
+
from flask import Flask, request, jsonify, abort
|
|
526
|
+
from google.oauth2 import id_token
|
|
527
|
+
from google.auth.transport import requests as google_requests
|
|
528
|
+
|
|
529
|
+
app = Flask(__name__)
|
|
530
|
+
_request = google_requests.Request()
|
|
531
|
+
CLIENT_ID = 'YOUR_CLIENT_ID.apps.googleusercontent.com'
|
|
532
|
+
|
|
533
|
+
@app.route('/auth/google', methods=['POST'])
|
|
534
|
+
def google_login():
|
|
535
|
+
body = request.get_json()
|
|
536
|
+
|
|
537
|
+
# 1. CSRF check
|
|
538
|
+
csrf_cookie = request.cookies.get('g_csrf_token')
|
|
539
|
+
csrf_body = body.get('g_csrf_token')
|
|
540
|
+
if csrf_cookie and csrf_body and csrf_cookie != csrf_body:
|
|
541
|
+
abort(403, 'CSRF token mismatch')
|
|
542
|
+
|
|
543
|
+
# 2. Verify Google ID token
|
|
544
|
+
try:
|
|
545
|
+
payload = id_token.verify_oauth2_token(body['credential'], _request, CLIENT_ID)
|
|
546
|
+
except ValueError:
|
|
547
|
+
abort(401, 'Invalid Google token')
|
|
548
|
+
|
|
549
|
+
if not payload.get('email_verified', False):
|
|
550
|
+
abort(401, 'Email not verified')
|
|
551
|
+
|
|
552
|
+
# 3. Verify nonce
|
|
553
|
+
nonce = body.get('nonce')
|
|
554
|
+
if nonce and payload.get('nonce') != nonce:
|
|
555
|
+
abort(401, 'Nonce mismatch')
|
|
556
|
+
|
|
557
|
+
# 4. Find or create user, issue session/JWT...
|
|
558
|
+
return jsonify({'success': True, 'email': payload['email']})
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
## FedCM — mandatory, not a migration you are planning
|
|
562
|
+
|
|
563
|
+
Google Identity Services moved to the Federated Credential Management (FedCM)
|
|
564
|
+
API in Chrome as part of Privacy Sandbox, and **FedCM became mandatory for One
|
|
565
|
+
Tap and Sign-In button implementations in August 2025** (verified 2026-08-06).
|
|
566
|
+
The transition-period escape hatch — the `use_fedcm` opt-out and the traffic
|
|
567
|
+
exemption that preceded it — is gone. A guide that presents FedCM as optional or
|
|
568
|
+
upcoming predates the cutover; date-check it before following it.
|
|
569
|
+
|
|
570
|
+
What this means in practice:
|
|
571
|
+
|
|
572
|
+
- The GIS library handles FedCM for you — **keep `gsi/client` loaded from
|
|
573
|
+
Google and current**. A vendored or pinned copy is the one reliable way to
|
|
574
|
+
break sign-in.
|
|
575
|
+
- Test in Chrome with third-party cookies disabled. That is the default
|
|
576
|
+
condition now, not an edge case.
|
|
577
|
+
- FedCM renders a **browser-native** UI instead of the old iframe One Tap, so
|
|
578
|
+
CSS overrides and screenshots written against the iframe no longer apply.
|
|
579
|
+
- Code that branched on the old `prompt()` notification callbacks
|
|
580
|
+
(`isNotDisplayed()`, `getNotDisplayedReason()`) needs revisiting — the
|
|
581
|
+
moment-notification shape changed with FedCM.
|
|
582
|
+
|
|
583
|
+
Source: [Migrate to FedCM](https://developers.google.com/identity/gsi/web/guides/fedcm-migration).
|
|
584
|
+
|
|
585
|
+
## Sign Out
|
|
586
|
+
|
|
587
|
+
```js
|
|
588
|
+
// Client-side sign out
|
|
589
|
+
google.accounts.id.disableAutoSelect();
|
|
590
|
+
|
|
591
|
+
// Also revoke server-side session
|
|
592
|
+
fetch('/auth/logout', {method: 'POST'});
|
|
593
|
+
```
|