Files
zeekay 39386df20f tap: the tapped credential goes to IAM to be verified, and the gate says so
The page hands IAM the credential (id_token_hint), its nonce (idp_nonce) and the
address (login_hint) and trusts nothing in it; a credential naming no address
is not forwarded. tap.spec.ts held the old contract (address only,
auto_select false): it now asserts the nonce, the credential and the hint, no
prompt parameter, and that a panel which assumes Google until IAM answers
withdraws the prompt when IAM does not offer it.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 03:29:12 -07:00

144 lines
5.7 KiB
TypeScript

'use client'
// Google One Tap on /login and /signup.
//
// Google offers the accounts this browser is signed in to Google as, over the
// page, and a tap hands the page a signed credential. The page trusts nothing in
// it. It hands the credential to IAM, on the same top-level hop the Google button
// makes, as `id_token_hint` with the nonce it asked Google to bind (`idp_nonce`);
// IAM verifies the signature, issuer, audience, expiry, freshness and nonce
// against Google's keys and signs the person in with no second choice of account.
// The credential's address also rides as `login_hint`, so when IAM cannot take the
// credential Google is sent straight to that account instead of its chooser. IAM
// stays the one authority.
//
// It is offered only where a person is choosing how to sign in. Never on the
// landing (components/home/Start draws the panel without it: the prompt over a
// hero was removed there, 72ae61b7), never to a browser driven by a script, and
// never to one that says do-not-track or Global Privacy Control, since the prompt
// tells Google which site is open.
//
// The origin must be among the OAuth client's authorized JavaScript origins in
// Google Cloud (the client IAM's provider-google uses), or Google logs "The given
// origin is not allowed for the given client ID" and shows nothing.
import { useEffect, useRef } from 'react'
import { refused } from '@hanzo/event'
/** IAM's provider-google client. A client id is public: it is in the authorize URL IAM sends the browser to. */
const CLIENT = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '113591532635-s8pvqrebprkbndluhftddmdap4htvu1p.apps.googleusercontent.com'
const SCRIPT = 'https://accounts.google.com/gsi/client'
interface Identity {
initialize: (config: {
client_id: string
callback: (response: { credential?: string }) => void
nonce?: string
auto_select?: boolean
cancel_on_tap_outside?: boolean
use_fedcm_for_prompt?: boolean
}) => void
prompt: (callback?: (notification: any) => void) => void
cancel: () => void
disableAutoSelect?: () => void
}
declare global {
interface Window {
google?: { accounts: { id: Identity } }
}
}
/** Who the page hands a tapped account to: the mounted panel's, replaced on each mount. */
let hand: ((email?: string, credential?: string, nonce?: string) => void) | undefined
/** Google's script, loaded and configured once for the page's life: initialize() twice logs a warning. */
let loaded: Promise<Identity | undefined> | undefined
let currentNonce: string | undefined
/** The address a credential names, or nothing. The token is not verified here, so this is a hint and never an identity. */
export function hint(credential?: string): string | undefined {
try {
const body = credential?.split('.')[1]
if (!body) return undefined
const bytes = Uint8Array.from(atob(body.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(body.length / 4) * 4, '=')), (c) => c.charCodeAt(0))
const email = (JSON.parse(new TextDecoder().decode(bytes)) as { email?: unknown }).email
return typeof email === 'string' && /^[^\s@]+@[^\s@]+$/.test(email) ? email : undefined
} catch {
return undefined
}
}
function configure(id: Identity): Identity {
currentNonce = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : undefined
id.initialize({
client_id: CLIENT,
nonce: currentNonce,
callback: (response) => hand?.(hint(response.credential), response.credential, currentNonce),
auto_select: true,
cancel_on_tap_outside: true,
use_fedcm_for_prompt: true,
})
return id
}
function identity(): Promise<Identity | undefined> {
loaded ??= new Promise((resolve) => {
if (window.google?.accounts.id) return resolve(configure(window.google.accounts.id))
const script = document.createElement('script')
script.src = SCRIPT
script.async = true
script.defer = true
script.onload = () => resolve(window.google?.accounts.id ? configure(window.google.accounts.id) : undefined)
script.onerror = () => {
// Blocked or offline: the Google button is still there, and the next mount may try again.
loaded = undefined
script.remove()
resolve(undefined)
}
document.head.appendChild(script)
})
return loaded
}
/** Whether this browser may be offered the prompt at all: a person's, and one that has not opted out of being told on. */
function offered(): boolean {
if (typeof window === 'undefined') return false
if (navigator.webdriver === true) return false
return !refused()
}
/**
* One Tap while `on`: shown when the panel is the first screen and Google is
* a way in, withdrawn the moment a press, a step or a leave makes it stale.
* `pick` receives the tapped account's address, for the Google hop's hint.
*/
export function useTap(on: boolean, pick: (email?: string, credential?: string, nonce?: string) => void): void {
// The latest pick, so a render that changes it does not show the prompt again.
const latest = useRef(pick)
latest.current = pick
useEffect(() => {
if (!on || !offered()) return
let live = true
const mine = (email?: string, credential?: string, nonce?: string) => latest.current(email, credential, nonce)
hand = mine
void identity().then((id) => {
if (live && id) {
try {
id.prompt((notification: any) => {
// Gracefully ignore unprompted/origin mismatch notifications
if (notification?.isNotDisplayed?.() || notification?.isSkippedMoment?.()) {
// FedCM or origin restriction; silent fallback to explicit button
}
})
} catch (_) {}
}
})
return () => {
live = false
if (hand === mine) hand = undefined
window.google?.accounts.id.cancel()
}
}, [on])
}