@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/LICENSE +21 -0
  3. package/README.md +132 -0
  4. package/SECURITY.md +63 -0
  5. package/bin/sheleg-dev.js +99 -0
  6. package/package.json +45 -0
  7. package/plugins/sheleg-dev/.claude-plugin/plugin.json +28 -0
  8. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +442 -0
  9. package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +257 -0
  10. package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +201 -0
  11. package/plugins/sheleg-dev/skills/ad-tracking/references/gtag-api.md +389 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +103 -0
  13. package/plugins/sheleg-dev/skills/ad-tracking/references/performance-security.md +288 -0
  14. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +384 -0
  15. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +1357 -0
  16. package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +147 -0
  17. package/plugins/sheleg-dev/skills/frontend-performance/references/accessibility-perf.md +172 -0
  18. package/plugins/sheleg-dev/skills/frontend-performance/references/css-performance.md +180 -0
  19. package/plugins/sheleg-dev/skills/frontend-performance/references/nextjs.md +271 -0
  20. package/plugins/sheleg-dev/skills/google-auth/SKILL.md +275 -0
  21. package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +464 -0
  22. package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +615 -0
  23. package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +593 -0
  24. package/plugins/sheleg-dev/skills/google-auth/references/workload-identity.md +376 -0
  25. package/plugins/sheleg-dev/skills/google-signin/SKILL.md +121 -0
  26. package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +421 -0
  27. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +458 -0
  28. package/plugins/sheleg-dev/skills/stripe-billing/references/price-integrity.md +201 -0
  29. package/plugins/sheleg-dev/skills/stripe-billing/references/stripe-agent-toolchain.md +173 -0
  30. package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +294 -0
  31. package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +214 -0
  32. 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
+ ```