From 38aa5875d3572e2f4187e69e8cc0159fdbd993b3 Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Thu, 21 May 2026 00:26:44 +0200 Subject: [PATCH] feat(auth): add "Continue with Google" OAuth 2.0 login Server-side authorization-code flow: /v1/auth/google redirects to the consent screen with a CSRF state cookie; /v1/auth/google/callback exchanges the code, validates the ID token (iss/aud/exp/email_verified), and mints a 30-day session via upsertOAuthLogin. /v1/auth/providers lets the login UI hide the button until GOOGLE_OAUTH_ID/SECRET are set. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 11 +++ apps/api/src/config.ts | 4 + apps/api/src/routes/auth.ts | 150 +++++++++++++++++++++++++++++++++++- apps/web/app/login/page.tsx | 117 +++++++++++++++++++++------- apps/web/lib/api.ts | 10 ++- packages/auth/src/index.ts | 54 +++++++++++++ 6 files changed, 310 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index 88900b6..b6c66b3 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,17 @@ NEXT_PUBLIC_API_URL=http://localhost:4000 GITHUB_OAUTH_ID= GITHUB_OAUTH_SECRET= +# ---- Google OAuth (optional — "Continue with Google") ---- +# Create at https://console.cloud.google.com/apis/credentials +# Authorized redirect URI must be: /v1/auth/google/callback +# e.g. dev: http://localhost:4000/v1/auth/google/callback +# prod: https://api.buildmymcp.com/v1/auth/google/callback +GOOGLE_OAUTH_ID= +GOOGLE_OAUTH_SECRET= + +# Public URL of this API, used to build the OAuth redirect URI. +CONTROL_PLANE_PUBLIC_URL=http://localhost:4000 + # ---- Anthropic ---- ANTHROPIC_API_KEY= diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index c7584d6..519dce0 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -16,6 +16,8 @@ const Env = z.object({ ADMIN_EMAIL: z.string().email().optional(), ADMIN_PASSWORD: z.string().min(8).optional(), ADMIN_NAME: z.string().optional(), + GOOGLE_OAUTH_ID: z.string().optional(), + GOOGLE_OAUTH_SECRET: z.string().optional(), }); export const config = Env.parse({ @@ -31,6 +33,8 @@ export const config = Env.parse({ ADMIN_EMAIL: process.env.ADMIN_EMAIL, ADMIN_PASSWORD: process.env.ADMIN_PASSWORD, ADMIN_NAME: process.env.ADMIN_NAME, + GOOGLE_OAUTH_ID: process.env.GOOGLE_OAUTH_ID, + GOOGLE_OAUTH_SECRET: process.env.GOOGLE_OAUTH_SECRET, }); // INFRA-001: refuse to boot in production with the placeholder encryption key. diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 6ac8fba..4e268af 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,16 +1,50 @@ -import type { FastifyInstance } from 'fastify'; -import { z } from 'zod'; +import crypto from 'node:crypto'; import { consumeMagicLink, destroySession, getSession, issueMagicLink, loginWithPassword, + upsertOAuthLogin, } from '@bmm/auth'; -import { audit } from '../lib/audit.js'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; import { config } from '../config.js'; +import { audit } from '../lib/audit.js'; const SESSION_COOKIE = 'bmm_session'; +const OAUTH_STATE_COOKIE = 'bmm_oauth_state'; + +const GoogleClaims = z.object({ + iss: z.string(), + aud: z.string(), + exp: z.number(), + email: z.string().email(), + email_verified: z.union([z.boolean(), z.string()]).optional(), + name: z.string().optional(), +}); + +/** + * Decode (NOT signature-verify) a Google ID token payload. Signature verification + * is unnecessary here because the token is fetched directly from Google's token + * endpoint over TLS, authenticated with our client secret — an intermediary-free + * channel, per Google's own guidance. We still validate iss / aud / exp / email + * below as defense-in-depth. + */ +function decodeGoogleIdToken(idToken: string): z.infer { + const parts = idToken.split('.'); + if (parts.length !== 3 || !parts[1]) throw new Error('malformed_id_token'); + const json = Buffer.from(parts[1], 'base64url').toString('utf8'); + return GoogleClaims.parse(JSON.parse(json)); +} + +function googleRedirectUri(): string { + return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/google/callback`; +} + +function googleConfigured(): boolean { + return Boolean(config.GOOGLE_OAUTH_ID && config.GOOGLE_OAUTH_SECRET); +} export async function authRoutes(app: FastifyInstance): Promise { app.post('/v1/auth/magic-link', async (req, reply) => { @@ -132,4 +166,114 @@ export async function authRoutes(app: FastifyInstance): Promise { } return reply.send({ ok: true }); }); + + // Which third-party login providers are configured. Lets the UI hide the + // Google button when no credentials are set, instead of showing a dead button. + app.get('/v1/auth/providers', async (_req, reply) => { + return reply.send({ google: googleConfigured() }); + }); + + // Step 1: hand the browser off to Google's consent screen. + app.get('/v1/auth/google', async (_req, reply) => { + if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) { + return reply.code(503).send({ error: 'google_oauth_not_configured' }); + } + const state = crypto.randomBytes(16).toString('base64url'); + reply.setCookie(OAUTH_STATE_COOKIE, state, { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: config.NODE_ENV === 'production', + maxAge: 600, + }); + const url = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + url.searchParams.set('client_id', config.GOOGLE_OAUTH_ID); + url.searchParams.set('redirect_uri', googleRedirectUri()); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'openid email profile'); + url.searchParams.set('state', state); + url.searchParams.set('access_type', 'online'); + url.searchParams.set('prompt', 'select_account'); + return reply.redirect(url.toString()); + }); + + // Step 2: Google redirects back here with an auth code. Exchange it, verify + // the ID token, mint a session, drop the user on the dashboard. + app.get('/v1/auth/google/callback', async (req, reply) => { + const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`; + const Query = z.object({ + code: z.string().min(10).optional(), + state: z.string().min(8).optional(), + error: z.string().optional(), + }); + const q = Query.safeParse(req.query); + const cookieState = req.cookies[OAUTH_STATE_COOKIE]; + reply.clearCookie(OAUTH_STATE_COOKIE, { path: '/' }); + + if (!q.success || q.data.error || !q.data.code || !q.data.state) { + return reply.redirect(`${loginUrl}?error=google_failed`); + } + // CSRF: the state echoed back by Google must match the one we set. + // Length-check first — timingSafeEqual throws on a length mismatch. + if ( + !cookieState || + cookieState.length !== q.data.state.length || + !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state)) + ) { + return reply.redirect(`${loginUrl}?error=google_state`); + } + if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) { + return reply.redirect(`${loginUrl}?error=google_failed`); + } + + try { + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code: q.data.code, + client_id: config.GOOGLE_OAUTH_ID, + client_secret: config.GOOGLE_OAUTH_SECRET, + redirect_uri: googleRedirectUri(), + grant_type: 'authorization_code', + }), + }); + if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`); + const tokens = (await tokenRes.json()) as { id_token?: string }; + if (!tokens.id_token) throw new Error('no_id_token'); + + const claims = decodeGoogleIdToken(tokens.id_token); + if (claims.iss !== 'accounts.google.com' && claims.iss !== 'https://accounts.google.com') { + throw new Error('bad_iss'); + } + if (claims.aud !== config.GOOGLE_OAUTH_ID) throw new Error('bad_aud'); + if (claims.exp * 1000 < Date.now()) throw new Error('token_expired'); + const verified = claims.email_verified === true || claims.email_verified === 'true'; + if (!verified) throw new Error('email_unverified'); + + const session = await upsertOAuthLogin( + { email: claims.email, name: claims.name ?? null }, + { ipAddress: req.ip, userAgent: req.headers['user-agent'] }, + ); + reply.setCookie(SESSION_COOKIE, session.sessionToken, { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: config.NODE_ENV === 'production', + maxAge: 30 * 24 * 60 * 60, + }); + await audit({ + orgId: session.orgId, + userId: session.userId, + action: 'auth.login', + resourceType: 'session', + metadata: { email: session.email, provider: 'google' }, + ipAddress: req.ip, + }); + return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`); + } catch (err) { + app.log.warn({ err }, 'google oauth callback failed'); + return reply.redirect(`${loginUrl}?error=google_failed`); + } + }); } diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 8883e8c..bd7a645 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -1,16 +1,32 @@ 'use client'; -import Link from 'next/link'; -import { useState } from 'react'; +import { Input, Label } from '@/components/input'; import { Logo } from '@/components/logo'; import { Button } from '@/components/ui/button'; -import { Input, Label } from '@/components/input'; -import { apiFetch } from '@/lib/api'; +import { apiFetch, apiUrl } from '@/lib/api'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; + +const ERROR_COPY: Record = { + google_failed: 'Google sign-in could not be completed. Please try again.', + google_state: 'Google sign-in expired or was interrupted. Please try again.', +}; export default function LoginPage() { const [email, setEmail] = useState(''); const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); const [error, setError] = useState(null); + const [googleEnabled, setGoogleEnabled] = useState(false); + + useEffect(() => { + apiFetch<{ google: boolean }>('/v1/auth/providers') + .then((r) => setGoogleEnabled(r.google)) + .catch(() => setGoogleEnabled(false)); + + const params = new URLSearchParams(window.location.search); + const err = params.get('error'); + if (err && ERROR_COPY[err]) setError(ERROR_COPY[err]); + }, []); async function submit(e: React.FormEvent) { e.preventDefault(); @@ -34,34 +50,54 @@ export default function LoginPage() {

