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) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi 2026-05-21 00:26:44 +02:00
parent a68e882092
commit 38aa5875d3
6 changed files with 310 additions and 36 deletions

View File

@ -15,6 +15,17 @@ NEXT_PUBLIC_API_URL=http://localhost:4000
GITHUB_OAUTH_ID= GITHUB_OAUTH_ID=
GITHUB_OAUTH_SECRET= GITHUB_OAUTH_SECRET=
# ---- Google OAuth (optional — "Continue with Google") ----
# Create at https://console.cloud.google.com/apis/credentials
# Authorized redirect URI must be: <CONTROL_PLANE_PUBLIC_URL>/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 ----
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=

View File

@ -16,6 +16,8 @@ const Env = z.object({
ADMIN_EMAIL: z.string().email().optional(), ADMIN_EMAIL: z.string().email().optional(),
ADMIN_PASSWORD: z.string().min(8).optional(), ADMIN_PASSWORD: z.string().min(8).optional(),
ADMIN_NAME: z.string().optional(), ADMIN_NAME: z.string().optional(),
GOOGLE_OAUTH_ID: z.string().optional(),
GOOGLE_OAUTH_SECRET: z.string().optional(),
}); });
export const config = Env.parse({ export const config = Env.parse({
@ -31,6 +33,8 @@ export const config = Env.parse({
ADMIN_EMAIL: process.env.ADMIN_EMAIL, ADMIN_EMAIL: process.env.ADMIN_EMAIL,
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD, ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
ADMIN_NAME: process.env.ADMIN_NAME, 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. // INFRA-001: refuse to boot in production with the placeholder encryption key.

View File

@ -1,16 +1,50 @@
import type { FastifyInstance } from 'fastify'; import crypto from 'node:crypto';
import { z } from 'zod';
import { import {
consumeMagicLink, consumeMagicLink,
destroySession, destroySession,
getSession, getSession,
issueMagicLink, issueMagicLink,
loginWithPassword, loginWithPassword,
upsertOAuthLogin,
} from '@bmm/auth'; } from '@bmm/auth';
import { audit } from '../lib/audit.js'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { config } from '../config.js'; import { config } from '../config.js';
import { audit } from '../lib/audit.js';
const SESSION_COOKIE = 'bmm_session'; 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<typeof GoogleClaims> {
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<void> { export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post('/v1/auth/magic-link', async (req, reply) => { app.post('/v1/auth/magic-link', async (req, reply) => {
@ -132,4 +166,114 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
} }
return reply.send({ ok: true }); 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`);
}
});
} }

View File

@ -1,16 +1,32 @@
'use client'; 'use client';
import Link from 'next/link'; import { Input, Label } from '@/components/input';
import { useState } from 'react';
import { Logo } from '@/components/logo'; import { Logo } from '@/components/logo';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input, Label } from '@/components/input'; import { apiFetch, apiUrl } from '@/lib/api';
import { apiFetch } from '@/lib/api'; import Link from 'next/link';
import { useEffect, useState } from 'react';
const ERROR_COPY: Record<string, string> = {
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() { export default function LoginPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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) { async function submit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@ -34,34 +50,54 @@ export default function LoginPage() {
<Logo className="mb-10" /> <Logo className="mb-10" />
<h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1> <h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]"> <p className="mt-1 text-[13px] text-[--color-fg-muted]">
We&apos;ll email you a magic link. No password. Continue with Google, or get a magic link by email.
</p> </p>
{state !== 'sent' ? ( {state !== 'sent' ? (
<form onSubmit={submit} className="mt-7 space-y-3"> <>
<div className="space-y-1.5"> {googleEnabled && (
<Label htmlFor="email">Email</Label> <>
<Input <a
id="email" href={apiUrl('/v1/auth/google')}
type="email" className="mt-7 flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
required >
autoComplete="email" <GoogleIcon />
value={email} Continue with Google
onChange={(e) => setEmail(e.target.value)} </a>
placeholder="you@company.com" <div className="my-5 flex items-center gap-3">
/> <span className="h-px flex-1 bg-[--color-border]" />
</div> <span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
<Button or
type="submit" </span>
variant="primary" <span className="h-px flex-1 bg-[--color-border]" />
size="lg" </div>
className="w-full" </>
disabled={state === 'sending'} )}
> <form onSubmit={submit} className={googleEnabled ? 'space-y-3' : 'mt-7 space-y-3'}>
{state === 'sending' ? 'Sending…' : 'Send magic link'} <div className="space-y-1.5">
</Button> <Label htmlFor="email">Email</Label>
{error && <p className="text-[12px] text-[--color-danger]">{error}</p>} <Input
</form> id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
/>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={state === 'sending'}
>
{state === 'sending' ? 'Sending…' : 'Send magic link'}
</Button>
{error && <p className="text-[12px] text-[--color-danger]">{error}</p>}
</form>
</>
) : ( ) : (
<div className="panel mt-7 p-4"> <div className="panel mt-7 p-4">
<p className="text-[13px]"> <p className="text-[13px]">
@ -82,3 +118,26 @@ export default function LoginPage() {
</div> </div>
); );
} }
function GoogleIcon() {
return (
<svg width="16" height="16" viewBox="0 0 18 18" aria-hidden="true">
<path
fill="#4285F4"
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615Z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18Z"
/>
<path
fill="#FBBC05"
d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332Z"
/>
<path
fill="#EA4335"
d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58Z"
/>
</svg>
);
}

View File

@ -1,9 +1,6 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
export async function apiFetch<T = unknown>( export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
path: string,
init: RequestInit = {},
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include', credentials: 'include',
cache: 'no-store', cache: 'no-store',
@ -26,6 +23,11 @@ export async function apiFetch<T = unknown>(
return (await res.json()) as T; 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 { export function apiWebSocketURL(path: string): string {
const httpBase = API_BASE; const httpBase = API_BASE;
const wsBase = httpBase.replace(/^http/, 'ws'); const wsBase = httpBase.replace(/^http/, 'ws');

View File

@ -123,6 +123,60 @@ export async function consumeMagicLink(
return { sessionToken, userId: user.id, orgId: membership.orgId, email: user.email }; 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<ConsumedSession> {
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 { export interface AuthedUser {
userId: string; userId: string;
orgId: string; orgId: string;