SuperAPI AI Directory

google-signin

General & other

View repository

Updated 10-15 23:25

About this skill

Google Sign-In (GIS) — production web login

Condensed operating instructions. Full narrative guide with diagrams, token anatomy, troubleshooting table, and a copy-paste FastAPI + JS skeleton: references/full-guide.md — read it when implementing from scratch; its FastAPI + JS skeleton is the reference implementation this skill ships.

Pick the right flow first

  • Only need "who is this user?" → GIS ID-token flow (this skill): load https://accounts.google.com/gsi/client, receive one Google-signed JWT, verify server-side. No redirect URI, no client secret, no Google-token storage.
  • Need to call Google APIs (Gmail/Drive/Calendar) on the user's behalf → OAuth 2.0 authorization-code flow instead (redirect URI + client secret + refresh tokens). Never use the code flow just for login.

Core flow (ID-token)

  1. Frontend: fetch a server-issued nonce first (GET /api/auth/google/nonce — the server stores it with a TTL, bound to a pre-auth HttpOnly cookie), then google.accounts.id.initialize({ client_id, callback, nonce })
    • renderButton(). A client-generated nonce POSTed back proves nothing: the value would be read out of the very JWT it is meant to check. The GSI script loads async — retry rendering (e.g. 20 × 150 ms) instead of silently dropping the button.
  2. Callback receives { credential } — a Google-signed ID token (JWT). POST it alone to your backend (no nonce in the body). Never treat it as a session.
  3. Backend: verify with the official lib — Python google.oauth2.id_token.verify_oauth2_token(credential, transport, CLIENT_ID), Node google-auth-library verifyIdToken({ idToken, audience }). That checks signature (Google JWKS), aud, iss, exp. Never hand-decode and trust the payload.
  4. Additionally require email_verified == true and pop the expected nonce from the pre-auth session (one-time consume) and require the token nonce claim to equal it exactly — a missing claim, a missing expectation, an expired or an already-consumed nonce all reject. This is what makes a stolen token non-replayable: a replay from a new session meets a different expectation, a replay in the same session meets a consumed one.
  5. Find-or-create the user, then issue YOUR OWN session (app JWT) as an HttpOnly + Secure + SameSite=Strict cookie. Google's token is verified once, never stored, never logged.

Identity & account linking (three-way branch)

Key the user on sub (stable Google user ID; store as google_id) — never on email (emails change, sub doesn't). On each Google login:

  1. Record with this google_id exists → login (refresh name/picture).
  2. Email matches an existing account without google_id → do NOT auto-link on email_verified alone. email_verified is Google saying it verified the inbox ONCE, not that this person owns the LOCAL account, and for a third-party address Google is not even authoritative that they still own the inbox. Default: link only after a fresh re-auth of the existing local account (its password / existing factor), so the person proves they hold the account Google is being attached to.
    • Auto-link WITHOUT that re-auth is allowed only when Google is authoritative for the address — email_verified AND (the address is @gmail.com OR the token carries an hd Workspace-domain claim) — AND the existing account's own email ownership was proven. For any other (third-party) address, run an independent challenge (a link to that inbox) before linking; Google's one-time verification is not current proof.
    • If the existing account is a password account whose email was never verified (a possible pre-registration hijack), send the person into a safe account-recovery flow — never "sign in with the existing password", which may be the attacker's.
  3. No match → create the user. Mark email_verified=True only when Google is authoritative for the address (Gmail or hd); for a third-party address record it unverified and challenge the inbox before granting anything that trusts the email.

Login-CSRF (cover BOTH delivery flows)

The two delivery flows are separate, explicitly typed paths, chosen by Content-Type — not one handler that claims both. A skeleton that declares form support but only binds a JSON body does not actually run the form contract.

  • Form-POST flow (login_uri auto-POST, application/x-www-form-urlencoded): GIS double-submits g_csrf_token in the FORM BODY and the cookie — require BOTH present and equal, constant-time (hmac.compare_digest). Missing either side → reject. This token is the whole authorization for the form path.
  • JS-fetch flow (application/json): no g_csrf_token, so require a trusted same-origin signal — Sec-Fetch-Site: same-origin (browser-set, unforgeable), OR, only when that header is ABSENT, an exact Origin in an allowlist. A missing Sec-Fetch-Site is NOT assumed same-origin; cross-site and none are rejected; and with neither a trusted metadata value nor an allowed Origin the request fails closed.

The CSRF decision runs BEFORE the token is verified — no external call on a request that has not proven its origin. Without this, an attacker's page can force-POST the attacker's credential and silently log the victim into the attacker's account.

Setup (GCP)

  1. Cloud Console → APIs & Services → OAuth consent screen: choose the audience. External Testing normally limits access to test users, but basic identity scopes (openid, email, profile) are exempt. Workspace policy can still block access; publishing alone is not the remedy. See the dated setup note in the full guide.
  2. Credentials → Create OAuth client ID → Web application → Authorized JavaScript origins = every origin the button renders on, incl. http://localhost:<port> for dev (scheme+host+port, no path, no trailing slash). Authorized redirect URIs: leave empty for GIS.
  3. Ship only GOOGLE_CLIENT_ID (public; env var; one source of truth — inject into HTML server-side, e.g. via a <meta> tag). The client secret is UNUSED in the GIS flow — never put it in the app.
  4. CSP if present: allow https://accounts.google.com in script-src, connect-src, frame-src.
  5. If GOOGLE_CLIENT_ID is empty → hide the button and return "not configured" server-side (honest degradation, no crash).

Security checklist (verify ALL before calling it done)

  • Signature verified via official lib against Google JWKS (no verify=False, no manual base64 decode-and-trust)
  • aud == your client ID; exp enforced
  • email_verified required
  • Nonce: fresh per render, checked server-side against the token claim
  • Pre-hijacking guard on email-based linking
  • Login-CSRF: g_csrf_token double-submit AND same-origin check for the JS flow
  • Own session cookie: HttpOnly + Secure + SameSite=Strict
  • /api/auth/* rate-limited; auth events logged WITHOUT the credential
  • Session revocation path exists (e.g. an auth_version/ver claim check)
  • Logout also calls google.accounts.id.disableAutoSelect()

Debugging quick table

Symptom Fix
"The given origin is not allowed for the given client ID" Add the exact origin (port, www.) to Authorized JavaScript origins; propagation takes minutes
Button never renders GSI async race (add retry loop), empty client ID, or CSP blocks accounts.google.com
"Token used too early/expired" Server clock skew → sync NTP
Audience mismatch Frontend/backend client IDs differ → single env source of truth
access_denied on consent Check requested scopes, audience and Workspace policy. Testing allowlisting applies beyond basic identity scopes; investigate before changing publishing status
Works locally, breaks in prod Prod origin missing in GCP, or Secure cookie served over plain HTTP
One Tap missing / FedCM console warnings FedCM is mandatory since Aug 2025 and the opt-out is gone (checked 2026-08-06). Load gsi/client from Google and keep it current — a vendored or pinned copy is the real break. The rendered button flow is unaffected; code branching on the old isNotDisplayed() moment callbacks is not

Degradation

  • Not Claude Code (Cursor, Codex, the skills CLI, the API container): the pack's PreToolUse gate does not run — the rules here hold, the refusal does not.
  • No browser channel: the sign-in button and its redirect are verified by reading the code, not by watching it work. Record that as the weaker claim it is.
  • No console access: creating the OAuth client is a human step; state it once, do the rest, and say what is waiting on it.