mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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
144 lines
5.7 KiB
TypeScript
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])
|
|
}
|