From 3f9af56060356f0169d246631ab69f7b18b5699e Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 20 Jun 2026 20:16:20 +1000 Subject: [PATCH] add sign in --- README.md | 19 ++ src/components/ProviderButtons.tsx | 9 - tests/api.oauth.test.ts | 239 +++++++++++++++++++++++++ worker/env.ts | 2 + worker/index.ts | 5 + worker/routes/api.oauth.ts | 276 +++++++++++++++++++++++++++++ 6 files changed, 541 insertions(+), 9 deletions(-) create mode 100644 tests/api.oauth.test.ts create mode 100644 worker/routes/api.oauth.ts diff --git a/README.md b/README.md index bff2b26..56f03d8 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,25 @@ shortlink auth works out of the box — no extra configuration needed. Prod works the same way: `heygo.cc` and `my.heygo.cc` share `.heygo.cc`. +### Google sign-in + +Create a Google OAuth web client and add these authorized redirect URIs: + +- Dev: `https://dev.heygo.cc/api/auth/google/callback` +- Prod: `https://heygo.cc/api/auth/google/callback` + +Then set the Worker secrets for the target environment: + +```bash +npx wrangler secret put GOOGLE_CLIENT_ID --config wrangler.dev.jsonc +npx wrangler secret put GOOGLE_CLIENT_SECRET --config wrangler.dev.jsonc +``` + +Repeat with `wrangler.prod.jsonc` for production. The Google sign-in flow uses +the `openid email profile` scopes, stores the Google account ID in +`oauth_accounts`, creates a `heygo_session` cookie, and promotes matching +`ADMIN_EMAILS` users to admin. + ### All deployment commands | Command | What it does | diff --git a/src/components/ProviderButtons.tsx b/src/components/ProviderButtons.tsx index 8f5186d..89d5e4e 100644 --- a/src/components/ProviderButtons.tsx +++ b/src/components/ProviderButtons.tsx @@ -1,8 +1,3 @@ -// Login provider buttons. OAuth callback handlers are not implemented yet -// (Task 10 scope is UI only); the buttons are real anchor links to the -// provider auth start endpoints so they light up once the OAuth flow lands. -// They are not fake/JS auth — clicking performs a full-page navigation. - interface ProviderInfo { readonly id: string; readonly label: string; @@ -32,10 +27,6 @@ export default function ProviderButtons({ disabled = false }: ProviderButtonsPro {provider.label} ))} -