Sign in to your workspace

- We'll email you a magic link. No password. + Continue with Google, or get a magic link by email.

{state !== 'sent' ? ( -
-
- - setEmail(e.target.value)} - placeholder="you@company.com" - /> -
- - {error &&

{error}

} -
+ <> + {googleEnabled && ( + <> + + + Continue with Google + +
+ + + or + + +
+ + )} +
+
+ + setEmail(e.target.value)} + placeholder="you@company.com" + /> +
+ + {error &&

{error}

} +
+ ) : (

@@ -82,3 +118,26 @@ export default function LoginPage() {

); } + +function GoogleIcon() { + return ( + + ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 4d1882d..6558b87 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1,9 +1,6 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; -export async function apiFetch( - path: string, - init: RequestInit = {}, -): Promise { +export async function apiFetch(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${API_BASE}${path}`, { credentials: 'include', cache: 'no-store', @@ -26,6 +23,11 @@ export async function apiFetch( return (await res.json()) as T; } +/** Absolute API URL for a path — use for full-page navigations (OAuth redirects). */ +export function apiUrl(path: string): string { + return `${API_BASE}${path}`; +} + export function apiWebSocketURL(path: string): string { const httpBase = API_BASE; const wsBase = httpBase.replace(/^http/, 'ws'); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index db086d3..354eeaa 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -123,6 +123,60 @@ export async function consumeMagicLink( return { sessionToken, userId: user.id, orgId: membership.orgId, email: user.email }; } +/** + * Get-or-create a user from a verified third-party identity (Google, etc.) and + * mint a session. The caller is responsible for verifying the identity provider's + * token BEFORE calling this — `email` must already be proven to belong to the user. + */ +export async function upsertOAuthLogin( + input: { email: string; name?: string | null }, + meta: { ipAddress?: string; userAgent?: string } = {}, + db: Database = createDb(), +): Promise { + const email = input.email.trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new Error('invalid_email'); + } + + let user = (await db.select().from(users).where(eq(users.email, email)).limit(1))[0]; + if (!user) { + [user] = await db + .insert(users) + .values({ email, emailVerified: true, name: input.name ?? undefined }) + .returning(); + if (!user) throw new Error('user_create_failed'); + const orgSlug = `${slugify(email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`; + const [org] = await db + .insert(organizations) + .values({ slug: orgSlug, name: `${email.split('@')[0]}'s workspace` }) + .returning(); + if (!org) throw new Error('org_create_failed'); + await db.insert(memberships).values({ orgId: org.id, userId: user.id, role: 'owner' }); + } else if (!user.emailVerified) { + await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id)); + } + + const resolved = user; + const [membership] = await db + .select() + .from(memberships) + .where(eq(memberships.userId, resolved.id)) + .limit(1); + if (!membership) throw new Error('no_org_membership'); + + const sessionToken = randomToken(32); + await db.insert(sessions).values({ + userId: resolved.id, + tokenHash: sha256(sessionToken), + expiresAt: new Date(Date.now() + SESSION_TTL_MS), + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, resolved.id)); + + return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email }; +} + export interface AuthedUser { userId: string; orgId: string;