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:
@@ -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.
|
||||
|
||||
@@ -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<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> {
|
||||
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 });
|
||||
});
|
||||
|
||||
// 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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user