- OAuth sign-in is not wired up yet. These buttons start the provider flow once the - callback handlers ship. -

); } diff --git a/tests/api.oauth.test.ts b/tests/api.oauth.test.ts new file mode 100644 index 0000000..fcaa9e3 --- /dev/null +++ b/tests/api.oauth.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import worker from '../worker/index'; +import { AUTH_SESSION_COOKIE_NAME } from '../worker/auth'; +import type { Env } from '../worker/env'; + +type UserRow = { + id: string; + email: string; + name: string | null; + image_url: string | null; + role: 'user' | 'admin'; +}; + +type OAuthAccountRow = { + id: string; + user_id: string; + provider: string; + provider_account_id: string; +}; + +type SessionRow = { + id: string; + user_id: string; + session_token_hash: string; + expires_at: string; +}; + +class FakeOAuthD1 { + readonly users: UserRow[] = []; + readonly oauthAccounts: OAuthAccountRow[] = []; + readonly sessions: SessionRow[] = []; + + prepare(sql: string): FakeOAuthStatement { + return new FakeOAuthStatement(this, sql); + } +} + +class FakeOAuthStatement { + private params: unknown[] = []; + + constructor( + private readonly db: FakeOAuthD1, + private readonly sql: string, + ) {} + + bind(...params: unknown[]): this { + this.params = params; + return this; + } + + async first(): Promise { + if (this.sql.includes('FROM oauth_accounts JOIN users')) { + const [provider, providerAccountId] = this.params.map(String); + const account = this.db.oauthAccounts.find( + (row) => row.provider === provider && row.provider_account_id === providerAccountId, + ); + const user = account ? this.db.users.find((row) => row.id === account.user_id) : undefined; + return (user ?? null) as T | null; + } + + if (this.sql.includes('FROM users WHERE lower(email)')) { + const email = String(this.params[0]).toLowerCase(); + const user = this.db.users.find((row) => row.email.toLowerCase() === email); + return (user ?? null) as T | null; + } + + return null; + } + + async run(): Promise { + if (this.sql.startsWith('INSERT INTO users')) { + const [id, email, name, imageUrl, role] = this.params; + this.db.users.push({ + id: String(id), + email: String(email), + name: name == null ? null : String(name), + image_url: imageUrl == null ? null : String(imageUrl), + role: role as 'user' | 'admin', + }); + } + + if (this.sql.startsWith('UPDATE users SET name')) { + const [name, imageUrl, id] = this.params; + const user = this.db.users.find((row) => row.id === id); + if (user) { + if (name != null) user.name = String(name); + if (imageUrl != null) user.image_url = String(imageUrl); + } + } + + if (this.sql.startsWith('UPDATE users SET role')) { + const [role, id] = this.params; + const user = this.db.users.find((row) => row.id === id); + if (user) { + user.role = role as 'user' | 'admin'; + } + } + + if (this.sql.startsWith('INSERT OR IGNORE INTO oauth_accounts')) { + const [id, userId, provider, providerAccountId] = this.params.map(String); + const exists = this.db.oauthAccounts.some( + (row) => row.provider === provider && row.provider_account_id === providerAccountId, + ); + if (!exists) { + this.db.oauthAccounts.push({ id, user_id: userId, provider, provider_account_id: providerAccountId }); + } + } + + if (this.sql.startsWith('INSERT INTO sessions')) { + const [id, userId, sessionTokenHash, expiresAt] = this.params.map(String); + this.db.sessions.push({ + id, + user_id: userId, + session_token_hash: sessionTokenHash, + expires_at: expiresAt, + }); + } + + return { success: true, meta: {} } as D1Result; + } +} + +class FakeExecutionContext { + waitUntil(): void {} + passThroughOnException(): void {} +} + +function makeEnv(db: FakeOAuthD1): Env { + return { + DB: db as unknown as D1Database, + PUBLIC_HOST: 'dev.heygo.cc', + PRIVATE_HOST: 'my.dev.heygo.cc', + APP_BASE_URL: 'https://dev.heygo.cc', + COOKIE_DOMAIN: '.heygo.cc', + ADMIN_EMAILS: 'wahyd4@gmail.com', + GOOGLE_CLIENT_ID: 'google-client-id', + GOOGLE_CLIENT_SECRET: 'google-client-secret', + }; +} + +async function fetchWorker(path: string, env: Env, init: RequestInit = {}): Promise { + const request = new Request(`https://dev.heygo.cc${path}`, init); + return worker.fetch( + request as unknown as Parameters[0], + env as unknown as Parameters[1], + new FakeExecutionContext() as unknown as Parameters[2], + ); +} + +describe('Google OAuth API', () => { + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === 'https://oauth2.googleapis.com/token') { + return Response.json({ access_token: 'google-access-token', token_type: 'Bearer', expires_in: 3600 }); + } + if (url === 'https://openidconnect.googleapis.com/v1/userinfo') { + return Response.json({ + sub: 'google-user-1', + email: 'wahyd4@gmail.com', + email_verified: true, + name: 'Wahyd', + picture: 'https://example.com/avatar.png', + }); + } + return new Response('not found', { status: 404 }); + }), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('redirects to Google with a state cookie', async () => { + const db = new FakeOAuthD1(); + const env = makeEnv(db); + + const response = await fetchWorker('/api/auth/google', env); + + expect(response.status).toBe(302); + const location = new URL(response.headers.get('location')!); + expect(location.origin + location.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth'); + expect(location.searchParams.get('client_id')).toBe('google-client-id'); + expect(location.searchParams.get('redirect_uri')).toBe('https://dev.heygo.cc/api/auth/google/callback'); + expect(location.searchParams.get('scope')).toBe('openid email profile'); + expect(location.searchParams.get('state')).toMatch(/^[0-9a-f]{64}$/); + expect(response.headers.get('set-cookie')).toContain('heygo_oauth_state='); + expect(response.headers.get('set-cookie')).toContain('HttpOnly'); + }); + + it('creates an admin user and session on callback', async () => { + const db = new FakeOAuthD1(); + const env = makeEnv(db); + const state = 'a'.repeat(64); + + const response = await fetchWorker( + `/api/auth/google/callback?code=oauth-code&state=${state}`, + env, + { headers: { cookie: `heygo_oauth_state=${state}` } }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('https://dev.heygo.cc/app/private'); + expect(response.headers.get('set-cookie')).toContain(`${AUTH_SESSION_COOKIE_NAME}=`); + expect(db.users).toHaveLength(1); + expect(db.users[0]).toMatchObject({ + email: 'wahyd4@gmail.com', + name: 'Wahyd', + image_url: 'https://example.com/avatar.png', + role: 'admin', + }); + expect(db.oauthAccounts).toHaveLength(1); + expect(db.oauthAccounts[0]).toMatchObject({ + user_id: db.users[0].id, + provider: 'google', + provider_account_id: 'google-user-1', + }); + expect(db.sessions).toHaveLength(1); + }); + + it('rejects a callback with an invalid state', async () => { + const db = new FakeOAuthD1(); + const env = makeEnv(db); + + const response = await fetchWorker( + '/api/auth/google/callback?code=oauth-code&state=wrong', + env, + { headers: { cookie: `heygo_oauth_state=${'a'.repeat(64)}` } }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get('location')).toContain('/app/login'); + expect(db.users).toHaveLength(0); + expect(db.sessions).toHaveLength(0); + }); +}); diff --git a/worker/env.ts b/worker/env.ts index 373aa34..bcb3367 100644 --- a/worker/env.ts +++ b/worker/env.ts @@ -7,4 +7,6 @@ export interface Env { APP_BASE_URL: string; COOKIE_DOMAIN: string; ADMIN_EMAILS?: string; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; } diff --git a/worker/index.ts b/worker/index.ts index 02a6751..518d937 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -3,6 +3,7 @@ import { withPrivateNoStoreHeaders } from './lib/responses'; import { handleAuthApi } from './routes/api.auth'; import { handleDevAuth } from './routes/api.dev-auth'; import { handleLinksApi } from './routes/api.links'; +import { handleOAuthApi } from './routes/api.oauth'; import { handleLinkGoRoute } from './routes/link-go'; import { handlePromotionsApi } from './routes/api.promotions'; import { handlePrivateShortlink, isHeygoPrivateHost } from './routes/private-redirect'; @@ -101,6 +102,10 @@ export default { } if (url.pathname.startsWith('/api/')) { + const oauthResponse = await handleOAuthApi(request, env); + if (oauthResponse) { + return withPrivateHostNoStoreHeaders(url, oauthResponse, env.PRIVATE_HOST); + } const authResponse = await handleAuthApi(request, env); if (authResponse) { return withPrivateHostNoStoreHeaders(url, authResponse, env.PRIVATE_HOST); diff --git a/worker/routes/api.oauth.ts b/worker/routes/api.oauth.ts new file mode 100644 index 0000000..674eb52 --- /dev/null +++ b/worker/routes/api.oauth.ts @@ -0,0 +1,276 @@ +import { + AUTH_SESSION_COOKIE_NAME, + getSessionCookieAttributes, + hashSessionToken, + isConfiguredAdminEmail, +} from '../auth'; +import type { Env } from '../env'; + +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const GOOGLE_USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo'; +const GOOGLE_SCOPES = 'openid email profile'; +const OAUTH_STATE_COOKIE_NAME = 'heygo_oauth_state'; +const SESSION_TTL_DAYS = 30; + +const FIND_OAUTH_USER = `SELECT users.id, users.email, users.name, users.image_url, users.role +FROM oauth_accounts JOIN users ON users.id = oauth_accounts.user_id +WHERE oauth_accounts.provider = ? AND oauth_accounts.provider_account_id = ? +LIMIT 1`; +const FIND_USER_BY_EMAIL = `SELECT id, email, name, image_url, role FROM users WHERE lower(email) = lower(?) LIMIT 1`; +const CREATE_USER = `INSERT INTO users (id, email, name, image_url, role) VALUES (?, ?, ?, ?, ?)`; +const UPDATE_USER_PROFILE = `UPDATE users SET name = COALESCE(?, name), image_url = COALESCE(?, image_url), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`; +const UPDATE_USER_ROLE = `UPDATE users SET role = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`; +const CREATE_OAUTH_ACCOUNT = `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_account_id) VALUES (?, ?, ?, ?)`; +const CREATE_SESSION = `INSERT INTO sessions (id, user_id, session_token_hash, expires_at) VALUES (?, ?, ?, ?)`; + +type OAuthUserRow = { + id: string; + email: string | null; + name: string | null; + image_url: string | null; + role: 'user' | 'admin'; +}; + +type GoogleTokenResponse = { + access_token?: string; + token_type?: string; + expires_in?: number; + id_token?: string; + error?: string; + error_description?: string; +}; + +type GoogleUserInfo = { + sub?: string; + email?: string; + email_verified?: boolean; + name?: string; + picture?: string; +}; + +export async function handleOAuthApi(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/api/auth/google') { + return handleGoogleStart(request, env); + } + + if (url.pathname === '/api/auth/google/callback') { + return handleGoogleCallback(request, env); + } + + return null; +} + +async function handleGoogleStart(request: Request, env: Env): Promise { + if (request.method !== 'GET') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }); + } + + const credentialError = requireGoogleCredentials(env); + if (credentialError) { + return credentialError; + } + + const requestUrl = new URL(request.url); + const state = generateToken(); + const redirectUri = googleRedirectUri(env); + const loginHint = requestUrl.searchParams.get('login_hint') ?? env.ADMIN_EMAILS?.split(/[\s,;]+/)[0] ?? ''; + const googleUrl = new URL(GOOGLE_AUTH_URL); + googleUrl.searchParams.set('client_id', env.GOOGLE_CLIENT_ID!); + googleUrl.searchParams.set('redirect_uri', redirectUri); + googleUrl.searchParams.set('response_type', 'code'); + googleUrl.searchParams.set('scope', GOOGLE_SCOPES); + googleUrl.searchParams.set('state', state); + googleUrl.searchParams.set('include_granted_scopes', 'true'); + if (loginHint) { + googleUrl.searchParams.set('login_hint', loginHint); + } + + return new Response(null, { + status: 302, + headers: { + location: googleUrl.toString(), + 'set-cookie': oauthStateCookie(request.url, env, state), + 'cache-control': 'no-store', + }, + }); +} + +async function handleGoogleCallback(request: Request, env: Env): Promise { + if (request.method !== 'GET') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }); + } + + const credentialError = requireGoogleCredentials(env); + if (credentialError) { + return credentialError; + } + + const url = new URL(request.url); + const error = url.searchParams.get('error'); + if (error) { + return redirectWithAuthError(env, `Google sign-in failed: ${error}`); + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const storedState = parseCookieHeader(request.headers.get('cookie'))[OAUTH_STATE_COOKIE_NAME]; + if (!code || !state || !storedState || !timingSafeEqual(state, storedState)) { + return redirectWithAuthError(env, 'Invalid sign-in state. Please try again.'); + } + + const token = await exchangeGoogleCode(env, code); + if (!token.access_token) { + return redirectWithAuthError(env, token.error_description ?? token.error ?? 'Google token exchange failed.'); + } + + const profile = await fetchGoogleUserInfo(token.access_token); + if (!profile.sub || !profile.email || profile.email_verified !== true) { + return redirectWithAuthError(env, 'Google account email is not verified.'); + } + + const user = await findOrCreateOAuthUser(env, profile); + const sessionToken = generateToken(); + const tokenHash = await hashSessionToken(sessionToken); + const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000).toISOString(); + await env.DB.prepare(CREATE_SESSION).bind(crypto.randomUUID(), user.id, tokenHash, expiresAt).run(); + + const sessionCookie = `${AUTH_SESSION_COOKIE_NAME}=${sessionToken}; ${getSessionCookieAttributes(url, env.COOKIE_DOMAIN).join('; ')}`; + const clearStateCookie = oauthStateCookie(request.url, env, '', true); + return new Response(null, { + status: 302, + headers: [ + ['location', `${env.APP_BASE_URL}/app/private`], + ['set-cookie', sessionCookie], + ['set-cookie', clearStateCookie], + ['cache-control', 'no-store'], + ], + }); +} + +function requireGoogleCredentials(env: Env): Response | null { + if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) { + return Response.json( + { error: 'Google OAuth is not configured' }, + { status: 503, headers: { 'cache-control': 'no-store' } }, + ); + } + return null; +} + +async function exchangeGoogleCode(env: Env, code: string): Promise { + const body = new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID!, + client_secret: env.GOOGLE_CLIENT_SECRET!, + redirect_uri: googleRedirectUri(env), + grant_type: 'authorization_code', + }); + + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + return (await response.json()) as GoogleTokenResponse; +} + +async function fetchGoogleUserInfo(accessToken: string): Promise { + const response = await fetch(GOOGLE_USERINFO_URL, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + return {}; + } + return (await response.json()) as GoogleUserInfo; +} + +async function findOrCreateOAuthUser(env: Env, profile: GoogleUserInfo): Promise { + const provider = 'google'; + const providerAccountId = profile.sub!; + const email = profile.email!; + const configuredAdmin = isConfiguredAdminEmail(email, env); + const role = configuredAdmin ? 'admin' : 'user'; + + let user = await env.DB.prepare(FIND_OAUTH_USER).bind(provider, providerAccountId).first(); + if (!user) { + user = await env.DB.prepare(FIND_USER_BY_EMAIL).bind(email).first(); + } + + if (!user) { + user = { + id: crypto.randomUUID(), + email, + name: profile.name ?? email.split('@')[0], + image_url: profile.picture ?? null, + role, + }; + await env.DB.prepare(CREATE_USER).bind(user.id, user.email, user.name, user.image_url, user.role).run(); + } else { + await env.DB.prepare(UPDATE_USER_PROFILE).bind(profile.name ?? null, profile.picture ?? null, user.id).run(); + if (configuredAdmin && user.role !== 'admin') { + await env.DB.prepare(UPDATE_USER_ROLE).bind('admin', user.id).run(); + user = { ...user, role: 'admin' }; + } + } + + await env.DB.prepare(CREATE_OAUTH_ACCOUNT) + .bind(crypto.randomUUID(), user.id, provider, providerAccountId) + .run(); + + return user; +} + +function googleRedirectUri(env: Env): string { + return `${env.APP_BASE_URL}/api/auth/google/callback`; +} + +function oauthStateCookie(requestUrl: string | URL, env: Env, value: string, expired = false): string { + const attrs = getSessionCookieAttributes(requestUrl, env.COOKIE_DOMAIN); + if (expired) { + attrs.push('Expires=Thu, 01 Jan 1970 00:00:00 GMT'); + } else { + attrs.push('Max-Age=600'); + } + return `${OAUTH_STATE_COOKIE_NAME}=${value}; ${attrs.join('; ')}`; +} + +function parseCookieHeader(cookieHeader: string | null): Record { + const cookies: Record = {}; + if (!cookieHeader) { + return cookies; + } + for (const part of cookieHeader.split(';')) { + const index = part.indexOf('='); + if (index === -1) { + continue; + } + cookies[part.slice(0, index).trim()] = part.slice(index + 1).trim(); + } + return cookies; +} + +function generateToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + let diff = 0; + for (let i = 0; i < a.length; i += 1) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +function redirectWithAuthError(env: Env, message: string): Response { + const location = new URL(`${env.APP_BASE_URL}/app/login`); + location.searchParams.set('error', message); + return Response.redirect(location.toString(), 302); +}