@pya-platform/auth 0.1.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 ADDED
@@ -0,0 +1,14 @@
1
+ # @pya/auth
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a9ca6bf: Initial release of the Pya platform packages. Extracted from `pyaeats-app`, consumed by `pyaeats-app` (food delivery) and `pyaserv` (services classifieds).
8
+
9
+ Each package exposes a Hono router factory (auth/cms/reviews/comments) or a typed helper (email/audit/cf) parameterised over Cloudflare D1 + KV bindings. UI primitives ship as Lit web components on top of `@pya/tokens` (CSS custom properties). See `ROADMAP.md` and `docs/phase-6-rollout.md` for the consumer cutover plan.
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [a9ca6bf]
14
+ - @pya/shared@0.1.0
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@pya-platform/auth",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "registry": "https://registry.npmjs.org",
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/undeadliner/pya-platform.git"
12
+ },
13
+ "type": "module",
14
+ "description": "Auth engine — passwordless (OTP + magic link), passkey/WebAuthn, OAuth, recovery codes, sessions (KV), CSRF. Hono router factories parameterised over D1/KV bindings.",
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./routes/passwordless": "./src/routes/passwordless.ts",
18
+ "./routes/oauth": "./src/routes/oauth.ts",
19
+ "./routes/dev-bypass": "./src/routes/dev-bypass.ts",
20
+ "./session": "./src/session.ts",
21
+ "./migrations/*": "./src/migrations/*"
22
+ },
23
+ "scripts": {
24
+ "type-check": "tsc --noEmit",
25
+ "test": "echo '@pya/auth has no tests yet'"
26
+ },
27
+ "dependencies": {
28
+ "@pya-platform/shared": "workspace:*",
29
+ "@simplewebauthn/server": "^13.3.0",
30
+ "effect": "^3.10.0",
31
+ "hono": "^4.6.0",
32
+ "jose": "^5.9.0",
33
+ "valibot": "^1.0.0"
34
+ },
35
+ "peerDependencies": {
36
+ "@cloudflare/workers-types": "^4.20240909.0"
37
+ }
38
+ }
package/src/env.ts ADDED
@@ -0,0 +1,57 @@
1
+ // The shape every Pya host worker must supply to `@pya-platform/auth` via `Bindings`.
2
+ // Hosts can extend this with their own bindings — Hono merges via intersection.
3
+ // Keep this interface narrow: only what auth itself touches.
4
+
5
+ export interface PyaAuthBindings {
6
+ /** Primary database (sessions/users/passkeys/recovery_codes/audit) */
7
+ readonly DB: D1Database
8
+ /** Session KV — bound to a separate namespace from cache KVs */
9
+ readonly SESSIONS: KVNamespace
10
+ /** OAuth state KV — short-TTL nonces */
11
+ readonly OAUTH_STATE: KVNamespace
12
+ /** Deployment env — controls SameSite policy and dev-bypass gating */
13
+ readonly ENVIRONMENT: 'development' | 'preview' | 'staging' | 'production'
14
+ /** Customer-facing site origin (cookie domain anchoring) */
15
+ readonly SITE_ORIGIN: string
16
+ /** Admin site origin (separate cookie name) */
17
+ readonly ADMIN_ORIGIN: string
18
+ /** API self origin (for OAuth callback URL construction) */
19
+ readonly API_ORIGIN: string
20
+
21
+ /** Pepper for IP/UA hashes — rotate without invalidating sessions */
22
+ readonly SESSION_PEPPER?: string
23
+ /** CSRF HMAC signing key */
24
+ readonly CSRF_HMAC_KEY?: string
25
+ /** Cloudflare Turnstile secret (bot protection) */
26
+ readonly TURNSTILE_SECRET?: string
27
+
28
+ /** Resend API key (passwordless email) */
29
+ readonly RESEND_API_KEY?: string
30
+ /** Email "from" domain — must be verified in Resend */
31
+ readonly EMAIL_DOMAIN?: string
32
+
33
+ /** WebAuthn relying-party identifier (e.g. `pyaeats.com`) */
34
+ readonly WEBAUTHN_RP_ID?: string
35
+ /** Comma-separated allowed origins for WebAuthn assertions */
36
+ readonly WEBAUTHN_ORIGINS?: string
37
+
38
+ /** OAuth provider secrets — optional, providers without secrets return 501 */
39
+ readonly GOOGLE_OAUTH_CLIENT_ID?: string
40
+ readonly GOOGLE_OAUTH_CLIENT_SECRET?: string
41
+ readonly FACEBOOK_APP_ID?: string
42
+ readonly FACEBOOK_APP_SECRET?: string
43
+
44
+ /** OAuth endpoint overrides (E2E sidecar). Defaults baked in providers/*.ts */
45
+ readonly GOOGLE_AUTH_URL?: string
46
+ readonly GOOGLE_TOKEN_URL?: string
47
+ readonly GOOGLE_JWKS_URL?: string
48
+ }
49
+
50
+ // Re-exported as global `Env` so the existing code (which references `Env`
51
+ // directly via Hono's `Bindings`) compiles without further edits. Each host
52
+ // worker declares its own `Env` that extends `PyaAuthBindings` plus host-
53
+ // specific fields; that augmented `Env` is what Hono sees at runtime.
54
+ declare global {
55
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
56
+ interface Env extends PyaAuthBindings {}
57
+ }
@@ -0,0 +1,64 @@
1
+ import { type ProviderClaims, uuidV7 } from '@pya-platform/shared'
2
+ import { IdentityConflictError } from '@pya-platform/shared'
3
+
4
+ export interface LinkResult {
5
+ readonly userId: string
6
+ readonly created: boolean
7
+ readonly linked: boolean
8
+ }
9
+
10
+ const insertIdentity = (db: D1Database, userId: string, claims: ProviderClaims, ts: number) =>
11
+ db
12
+ .prepare(
13
+ `INSERT INTO user_identities (user_id, provider, subject, email_at_link, linked_at)
14
+ VALUES (?, ?, ?, ?, ?)`,
15
+ )
16
+ .bind(userId, claims.provider, claims.subject, claims.email, ts)
17
+
18
+ export const provisionOrLink = async (
19
+ db: D1Database,
20
+ claims: ProviderClaims,
21
+ intent: 'login' | 'link',
22
+ currentUserId: string | undefined,
23
+ ): Promise<LinkResult> => {
24
+ const now = Math.floor(Date.now() / 1000)
25
+
26
+ const existing = await db
27
+ .prepare('SELECT user_id FROM user_identities WHERE provider = ? AND subject = ?')
28
+ .bind(claims.provider, claims.subject)
29
+ .first<{ user_id: string }>()
30
+
31
+ if (existing !== null) {
32
+ if (intent === 'link' && currentUserId !== undefined && existing.user_id !== currentUserId) {
33
+ throw new IdentityConflictError({ provider: claims.provider })
34
+ }
35
+ return { userId: existing.user_id, created: false, linked: false }
36
+ }
37
+
38
+ if (intent === 'link' && currentUserId !== undefined) {
39
+ await insertIdentity(db, currentUserId, claims, now).run()
40
+ return { userId: currentUserId, created: false, linked: true }
41
+ }
42
+
43
+ const existingUser = await db
44
+ .prepare("SELECT id FROM users WHERE email = ? AND status != 'deleted'")
45
+ .bind(claims.email)
46
+ .first<{ id: string }>()
47
+
48
+ if (existingUser !== null) {
49
+ await insertIdentity(db, existingUser.id, claims, now).run()
50
+ return { userId: existingUser.id, created: false, linked: true }
51
+ }
52
+
53
+ const userId = uuidV7()
54
+ await db.batch([
55
+ db
56
+ .prepare(
57
+ `INSERT INTO users (id, email, email_verified, display_name, locale, created_at, status)
58
+ VALUES (?, ?, 1, ?, ?, ?, 'active')`,
59
+ )
60
+ .bind(userId, claims.email, claims.displayName ?? null, claims.locale ?? 'es-PY', now),
61
+ insertIdentity(db, userId, claims, now),
62
+ ])
63
+ return { userId, created: true, linked: false }
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ // @pya-platform/auth — public surface.
2
+ //
3
+ // Hono router factories + middleware. Each consumer wires its own Worker
4
+ // like so:
5
+ //
6
+ // const app = new Hono<{ Bindings: Env }>()
7
+ // app.route('/api/auth', passwordlessRoutes)
8
+ // app.route('/api/auth', oauthRoutes)
9
+ // app.route('/api/auth/dev', createDevBypassRoutes({ onCronSweep }))
10
+ // app.use('/v1/*', requireAuth)
11
+ //
12
+ // The `Env` interface is contributed by `./env.ts` (PyaAuthBindings). Hosts
13
+ // extend it with their own bindings — Hono merges via intersection.
14
+
15
+ import './env.ts'
16
+
17
+ export type { PyaAuthBindings } from './env.ts'
18
+ export { requireAuth, issueSession, revokeSession } from './session.ts'
19
+ export { passwordlessRoutes } from './routes/passwordless.ts'
20
+ export { oauthRoutes } from './routes/oauth.ts'
21
+ export { createDevBypassRoutes } from './routes/dev-bypass.ts'
22
+ export { logAuth, type AuthEvent } from './log.ts'
23
+ export { provisionOrLink } from './identity-link.ts'
24
+ export {
25
+ newSessionId,
26
+ readSession,
27
+ touchSession,
28
+ writeSession,
29
+ deleteSession,
30
+ } from './store/session-store.ts'
31
+ // Re-export domain errors so consumers don't need to import @pya-platform/shared
32
+ // separately just for `mapErrorToStatus` / `UnauthorizedError` / etc.
33
+ export {
34
+ UnauthorizedError,
35
+ ForbiddenError,
36
+ NotFoundError,
37
+ ValidationError,
38
+ ConflictError,
39
+ RateLimitedError,
40
+ UpstreamError,
41
+ InvalidTokenError,
42
+ IdentityConflictError,
43
+ ProviderNotEnabledError,
44
+ mapErrorToStatus,
45
+ type DomainError,
46
+ } from '@pya-platform/shared'
package/src/log.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { IdentityProvider } from '@pya-platform/shared'
2
+
3
+ export type AuthEvent =
4
+ | 'auth.login.oauth'
5
+ | 'auth.login.email'
6
+ | 'auth.login.passkey'
7
+ | 'auth.login.recovery'
8
+ | 'auth.login.rejected'
9
+ | 'auth.email.send_failed'
10
+ | 'auth.passkey.registered'
11
+ | 'auth.recovery.generated'
12
+
13
+ export type AuthProvider = IdentityProvider | 'recovery'
14
+
15
+ export interface AuthLogEvent {
16
+ readonly event: AuthEvent
17
+ readonly ts: number
18
+ readonly userId?: string
19
+ readonly provider: AuthProvider
20
+ readonly ipHash?: string
21
+ readonly outcome: 'created' | 'linked' | 'reused' | 'rejected' | 'sent'
22
+ readonly reason?: string
23
+ }
24
+
25
+ export const logAuth = (event: AuthLogEvent): void => {
26
+ console.log(JSON.stringify({ stream: 'audit', ...event }))
27
+ }
@@ -0,0 +1,70 @@
1
+ -- 0001_init — identity & audit foundations.
2
+ -- Migrations are applied via `wrangler d1 migrations apply` per environment.
3
+
4
+ CREATE TABLE users (
5
+ id TEXT PRIMARY KEY, -- UUID v7
6
+ email TEXT NOT NULL, -- normalised lowercase
7
+ email_verified INTEGER NOT NULL DEFAULT 0, -- 0/1
8
+ display_name TEXT,
9
+ locale TEXT NOT NULL DEFAULT 'es-PY',
10
+ created_at INTEGER NOT NULL, -- unix seconds
11
+ status TEXT NOT NULL DEFAULT 'active' -- active | suspended | deleted
12
+ );
13
+
14
+ CREATE UNIQUE INDEX ux_users_email_active ON users(email) WHERE status != 'deleted';
15
+
16
+ CREATE TABLE user_identities (
17
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
18
+ provider TEXT NOT NULL, -- google | apple | facebook
19
+ subject TEXT NOT NULL, -- provider 'sub' claim
20
+ email_at_link TEXT,
21
+ linked_at INTEGER NOT NULL,
22
+ PRIMARY KEY (provider, subject)
23
+ );
24
+
25
+ CREATE INDEX ix_identities_user ON user_identities(user_id);
26
+
27
+ CREATE TABLE roles (
28
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
29
+ role TEXT NOT NULL, -- customer | store_owner | store_staff | courier | admin | super_admin
30
+ store_id TEXT, -- NULL for global roles
31
+ granted_by TEXT REFERENCES users(id),
32
+ granted_at INTEGER NOT NULL,
33
+ PRIMARY KEY (user_id, role, store_id)
34
+ );
35
+
36
+ CREATE INDEX ix_roles_user ON roles(user_id);
37
+ CREATE INDEX ix_roles_store ON roles(store_id, role);
38
+
39
+ CREATE TABLE audit_log (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ ts INTEGER NOT NULL,
42
+ actor_user_id TEXT,
43
+ actor_role TEXT NOT NULL,
44
+ actor_ip_hash TEXT, -- HMAC-SHA256(ip, SESSION_PEPPER)
45
+ action TEXT NOT NULL,
46
+ target_type TEXT,
47
+ target_id TEXT,
48
+ store_id TEXT,
49
+ details_json TEXT,
50
+ prev_hash TEXT NOT NULL,
51
+ row_hash TEXT NOT NULL
52
+ );
53
+
54
+ CREATE INDEX ix_audit_ts ON audit_log(ts);
55
+ CREATE INDEX ix_audit_actor ON audit_log(actor_user_id, ts);
56
+
57
+ -- RUM events from web-vitals beacons.
58
+ CREATE TABLE rum_events (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ name TEXT NOT NULL,
61
+ value REAL NOT NULL,
62
+ rating TEXT,
63
+ url TEXT NOT NULL,
64
+ ua TEXT,
65
+ ts INTEGER NOT NULL,
66
+ session_id TEXT
67
+ );
68
+
69
+ CREATE INDEX ix_rum_name_ts ON rum_events(name, ts);
70
+ CREATE INDEX ix_rum_url ON rum_events(url, ts);
@@ -0,0 +1,17 @@
1
+ -- 0004_passkeys — WebAuthn credential storage for spec 011.
2
+ -- Applied via `wrangler d1 migrations apply pyaeats-preview --remote`.
3
+
4
+ CREATE TABLE passkeys (
5
+ credential_id TEXT PRIMARY KEY, -- base64url
6
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
7
+ public_key TEXT NOT NULL, -- base64url-encoded COSE key bytes
8
+ sign_count INTEGER NOT NULL DEFAULT 0,
9
+ transports TEXT, -- JSON array, e.g. ["internal","hybrid"]
10
+ label TEXT, -- user-friendly device label
11
+ created_at INTEGER NOT NULL, -- unix seconds
12
+ last_used_at INTEGER NOT NULL,
13
+ backup_eligible INTEGER NOT NULL DEFAULT 0, -- 0/1
14
+ backup_state INTEGER NOT NULL DEFAULT 0 -- 0/1
15
+ );
16
+
17
+ CREATE INDEX ix_passkeys_user ON passkeys(user_id);
@@ -0,0 +1,24 @@
1
+ -- Recovery codes — one-time fallback when both passkey AND email OTP fail
2
+ -- (lost device + lost mailbox + lost recovery key on the third device, etc).
3
+ --
4
+ -- A user enrolling a passkey for the first time gets 8 codes generated and
5
+ -- displayed ONCE. They store them outside the app (password manager, paper).
6
+ -- Redeeming a code:
7
+ -- • marks it used (`used_at`)
8
+ -- • invalidates ALL existing passkeys for that user (security best practice —
9
+ -- a leaked code means the account is compromised; force re-enrolment)
10
+ -- • mints a session
11
+ --
12
+ -- We store only the hash, never the plaintext. PBKDF2-SHA256 1-round w/
13
+ -- per-row salt is enough for short-lived (~years) high-entropy (64 bits)
14
+ -- secrets; we don't need Argon2 cost overhead. Salt + hash are both 32 bytes,
15
+ -- encoded as 64-char hex.
16
+ CREATE TABLE recovery_codes (
17
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
18
+ salt_hex TEXT NOT NULL, -- 32 bytes hex = 64 chars
19
+ code_hash TEXT NOT NULL, -- 32 bytes hex = 64 chars
20
+ used_at INTEGER, -- unix-seconds when redeemed
21
+ created_at INTEGER NOT NULL,
22
+ PRIMARY KEY (user_id, code_hash)
23
+ );
24
+ CREATE INDEX ix_recovery_codes_user ON recovery_codes(user_id, used_at);
@@ -0,0 +1,102 @@
1
+ import {
2
+ type OAuthProvider,
3
+ OAuthProviderSchema,
4
+ type OAuthState,
5
+ OAuthStateSchema,
6
+ type ProviderClaims,
7
+ } from '@pya-platform/shared'
8
+ import { UnauthorizedError } from '@pya-platform/shared'
9
+ import type { Context } from 'hono'
10
+ import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
11
+ import * as v from 'valibot'
12
+ import { provisionOrLink } from './identity-link.ts'
13
+ import { logAuth } from './log.ts'
14
+ import { exchangeAndVerifyApple } from './providers/apple.ts'
15
+ import { exchangeAndVerifyFacebook } from './providers/facebook.ts'
16
+ import { exchangeAndVerifyGoogle } from './providers/google.ts'
17
+ import { issueSession } from './session.ts'
18
+
19
+ export const buildRedirectUri = (env: Env, provider: OAuthProvider): string =>
20
+ `${env.API_ORIGIN}/api/auth/callback/${provider}`
21
+
22
+ const exchangeForProvider = (
23
+ env: Env,
24
+ provider: OAuthProvider,
25
+ redirectUri: string,
26
+ code: string,
27
+ state: OAuthState,
28
+ ): Promise<ProviderClaims> => {
29
+ switch (provider) {
30
+ case 'google':
31
+ return exchangeAndVerifyGoogle(env, redirectUri, code, state.verifier, state.nonce)
32
+ case 'facebook':
33
+ return exchangeAndVerifyFacebook(env, redirectUri, code, state.verifier, state.nonce)
34
+ case 'apple':
35
+ return exchangeAndVerifyApple(env, redirectUri, code, state.verifier, state.nonce)
36
+ }
37
+ }
38
+
39
+ const sha256Hex = async (input: string): Promise<string> => {
40
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
41
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
42
+ }
43
+
44
+ const outcomeOf = (created: boolean, linked: boolean): 'created' | 'linked' | 'reused' =>
45
+ created ? 'created' : linked ? 'linked' : 'reused'
46
+
47
+ export const handleOAuthCallback = async (c: Context<{ Bindings: Env }>): Promise<Response> => {
48
+ const provider = v.parse(OAuthProviderSchema, c.req.param('provider'))
49
+
50
+ const code = c.req.query('code')
51
+ const stateQ = c.req.query('state')
52
+ const cookieState = getCookie(c, 'pya_oauth_state')
53
+ deleteCookie(c, 'pya_oauth_state', { path: '/api/auth' })
54
+
55
+ if (
56
+ code === undefined ||
57
+ stateQ === undefined ||
58
+ cookieState === undefined ||
59
+ stateQ !== cookieState
60
+ ) {
61
+ throw new UnauthorizedError({ reason: 'invalid state' })
62
+ }
63
+
64
+ const raw = await c.env.OAUTH_STATE.get(`oauth:state:${stateQ}`, { type: 'json' })
65
+ if (raw === null) {
66
+ throw new UnauthorizedError({ reason: 'expired or replayed state' })
67
+ }
68
+ await c.env.OAUTH_STATE.delete(`oauth:state:${stateQ}`)
69
+
70
+ const stateRecord = v.parse(OAuthStateSchema, raw)
71
+ if (stateRecord.provider !== provider) {
72
+ throw new UnauthorizedError({ reason: 'provider mismatch' })
73
+ }
74
+
75
+ const redirectUri = buildRedirectUri(c.env, provider)
76
+ const claims = await exchangeForProvider(c.env, provider, redirectUri, code, stateRecord)
77
+
78
+ const link = await provisionOrLink(
79
+ c.env.DB,
80
+ claims,
81
+ stateRecord.intent ?? 'login',
82
+ stateRecord.currentUserId,
83
+ )
84
+
85
+ const ip = c.req.header('CF-Connecting-IP') ?? ''
86
+ logAuth({
87
+ event: 'auth.login.oauth',
88
+ ts: Math.floor(Date.now() / 1000),
89
+ userId: link.userId,
90
+ provider,
91
+ ipHash: await sha256Hex(ip + (c.env.SESSION_PEPPER ?? '')),
92
+ outcome: outcomeOf(link.created, link.linked),
93
+ })
94
+
95
+ await issueSession(c, setCookie, false, {
96
+ userId: link.userId,
97
+ roles: ['customer'],
98
+ storeIds: [],
99
+ })
100
+
101
+ return c.redirect(stateRecord.redirectAfter, 302)
102
+ }
@@ -0,0 +1,12 @@
1
+ import type { ProviderClaims } from '@pya-platform/shared'
2
+ import { ProviderNotEnabledError } from '@pya-platform/shared'
3
+
4
+ export const exchangeAndVerifyApple = async (
5
+ _env: Env,
6
+ _redirectUri: string,
7
+ _code: string,
8
+ _verifier: string,
9
+ _nonce: string,
10
+ ): Promise<ProviderClaims> => {
11
+ throw new ProviderNotEnabledError({ provider: 'apple' })
12
+ }
@@ -0,0 +1,12 @@
1
+ import type { ProviderClaims } from '@pya-platform/shared'
2
+ import { ProviderNotEnabledError } from '@pya-platform/shared'
3
+
4
+ export const exchangeAndVerifyFacebook = async (
5
+ _env: Env,
6
+ _redirectUri: string,
7
+ _code: string,
8
+ _verifier: string,
9
+ _nonce: string,
10
+ ): Promise<ProviderClaims> => {
11
+ throw new ProviderNotEnabledError({ provider: 'facebook' })
12
+ }
@@ -0,0 +1,117 @@
1
+ import { type ProviderClaims, ProviderClaimsSchema } from '@pya-platform/shared'
2
+ import { InvalidTokenError, UpstreamError } from '@pya-platform/shared'
3
+ import { type JSONWebKeySet, createLocalJWKSet, jwtVerify } from 'jose'
4
+ import * as v from 'valibot'
5
+
6
+ const DEFAULT_TOKEN_URL = 'https://oauth2.googleapis.com/token'
7
+ const DEFAULT_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs'
8
+ const ISSUERS: ReadonlySet<string> = new Set(['accounts.google.com', 'https://accounts.google.com'])
9
+
10
+ interface TokenResponse {
11
+ readonly id_token?: string
12
+ }
13
+
14
+ const buildBody = (
15
+ code: string,
16
+ clientId: string,
17
+ clientSecret: string,
18
+ redirectUri: string,
19
+ verifier: string,
20
+ ): URLSearchParams =>
21
+ new URLSearchParams({
22
+ code,
23
+ client_id: clientId,
24
+ client_secret: clientSecret,
25
+ redirect_uri: redirectUri,
26
+ grant_type: 'authorization_code',
27
+ code_verifier: verifier,
28
+ })
29
+
30
+ interface JwksEntry {
31
+ readonly jwks: JSONWebKeySet
32
+ readonly fetchedAt: number
33
+ }
34
+ const jwksCache = new Map<string, JwksEntry>()
35
+ const JWKS_TTL_MS = 6 * 60 * 60 * 1000
36
+
37
+ const fetchJwks = async (url: string): Promise<JSONWebKeySet> => {
38
+ const cached = jwksCache.get(url)
39
+ if (cached !== undefined && Date.now() - cached.fetchedAt < JWKS_TTL_MS) {
40
+ return cached.jwks
41
+ }
42
+ const res = await fetch(url)
43
+ if (!res.ok) throw new UpstreamError({ provider: 'google', status: res.status })
44
+ const jwks = (await res.json()) as JSONWebKeySet
45
+ jwksCache.set(url, { jwks, fetchedAt: Date.now() })
46
+ return jwks
47
+ }
48
+
49
+ /** Test-only: clear JWKS cache. */
50
+ export const __resetJwksCache = (): void => {
51
+ jwksCache.clear()
52
+ }
53
+
54
+ export const exchangeAndVerifyGoogle = async (
55
+ env: Env,
56
+ redirectUri: string,
57
+ code: string,
58
+ verifier: string,
59
+ nonce: string,
60
+ ): Promise<ProviderClaims> => {
61
+ const clientId = env.GOOGLE_OAUTH_CLIENT_ID ?? ''
62
+ const clientSecret = env.GOOGLE_OAUTH_CLIENT_SECRET ?? ''
63
+ const tokenUrl = env.GOOGLE_TOKEN_URL ?? DEFAULT_TOKEN_URL
64
+ const jwksUrl = env.GOOGLE_JWKS_URL ?? DEFAULT_JWKS_URL
65
+
66
+ const res = await fetch(tokenUrl, {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
69
+ body: buildBody(code, clientId, clientSecret, redirectUri, verifier),
70
+ })
71
+ if (!res.ok) throw new UpstreamError({ provider: 'google', status: res.status })
72
+
73
+ const tokenJson = (await res.json()) as TokenResponse
74
+ const idToken = tokenJson.id_token
75
+ if (idToken === undefined) {
76
+ throw new InvalidTokenError({ reason: 'missing id_token' })
77
+ }
78
+
79
+ const jwksSet = createLocalJWKSet(await fetchJwks(jwksUrl))
80
+ const verified = await verifyOrThrow(idToken, jwksSet, clientId)
81
+ const payload = verified.payload
82
+
83
+ if (typeof payload.iss !== 'string' || !ISSUERS.has(payload.iss)) {
84
+ throw new InvalidTokenError({ reason: 'bad iss' })
85
+ }
86
+ if (payload.nonce !== nonce) {
87
+ throw new InvalidTokenError({ reason: 'nonce mismatch' })
88
+ }
89
+ if (payload.email_verified !== true) {
90
+ throw new InvalidTokenError({ reason: 'email_unverified' })
91
+ }
92
+ if (typeof payload.sub !== 'string' || typeof payload.email !== 'string') {
93
+ throw new InvalidTokenError({ reason: 'missing sub/email' })
94
+ }
95
+
96
+ return v.parse(ProviderClaimsSchema, {
97
+ provider: 'google',
98
+ subject: payload.sub,
99
+ email: payload.email,
100
+ emailVerified: true,
101
+ displayName: typeof payload.name === 'string' ? payload.name : undefined,
102
+ locale: typeof payload.locale === 'string' ? payload.locale : undefined,
103
+ })
104
+ }
105
+
106
+ const verifyOrThrow = async (
107
+ idToken: string,
108
+ jwks: ReturnType<typeof createLocalJWKSet>,
109
+ audience: string,
110
+ ): Promise<Awaited<ReturnType<typeof jwtVerify>>> => {
111
+ try {
112
+ return await jwtVerify(idToken, jwks, { audience })
113
+ } catch (err) {
114
+ const reason = err instanceof Error ? err.message : 'verify failed'
115
+ throw new InvalidTokenError({ reason })
116
+ }
117
+ }