diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8075b6a..feae746 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -53,6 +53,17 @@ deno task -f web build # Production build (vite build → _fresh/) deno task -f web favicon # Regenerate favicon assets ``` +To exercise the real sign-in flows (Google One Tap, OAuth) instead of the +seeded demo user, disable the fallback (Google credentials come from the +`.env` file): + +``` +DISABLE_DEMO_USER=1 deno task -f web dev +``` + +The Google Cloud OAuth client needs `http://localhost:5173` in its +Authorized JavaScript origins for One Tap to appear. + Production builds and serving are handled by Deno Deploy. ## Favicon diff --git a/deno.lock b/deno.lock index c2bb069..ec24ac2 100644 --- a/deno.lock +++ b/deno.lock @@ -6,6 +6,7 @@ "jsr:@cliffy/internal@1.2.1": "1.2.1", "jsr:@cliffy/table@1.2.1": "1.2.1", "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1", + "jsr:@deno/kv-oauth@*": "0.11.0", "jsr:@deno/kv-oauth@0.11": "0.11.0", "jsr:@deno/loader@0.4": "0.4.0", "jsr:@deno/loader@~0.3.10": "0.3.14", @@ -21,6 +22,7 @@ "jsr:@std/datetime@0.221": "0.221.0", "jsr:@std/dotenv@~0.225.5": "0.225.8", "jsr:@std/encoding@0.221": "0.221.0", + "jsr:@std/encoding@1": "1.0.11", "jsr:@std/encoding@^1.0.10": "1.0.11", "jsr:@std/encoding@^1.0.11": "1.0.11", "jsr:@std/fmt@0.221": "0.221.0", @@ -71,15 +73,16 @@ "npm:@lexical/rich-text@0.50": "0.50.0", "npm:@openrouter/agent@0.11": "0.11.0", "npm:@opentelemetry/api@^1.9.0": "1.9.1", - "npm:@preact/signals@^2.11.2": "2.11.2_preact@10.29.8_preact-render-to-string@6.7.0", - "npm:@preact/signals@^2.5.1": "2.11.2_preact@10.29.8_preact-render-to-string@6.7.0", - "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.8_vite@7.3.6_preact-render-to-string@6.7.0", + "npm:@preact/signals@^2.11.2": "2.11.2_preact@10.29.8", + "npm:@preact/signals@^2.5.1": "2.11.2_preact@10.29.8", + "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.8_vite@7.3.6", "npm:@remix-run/node-fetch-server@0.12": "0.12.0", "npm:@resvg/resvg-wasm@2.6.2": "2.6.2", "npm:@tailwindcss/typography@~0.5.20": "0.5.20_tailwindcss@4.3.3", "npm:@tailwindcss/vite@^4.3.3": "4.3.3_vite@7.3.6_@types+node@26.5.1", "npm:@types/babel__core@^7.20.5": "7.20.5", "npm:@types/gapi@^0.0.47": "0.0.47", + "npm:@types/google.accounts@^0.0.18": "0.0.18", "npm:@types/google.picker@^0.0.52": "0.0.52", "npm:@types/node@^26.5.1": "26.5.1", "npm:culori@^4.0.2": "4.0.2", @@ -89,6 +92,7 @@ "npm:esbuild@0.25.7": "0.25.7", "npm:esbuild@~0.25.5": "0.25.12", "npm:husky@^9.1.7": "9.1.7", + "npm:jose@6": "6.2.12", "npm:lexical@0.50": "0.50.0", "npm:lucide-preact@^1.44.0": "1.45.0_preact@10.29.8_preact-render-to-string@6.7.0", "npm:marked@^18.0.12": "18.0.13", @@ -1316,17 +1320,20 @@ "@preact/signals-core@1.14.4": { "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==" }, - "@preact/signals@2.11.2_preact@10.29.8_preact-render-to-string@6.7.0": { + "@preact/signals@2.11.2_preact@10.29.8": { "integrity": "sha512-rVTRTt/T0HIRgbugwS5FigbfF/kfdEFYFtiqxa+lbpqTajepqnR0firuAS2iNdHyejWB7Yc9p9QePkVNBtTAwg==", "dependencies": [ "@preact/signals-core", "preact" ] }, - "@prefresh/babel-plugin@0.5.3": { - "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==" + "@prefresh/babel-plugin@0.5.4_@babel+core@7.29.7": { + "integrity": "sha512-GSwqHGVem7AjnOKAkmLNbAExqZZFddf9S3ySOwowGEsJR8vwNKJ7Z/262fov5wra8LkDVSmFw3CjMGRdLoCmRA==", + "dependencies": [ + "@babel/core" + ] }, - "@prefresh/core@1.5.11_preact@10.29.8_preact-render-to-string@6.7.0": { + "@prefresh/core@1.5.11_preact@10.29.8": { "integrity": "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ==", "dependencies": [ "preact" @@ -1335,7 +1342,7 @@ "@prefresh/utils@1.2.1": { "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==" }, - "@prefresh/vite@2.4.12_preact@10.29.8_vite@7.3.6_preact-render-to-string@6.7.0": { + "@prefresh/vite@2.4.12_preact@10.29.8_vite@7.3.6": { "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", "dependencies": [ "@babel/core", @@ -1624,6 +1631,9 @@ "@types/gapi@0.0.47": { "integrity": "sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==" }, + "@types/google.accounts@0.0.18": { + "integrity": "sha512-yHaPznll97ZnMJlPABHyeiIlLn3u6gQaUjA5k/O9lrrpgFB9VT10CKPLuKM0qTHMl50uXpW5sIcG+utm8jMOHw==" + }, "@types/google.picker@0.0.52": { "integrity": "sha512-k0HyW8HxJePomM2r0JWq9nE9XG6qY93lVpoVnaV4WjQggDHrGwDKq3G8CGpcBWhQlJBTxX9jDIrI7RQnqjM63w==" }, @@ -1886,6 +1896,9 @@ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "bin": true }, + "jose@6.2.12": { + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==" + }, "joycon@3.1.1": { "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==" }, @@ -2324,6 +2337,8 @@ "jsr:@fresh/core@^2.3.3", "jsr:@fresh/plugin-vite@^1.1.2", "jsr:@std/assert@^1.0.19", + "jsr:@std/encoding@1", + "jsr:@std/http@0.221", "jsr:@std/text@^1.0.19", "npm:@biomejs/biome@^2.5.13", "npm:@fontsource-variable/atkinson-hyperlegible-next@^5.3.0", @@ -2348,11 +2363,13 @@ "npm:@tailwindcss/vite@^4.3.3", "npm:@types/babel__core@^7.20.5", "npm:@types/gapi@^0.0.47", + "npm:@types/google.accounts@^0.0.18", "npm:@types/google.picker@^0.0.52", "npm:@types/node@^26.5.1", "npm:culori@^4.0.2", "npm:date-fns@^4.4.0", "npm:dompurify@^3.4.15", + "npm:jose@6", "npm:lexical@0.50", "npm:lucide-preact@^1.44.0", "npm:marked@^18.0.12", diff --git a/packages/web/deno.jsonc b/packages/web/deno.jsonc index 9291224..10bdcac 100644 --- a/packages/web/deno.jsonc +++ b/packages/web/deno.jsonc @@ -9,7 +9,12 @@ "deno.ns", "deno.unstable" ], - "types": ["vite/client", "@types/gapi", "@types/google.picker"] + "types": [ + "vite/client", + "@types/gapi", + "@types/google.picker", + "@types/google.accounts" + ] }, "exclude": ["**/_fresh/*"], "exports": "./main.ts", @@ -38,17 +43,21 @@ "@preact/signals": "npm:@preact/signals@^2.11.2", "@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@2.6.2", "@std/assert": "jsr:@std/assert@^1.0.19", + "@std/encoding": "jsr:@std/encoding@^1.0.0", + "@std/http": "jsr:@std/http@^0.221.0", "@std/text": "jsr:@std/text@^1.0.19", "@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.20", "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.3.3", "@types/babel__core": "npm:@types/babel__core@^7.20.5", "@types/gapi": "npm:@types/gapi@^0.0.47", + "@types/google.accounts": "npm:@types/google.accounts@^0.0.18", "@types/google.picker": "npm:@types/google.picker@^0.0.52", "@types/node": "npm:@types/node@^26.5.1", "culori": "npm:culori@^4.0.2", "date-fns": "npm:date-fns@^4.4.0", "dompurify": "npm:dompurify@^3.4.15", "fresh": "jsr:@fresh/core@^2.3.3", + "jose": "npm:jose@^6.0.0", "lexical": "npm:lexical@^0.50.0", "lucide-preact": "npm:lucide-preact@^1.44.0", "marked": "npm:marked@^18.0.12", @@ -69,7 +78,7 @@ "tasks": { "build": "vite build", "check": "deno run -A npm:@biomejs/biome check && deno lint . && deno check", - "dev": "DENO_ENV=development vite | pino-pretty --colorize --translateTime SYS:standard --ignore pid,hostname", + "dev": "DENO_ENV=development DENO_KV_PATH=./local-kv.sqlite3 vite | pino-pretty --colorize --translateTime SYS:standard --ignore pid,hostname", "favicon": "deno run -A favicon/generate.ts", "favicon:check": "deno run -A favicon/generate.ts --check", "kvctl": "deno run -A --env-file=.env kvctl.ts", diff --git a/packages/web/islands/GoogleOneTap.tsx b/packages/web/islands/GoogleOneTap.tsx new file mode 100644 index 0000000..8a40995 --- /dev/null +++ b/packages/web/islands/GoogleOneTap.tsx @@ -0,0 +1,80 @@ +import { ArrowUpRight } from "lucide-preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { loadGsi } from "@/utils/googleGsi.ts"; + +interface GoogleOneTapProps { + clientId: string; + next: string; +} + +/** + * Google sign-in for the login page: the native "Sign in with Google" + * button plus the One Tap prompt (FedCM) for eligible visitors. Both share + * one callback that posts the ID token credential to /oauth/onetap, which + * sets the session cookie and redirects to `next`. If GIS fails to load, + * falls back to the full OAuth redirect link. + */ +export default function GoogleOneTap({ clientId, next }: GoogleOneTapProps) { + const formRef = useRef(null); + const credentialRef = useRef(null); + const buttonRef = useRef(null); + const [gsiFailed, setGsiFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + loadGsi() + .then(() => { + if (cancelled || !buttonRef.current) return; + google.accounts.id.initialize({ + client_id: clientId, + auto_select: true, + use_fedcm_for_prompt: true, + callback: (response) => { + if (!response.credential || !credentialRef.current) return; + credentialRef.current.value = response.credential; + formRef.current?.submit(); + }, + }); + // Arriving from /oauth/signout: clear GSI's remembered auto selection + // so the user is not silently signed back in. + if (new URLSearchParams(globalThis.location.search).has("signedout")) { + google.accounts.id.disableAutoSelect(); + } + google.accounts.id.renderButton(buttonRef.current, { + type: "standard", + theme: "filled_blue", + size: "large", + text: "signin_with", + shape: "rectangular", + logo_alignment: "left", + }); + google.accounts.id.prompt(); + }) + .catch(() => { + if (!cancelled) setGsiFailed(true); + }); + return () => { + cancelled = true; + }; + }, [clientId]); + + return ( +
+ {gsiFailed ? ( + + + Sign in with Google + + ) : ( +
+ )} + +
+ ); +} diff --git a/packages/web/islands/Navigation.tsx b/packages/web/islands/Navigation.tsx index 72b948e..09a8e37 100644 --- a/packages/web/islands/Navigation.tsx +++ b/packages/web/islands/Navigation.tsx @@ -12,8 +12,8 @@ interface NavigationProps { export default function Navigation({ user, children }: NavigationProps) { return (
- -
+
+
{children}
diff --git a/packages/web/middleware/auth.ts b/packages/web/middleware/auth.ts index a6e3710..f0233d0 100644 --- a/packages/web/middleware/auth.ts +++ b/packages/web/middleware/auth.ts @@ -5,6 +5,7 @@ import { getOAuthHelpers } from "@/utils/oauth.ts"; import { getUserIdForSession } from "@/utils/sessions.ts"; const isDev = Deno.env.get("DENO_ENV") === "development"; +const demoUserDisabled = Deno.env.get("DISABLE_DEMO_USER") === "1"; /** * Resolves `ctx.state.user` for each request. @@ -12,9 +13,9 @@ const isDev = Deno.env.get("DENO_ENV") === "development"; * Resolution order: * 1. `X-User-Id` header, dev only -- lets local scripts/tests act as a * seeded user; disabled in production. - * 2. Valid Google OAuth session cookie (see routes/oauth/*). - * 3. The seeded demo user, in dev only. - * 4. Otherwise unauthenticated: API routes get 401 JSON, browser routes + * 2. Valid Google OAuth session cookie (see routes/oauth/*). + * 3. The seeded demo user, in dev only (skip with DISABLE_DEMO_USER=1). + * 4. Otherwise unauthenticated: API routes get 401 JSON, browser routes * redirect to /login. * * The /oauth/* routes and /login page are skipped so sign-in / sign-out / @@ -60,7 +61,7 @@ const authMiddleware: Middleware = define.middleware(async (ctx) => { // Fall through to the dev demo-user fallback below. } - if (isDev && demoUser) { + if (isDev && demoUser && !demoUserDisabled) { ctx.state.user = demoUser; return ctx.next(); } diff --git a/packages/web/routes/login.tsx b/packages/web/routes/login.tsx index 584cad6..2aaf1d1 100644 --- a/packages/web/routes/login.tsx +++ b/packages/web/routes/login.tsx @@ -1,38 +1,35 @@ import type { PageProps } from "fresh"; -import { ArrowUpRight } from "lucide-preact"; +import GoogleOneTap from "@/islands/GoogleOneTap.tsx"; +import Navigation from "@/islands/Navigation.tsx"; +import { safeNext } from "@/utils/nextUrl.ts"; -/** - * Returns a safe same-origin path to redirect to after sign-in, or `/` if the - * given value is missing or unsafe. Rejects protocol-relative URLs (`//...`), - * the login page itself, and OAuth routes (avoids post-login redirect loops). - */ -function safeNext(next: string | null): string { - if (!next?.startsWith("/") || next.startsWith("//")) return "/"; - if (next === "/login" || next.startsWith("/oauth/")) return "/"; - return next; -} +const clientId = Deno.env.get("GOOGLE_CLIENT_ID"); /** * Sign-in landing page. Shown to unauthenticated browser users (the auth - * middleware redirects them here with a `next` query param). The button starts - * the Google OAuth flow and passes `next` as `success_url` so - * `@deno/kv-oauth` sends the user back to the page they originally requested - * instead of falling back to the `/login` referer. + * middleware redirects them here with a `next` query param). The Google + * island handles both the native button and the One Tap prompt; its + * credential callback posts to /oauth/onetap with `next` so the user lands + * back on the page they originally requested. */ export default function LoginPage({ url }: PageProps) { const next = safeNext(url.searchParams.get("next")); - const href = `/oauth/signin?success_url=${encodeURIComponent(next)}`; return ( -
-
-

- Sign in with your Google account to continue. -

- - - Sign in with Google - -
-
+
+
+ +
+
Sign in to Essayist
+
+
+
+
+
+ {clientId && } +
+
+
+
+
); } diff --git a/packages/web/routes/oauth/callback.ts b/packages/web/routes/oauth/callback.ts index 8a7b01e..437f301 100644 --- a/packages/web/routes/oauth/callback.ts +++ b/packages/web/routes/oauth/callback.ts @@ -1,6 +1,5 @@ -import { UserEmailTakenError } from "@essayist/core"; import { define } from "@/define.ts"; -import { workspaceStore } from "@/store.ts"; +import { resolveGoogleUser } from "@/utils/googleUser.ts"; import { getGoogleUserInfo, getOAuthHelpers } from "@/utils/oauth.ts"; import { createSession, @@ -21,31 +20,7 @@ export const handler = define.handlers(async (ctx) => { const { response, sessionId, tokens } = await helpers.handleCallback(ctx.req); const info = await getGoogleUserInfo(tokens.accessToken); - let user = await workspaceStore.getUserByEmail(info.email); - if (!user) { - try { - user = await workspaceStore.createUser(info); - } catch (error) { - // Race: another concurrent login created the same email first. - if (error instanceof UserEmailTakenError) { - user = await workspaceStore.getUserByEmail(info.email); - } else { - throw error; - } - } - } - if (!user) { - throw new Error(`failed to resolve user for ${info.email}`); - } - - // Refresh name/picture from Google on each login (they can change). - if (info.name !== user.name || info.picture !== user.picture) { - const updated = await workspaceStore.updateUser(user.id, { - name: info.name, - picture: info.picture, - }); - if (updated) user = updated; - } + const user = await resolveGoogleUser(info); // Persist the OAuth tokens onto the app session so server-side Google API // calls (Drive export) can re-use the grant; see utils/googleToken.ts. diff --git a/packages/web/routes/oauth/onetap.ts b/packages/web/routes/oauth/onetap.ts new file mode 100644 index 0000000..e680fa6 --- /dev/null +++ b/packages/web/routes/oauth/onetap.ts @@ -0,0 +1,69 @@ +import { define } from "@/define.ts"; +import type { GoogleIdTokenClaims } from "@/utils/googleIdToken.ts"; +import { verifyGoogleIdToken } from "@/utils/googleIdToken.ts"; +import { refreshTokensForUser } from "@/utils/googleToken.ts"; +import { resolveGoogleUser } from "@/utils/googleUser.ts"; +import { safeNext } from "@/utils/nextUrl.ts"; +import { createSession, createSiteSession } from "@/utils/sessions.ts"; + +/** + * Google One Tap credential exchange. The GIS client posts the ID token JWT + * here (see islands/GoogleOneTap.tsx); we verify it, resolve the user, and + * issue a kv-oauth-compatible session cookie so the auth middleware accepts + * it like any OAuth sign-in. Like the other /oauth/* routes, this is skipped + * by the auth middleware. + */ +export const handler = define.handlers({ + async POST(ctx) { + // Form POSTs from the island are same-origin; reject anything else. + const origin = ctx.req.headers.get("origin"); + if (origin !== new URL(ctx.req.url).origin) { + return new Response("Cross-origin sign-in rejected", { status: 403 }); + } + + const clientId = Deno.env.get("GOOGLE_CLIENT_ID"); + if (!clientId) { + return new Response( + "Google One Tap is not configured: set GOOGLE_CLIENT_ID.", + { status: 500 }, + ); + } + + const form = await ctx.req.formData(); + const credential = form.get("credential"); + const next = form.get("next"); + if (typeof credential !== "string" || credential === "") { + return new Response("Missing credential", { status: 400 }); + } + + let claims: GoogleIdTokenClaims; + try { + claims = await verifyGoogleIdToken(credential, { clientId }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(`Invalid Google credential: ${message}`, { + status: 401, + }); + } + + const user = await resolveGoogleUser({ + email: claims.email, + name: claims.name, + picture: claims.picture, + }); + + // One Tap yields an ID token only, no OAuth tokens. Refreshing with the + // stored user-level refresh token keeps Drive features working for + // returning users; brand-new users get a tokenless session and re-auth + // on first Drive use (see utils/googleToken.ts). + const tokens = await refreshTokensForUser(user.id); + const sessionId = crypto.randomUUID(); + await createSession(sessionId, user.id, tokens ?? undefined); + + const response = new Response(null, { + status: 303, + headers: { location: safeNext(typeof next === "string" ? next : null) }, + }); + return await createSiteSession(ctx.req, response, sessionId); + }, +}); diff --git a/packages/web/routes/oauth/signout.ts b/packages/web/routes/oauth/signout.ts index 4aec6c6..d854402 100644 --- a/packages/web/routes/oauth/signout.ts +++ b/packages/web/routes/oauth/signout.ts @@ -8,5 +8,10 @@ export const handler = define.handlers(async (ctx) => { if (sessionId) { await deleteSession(sessionId); } - return await helpers.signOut(ctx.req); + const response = await helpers.signOut(ctx.req); + // Land on the login page with a marker so the GoogleOneTap island clears + // GSI's remembered auto selection (otherwise the user would be silently + // signed back in on the next visit). + response.headers.set("location", "/login?signedout=1"); + return response; }); diff --git a/packages/web/utils/googleGsi.ts b/packages/web/utils/googleGsi.ts new file mode 100644 index 0000000..0132ae0 --- /dev/null +++ b/packages/web/utils/googleGsi.ts @@ -0,0 +1,14 @@ +// Google Identity Services (GIS) client loader. + +import { loadScript } from "@/utils/loadScript.ts"; + +const GSI_SRC = "https://accounts.google.com/gsi/client"; + +function loadGsi(): Promise { + return loadScript( + GSI_SRC, + () => typeof google !== "undefined" && !!google.accounts?.id, + ); +} + +export { loadGsi }; diff --git a/packages/web/utils/googleIdToken.ts b/packages/web/utils/googleIdToken.ts new file mode 100644 index 0000000..d1eb503 --- /dev/null +++ b/packages/web/utils/googleIdToken.ts @@ -0,0 +1,94 @@ +/** + * Verification of Google ID tokens (JWTs) issued by Google Identity Services + * (One Tap). jose verifies the RS256 signature against Google's published + * JWK set (fetched and cached from the JWKS URL) plus the standard aud/iss/ + * exp claims; we add the email checks and map the claims we need. + * https://developers.google.com/identity/gsi/web/guides/verify-google-id-token + */ + +import { + createRemoteJWKSet, + type JWTHeaderParameters, + type JWTPayload, + jwtVerify, +} from "jose"; + +const JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"; +// Allow for client/server clock drift around exp/nbf. +const CLOCK_TOLERANCE_SECONDS = 60; + +// Google issues ID tokens from both issuer spellings. +const ISSUERS = ["accounts.google.com", "https://accounts.google.com"]; + +export class GoogleIdTokenError extends Error {} + +export interface GoogleIdTokenClaims { + /** Stable Google account id. */ + sub: string; + email: string; + name?: string; + picture?: string; +} + +export interface VerifyGoogleIdTokenOptions { + /** OAuth2 client id expected in the token's `aud` claim. */ + clientId: string; + /** + * Inject for tests; resolves the signing key for the token's header. + * Defaults to jose's remote JWK set for Google. + */ + getKey?: (header: JWTHeaderParameters) => Promise; + /** Inject for tests; defaults to Date.now(). */ + now?: () => number; +} + +let remoteJwks: ReturnType | undefined; + +function googleJwks(): ReturnType { + remoteJwks ??= createRemoteJWKSet(new URL(JWKS_URL)); + return remoteJwks; +} + +export async function verifyGoogleIdToken( + idToken: string, + options: VerifyGoogleIdTokenOptions, +): Promise { + let payload: JWTPayload; + try { + ({ payload } = await jwtVerify(idToken, options.getKey ?? googleJwks(), { + algorithms: ["RS256"], + audience: options.clientId, + issuer: ISSUERS, + clockTolerance: CLOCK_TOLERANCE_SECONDS, + currentDate: new Date(options.now?.() ?? Date.now()), + requiredClaims: ["exp", "sub", "email"], + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new GoogleIdTokenError(message, { cause: error }); + } + + const { sub, email } = payload; + if (typeof sub !== "string" || sub === "") { + throw new GoogleIdTokenError("token has no subject"); + } + if (typeof email !== "string" || email === "") { + throw new GoogleIdTokenError("token has no email"); + } + if (payload.email_verified === false) { + throw new GoogleIdTokenError("token email is not verified"); + } + + return { + sub, + email, + name: + typeof payload.name === "string" && payload.name !== "" + ? payload.name + : undefined, + picture: + typeof payload.picture === "string" && payload.picture !== "" + ? payload.picture + : undefined, + }; +} diff --git a/packages/web/utils/googleIdToken_test.ts b/packages/web/utils/googleIdToken_test.ts new file mode 100644 index 0000000..2e92302 --- /dev/null +++ b/packages/web/utils/googleIdToken_test.ts @@ -0,0 +1,186 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { encodeBase64Url } from "@std/encoding/base64url"; +import type { JWTHeaderParameters } from "jose"; +import { + GoogleIdTokenError, + verifyGoogleIdToken, +} from "@/utils/googleIdToken.ts"; + +const CLIENT_ID = "test-client-id"; +const NOW = 1_700_000_000_000; + +const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], +); +const publicJwk = { + ...(await crypto.subtle.exportKey("jwk", keyPair.publicKey)), + kid: "test-key", +}; +const publicKey = await crypto.subtle.importKey( + "jwk", + publicJwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], +); + +// Mimics jose's remote JWKS lookup: reject unknown kids, serve the test key. +const getKey = (header: JWTHeaderParameters): Promise => { + if (header.kid !== "test-key") { + return Promise.reject(new GoogleIdTokenError("unknown token key id")); + } + return Promise.resolve(publicKey); +}; + +const encodeBase64UrlJson = (value: unknown) => + encodeBase64Url(new TextEncoder().encode(JSON.stringify(value))); + +async function signIdToken( + payload: Record, + kid = "test-key", +): Promise { + const signedPart = `${encodeBase64UrlJson({ alg: "RS256", kid })}.${encodeBase64UrlJson(payload)}`; + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + keyPair.privateKey, + new TextEncoder().encode(signedPart), + ); + return `${signedPart}.${encodeBase64Url(new Uint8Array(signature))}`; +} + +function claims( + overrides: Record = {}, +): Record { + return { + iss: "https://accounts.google.com", + aud: CLIENT_ID, + sub: "1234567890", + email: "dima@example.com", + email_verified: true, + name: "Dima", + picture: "https://example.com/dima.png", + iat: NOW / 1000, + exp: NOW / 1000 + 300, + ...overrides, + }; +} + +async function verify(payload: Record) { + const idToken = await signIdToken(payload); + return await verifyGoogleIdToken(idToken, { + clientId: CLIENT_ID, + getKey, + now: () => NOW, + }); +} + +Deno.test("verifyGoogleIdToken -- accepts a valid token", async () => { + assertEquals(await verify(claims()), { + sub: "1234567890", + email: "dima@example.com", + name: "Dima", + picture: "https://example.com/dima.png", + }); +}); + +Deno.test("verifyGoogleIdToken -- accepts the bare issuer spelling", async () => { + const result = await verify(claims({ iss: "accounts.google.com" })); + assertEquals(result.email, "dima@example.com"); +}); + +Deno.test("verifyGoogleIdToken -- omits empty name and picture", async () => { + const result = await verify(claims({ name: "", picture: "" })); + assertEquals(result.name, undefined); + assertEquals(result.picture, undefined); +}); + +Deno.test("verifyGoogleIdToken -- rejects a foreign audience", async () => { + await assertRejects( + () => verify(claims({ aud: "someone-elses-client" })), + GoogleIdTokenError, + "aud", + ); +}); + +Deno.test("verifyGoogleIdToken -- rejects an expired token", async () => { + await assertRejects( + () => verify(claims({ exp: NOW / 1000 - 120 })), + GoogleIdTokenError, + "exp", + ); +}); + +Deno.test("verifyGoogleIdToken -- rejects a not-yet-valid token", async () => { + await assertRejects( + () => verify(claims({ nbf: NOW / 1000 + 120 })), + GoogleIdTokenError, + "nbf", + ); +}); + +Deno.test("verifyGoogleIdToken -- rejects an unknown issuer", async () => { + await assertRejects( + () => verify(claims({ iss: "https://evil.example" })), + GoogleIdTokenError, + "iss", + ); +}); + +Deno.test("verifyGoogleIdToken -- rejects an unverified email", async () => { + await assertRejects( + () => verify(claims({ email_verified: false })), + GoogleIdTokenError, + "not verified", + ); +}); + +Deno.test("verifyGoogleIdToken -- rejects a tampered signature", async () => { + const idToken = await signIdToken(claims()); + const [header, payload, signature] = idToken.split("."); + const tampered = + signature.slice(0, -1) + (signature.endsWith("A") ? "B" : "A"); + await assertRejects( + () => + verifyGoogleIdToken(`${header}.${payload}.${tampered}`, { + clientId: CLIENT_ID, + getKey, + now: () => NOW, + }), + GoogleIdTokenError, + "signature", + ); +}); + +Deno.test("verifyGoogleIdToken -- rejects an unknown key id", async () => { + const idToken = await signIdToken(claims(), "rotated-key"); + await assertRejects( + () => + verifyGoogleIdToken(idToken, { + clientId: CLIENT_ID, + getKey, + now: () => NOW, + }), + GoogleIdTokenError, + "key id", + ); +}); + +Deno.test("verifyGoogleIdToken -- rejects a malformed token", async () => { + await assertRejects( + () => + verifyGoogleIdToken("not-a-jwt", { + clientId: CLIENT_ID, + getKey, + now: () => NOW, + }), + GoogleIdTokenError, + "Compact JWS", + ); +}); diff --git a/packages/web/utils/googlePicker.ts b/packages/web/utils/googlePicker.ts index 5f89907..0ea1b22 100644 --- a/packages/web/utils/googlePicker.ts +++ b/packages/web/utils/googlePicker.ts @@ -1,6 +1,8 @@ // Google Picker client wrapper: opens the picker and resolves with the // selected documents. +import { loadScript } from "@/utils/loadScript.ts"; + export interface PickerDoc { id: string; name: string; @@ -12,28 +14,10 @@ export interface PickerConfig { appId?: string; } -let gapiPromise: Promise | undefined; +const GAPI_SRC = "https://apis.google.com/js/api.js"; -async function loadGapi(): Promise { - if (gapiPromise) return gapiPromise; - gapiPromise = new Promise((resolve, reject) => { - if (typeof gapi !== "undefined") { - resolve(); - return; - } - const script = document.querySelector( - 'script[src="https://apis.google.com/js/api.js"]', - ); - if (!script) { - reject(new Error("Google Picker script not on this page")); - return; - } - script.addEventListener("load", () => resolve()); - script.addEventListener("error", () => - reject(new Error("Failed to load Google Picker script")), - ); - }); - await gapiPromise; +function loadGapi(): Promise { + return loadScript(GAPI_SRC, () => typeof gapi !== "undefined"); } export async function openGooglePicker( diff --git a/packages/web/utils/googleToken.ts b/packages/web/utils/googleToken.ts index 4b83a9d..cb225b8 100644 --- a/packages/web/utils/googleToken.ts +++ b/packages/web/utils/googleToken.ts @@ -35,7 +35,7 @@ export async function getValidAccessToken( session.tokens.refreshToken ?? (await getUserRefreshToken(session.userId)); if (!refreshToken) return accessToken; // can't refresh; let Google reject it - const refreshed = await refreshWithGoogle(refreshToken); + const refreshed = await refreshTokens(refreshToken); await updateSessionTokens(sessionId, refreshed); if (refreshed.refreshToken) { await setUserRefreshToken(session.userId, refreshed.refreshToken); @@ -43,7 +43,25 @@ export async function getValidAccessToken( return refreshed.accessToken; } -async function refreshWithGoogle(refreshToken: string): Promise { +/** + * Returns fresh session tokens refreshed with the user's stored refresh + * token, or `null` when none is stored (One Tap sign-ins have no tokens of + * their own) or when Google rejects the stored token (revoked). On failure + * the sign-in still succeeds, just without Google API access. + */ +export async function refreshTokensForUser( + userId: string, +): Promise { + const refreshToken = await getUserRefreshToken(userId); + if (!refreshToken) return null; + try { + return await refreshTokens(refreshToken); + } catch { + return null; + } +} + +async function refreshTokens(refreshToken: string): Promise { const body = new URLSearchParams({ client_id: Deno.env.get("GOOGLE_CLIENT_ID") ?? "", client_secret: Deno.env.get("GOOGLE_CLIENT_SECRET") ?? "", diff --git a/packages/web/utils/googleUser.ts b/packages/web/utils/googleUser.ts new file mode 100644 index 0000000..1fb7f6c --- /dev/null +++ b/packages/web/utils/googleUser.ts @@ -0,0 +1,34 @@ +import { type User, UserEmailTakenError, type UserInput } from "@essayist/core"; +import { workspaceStore } from "@/store.ts"; + +/** + * Finds or creates the app user for a Google profile and refreshes the + * stored name/picture when they changed. + */ +export async function resolveGoogleUser(info: UserInput): Promise { + let user = await workspaceStore.getUserByEmail(info.email); + if (!user) { + try { + user = await workspaceStore.createUser(info); + } catch (error) { + // Race: another concurrent login created the same email first. + if (error instanceof UserEmailTakenError) { + user = await workspaceStore.getUserByEmail(info.email); + } else { + throw error; + } + } + } + if (!user) { + throw new Error(`failed to resolve user for ${info.email}`); + } + + if (info.name !== user.name || info.picture !== user.picture) { + const updated = await workspaceStore.updateUser(user.id, { + name: info.name, + picture: info.picture, + }); + if (updated) user = updated; + } + return user; +} diff --git a/packages/web/utils/loadScript.ts b/packages/web/utils/loadScript.ts new file mode 100644 index 0000000..a27d023 --- /dev/null +++ b/packages/web/utils/loadScript.ts @@ -0,0 +1,37 @@ +/** + * Waits for an external script to be available, injecting a tag when the + * page has not already rendered one. Resolves immediately when the global + * the script defines is already present (e.g. island remount). Concurrent + * calls for the same src share one promise; a failed load clears the cache + * so a later call can retry. + */ +const pending = new Map>(); + +export function loadScript(src: string, isReady: () => boolean): Promise { + const cached = pending.get(src); + if (cached) return cached; + + const promise = new Promise((resolve, reject) => { + if (isReady()) { + resolve(); + return; + } + let script = document.querySelector( + `script[src="${src}"]`, + ); + if (!script) { + script = document.createElement("script"); + script.src = src; + script.async = true; + script.defer = true; + document.head.append(script); + } + script.addEventListener("load", () => resolve()); + script.addEventListener("error", () => { + pending.delete(src); + reject(new Error(`Failed to load script ${src}`)); + }); + }); + pending.set(src, promise); + return promise; +} diff --git a/packages/web/utils/nextUrl.ts b/packages/web/utils/nextUrl.ts new file mode 100644 index 0000000..02d6ee7 --- /dev/null +++ b/packages/web/utils/nextUrl.ts @@ -0,0 +1,10 @@ +/** + * Returns a safe same-origin path to redirect to after sign-in, or `/` if the + * given value is missing or unsafe. Rejects protocol-relative URLs (`//...`), + * the login page itself, and OAuth routes (avoids post-login redirect loops). + */ +export function safeNext(next: string | null): string { + if (!next?.startsWith("/") || next.startsWith("//")) return "/"; + if (next === "/login" || next.startsWith("/oauth/")) return "/"; + return next; +} diff --git a/packages/web/utils/sessions.ts b/packages/web/utils/sessions.ts index 8fc63ea..23d6985 100644 --- a/packages/web/utils/sessions.ts +++ b/packages/web/utils/sessions.ts @@ -1,4 +1,5 @@ import type { Tokens } from "@deno/kv-oauth"; +import { setCookie } from "@std/http/cookie"; import { kv } from "@/store.ts"; // Maps the kv-oauth session id (from the signed cookie) to an app user id and @@ -75,6 +76,43 @@ export async function deleteSession(sessionId: string): Promise { await kv.delete([APP_SESSIONS, sessionId]); } +// One Tap sign-in creates sessions outside the kv-oauth flow, so we +// replicate kv-oauth 0.11's site session here: a `site-session` cookie +// (prefixed `__Host-` on https) plus a `site_sessions` KV row that its +// `getSessionId` validates against. kv-oauth must read the same KV: the dev +// task sets DENO_KV_PATH to our sqlite file; on Deno Deploy both open the +// platform KV. +const SITE_SESSION_COOKIE = "site-session"; +// kv-oauth's cookie maxAge is in seconds and backs the KV row with the same +// TTL; derive both from our session TTL. +const SITE_SESSION_MAX_AGE_SECONDS = SESSION_TTL_MS / 1000; + +/** + * Sets a kv-oauth-compatible site session cookie on the response and writes + * the backing KV row, so the auth middleware resolves it like any OAuth + * sign-in session. + */ +export async function createSiteSession( + request: Request, + response: Response, + sessionId: string, +): Promise { + const isHttps = new URL(request.url).protocol === "https:"; + setCookie(response.headers, { + name: isHttps ? `__Host-${SITE_SESSION_COOKIE}` : SITE_SESSION_COOKIE, + value: sessionId, + path: "/", + httpOnly: true, + secure: isHttps, + maxAge: SITE_SESSION_MAX_AGE_SECONDS, + sameSite: "Lax", + }); + await kv.set(["site_sessions", sessionId], true, { + expireIn: SESSION_TTL_MS, + }); + return response; +} + export async function setUserRefreshToken( userId: string, refreshToken: string,