[object Object]

← back to Norma

Harden Norma OAuth webhooks and release checks

9a97f8aad32eb388a17c5d4d486690dcb34b1db9 · 2026-09-08 20:53:39 -0700 · Steve Abrams

Files touched

Diff

commit 9a97f8aad32eb388a17c5d4d486690dcb34b1db9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 8 20:53:39 2026 -0700

    Harden Norma OAuth webhooks and release checks
---
 app/api/gmail/oauth/callback/route.ts | 18 +++++++----
 app/api/gmail/oauth/route.ts          | 30 +++++++++++++-----
 app/api/webhooks/pulse-topic/route.ts | 47 ++++++++++++++++++++++++-----
 lib/gmail.ts                          | 57 ++++++++++++++++++++++++++++++++++-
 next.config.ts                        |  9 ++++--
 5 files changed, 138 insertions(+), 23 deletions(-)

diff --git a/app/api/gmail/oauth/callback/route.ts b/app/api/gmail/oauth/callback/route.ts
index 283c53c..9ae1de5 100644
--- a/app/api/gmail/oauth/callback/route.ts
+++ b/app/api/gmail/oauth/callback/route.ts
@@ -1,5 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
-import { getOAuth2Client, storeTokens } from '@/lib/gmail';
+import { getOAuth2Client, storeTokens, verifyOAuthState } from '@/lib/gmail';
 
 /**
  * GET /api/gmail/oauth/callback — Google OAuth2 redirect target.
@@ -9,7 +9,7 @@ import { getOAuth2Client, storeTokens } from '@/lib/gmail';
 export async function GET(request: NextRequest) {
   const { searchParams } = new URL(request.url);
   const code  = searchParams.get('code');
-  const state = searchParams.get('state'); // mailbox email
+  const stateValue = searchParams.get('state') || '';
   const error = searchParams.get('error');
 
   if (error) {
@@ -26,13 +26,17 @@ export async function GET(request: NextRequest) {
     );
   }
 
-  const mailboxEmail = state && state.includes('@') ? state : '';
-  if (!mailboxEmail) {
+  const cookieState = request.cookies.get('gmail-oauth-state')?.value;
+  const state = cookieState && decodeURIComponent(cookieState) === stateValue
+    ? verifyOAuthState(stateValue)
+    : null;
+  if (!state) {
     return new NextResponse(
-      `<html><body><h2>Missing mailbox</h2><p>OAuth flow returned without a target mailbox in <code>state</code>. Restart the connect flow from inside Norma.</p><p><a href="/">Back to Norma</a></p></body></html>`,
+      `<html><body><h2>Expired connection</h2><p>The Gmail connection request is invalid or expired. Restart the connect flow from inside Norma.</p><p><a href="/">Back to Norma</a></p></body></html>`,
       { headers: { 'Content-Type': 'text/html' } },
     );
   }
+  const mailboxEmail = state.mailboxEmail;
 
   try {
     const client = getOAuth2Client();
@@ -41,7 +45,7 @@ export async function GET(request: NextRequest) {
 
     console.log(`[gmail-oauth] Tokens stored for ${mailboxEmail}`);
 
-    return new NextResponse(
+    const response = new NextResponse(
       `<html><body style="font-family:system-ui;padding:40px;text-align:center">
         <h2 style="color:#10b981">Gmail Connected!</h2>
         <p>${mailboxEmail} is now linked to Norma.</p>
@@ -50,6 +54,8 @@ export async function GET(request: NextRequest) {
       </body></html>`,
       { headers: { 'Content-Type': 'text/html' } },
     );
+    response.headers.append('Set-Cookie', 'gmail-oauth-state=; Path=/api/gmail/oauth; HttpOnly; SameSite=Lax; Max-Age=0');
+    return response;
   } catch (err) {
     console.error('[gmail-oauth] Token exchange failed:', (err as Error).message);
     return new NextResponse(
diff --git a/app/api/gmail/oauth/route.ts b/app/api/gmail/oauth/route.ts
index 6d7a5e0..2fb0ccb 100644
--- a/app/api/gmail/oauth/route.ts
+++ b/app/api/gmail/oauth/route.ts
@@ -1,6 +1,6 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { requireRole } from '@/lib/require-role';
-import { getAuthUrl, getConnectionStatus, getOAuth2Client, storeTokens } from '@/lib/gmail';
+import { createOAuthState, getAuthUrl, getConnectionStatus, getOAuth2Client, storeTokens, verifyOAuthState } from '@/lib/gmail';
 import { verifyAuth } from '@/lib/auth';
 
 /**
@@ -36,8 +36,9 @@ export async function GET(request: NextRequest) {
   }
 
   if (action === 'connect') {
-    const url = getAuthUrl(mailboxEmail, mailboxEmail);
-    return new NextResponse(
+    const state = createOAuthState(auth.username, mailboxEmail);
+    const url = getAuthUrl(mailboxEmail, state);
+    const response = new NextResponse(
       `<!DOCTYPE html>
 <html><head><title>Connect Gmail — ${mailboxEmail}</title>
 <style>
@@ -61,7 +62,7 @@ export async function GET(request: NextRequest) {
 
   <div class="step">
     <h3>Step 2 — Copy the code</h3>
-    <p>After authorizing, Google will redirect to a URL that contains <code>?code=…&amp;state=${encodeURIComponent(mailboxEmail)}</code>. Copy the FULL URL from the address bar.</p>
+    <p>After authorizing, Google will redirect to a URL containing a short-lived signed <code>code</code> and <code>state</code>. Copy the FULL URL from the address bar.</p>
   </div>
 
   <div class="step">
@@ -75,6 +76,8 @@ export async function GET(request: NextRequest) {
 </body></html>`,
       { headers: { 'Content-Type': 'text/html' } },
     );
+    response.headers.append('Set-Cookie', `gmail-oauth-state=${encodeURIComponent(state)}; Path=/api/gmail/oauth; HttpOnly; SameSite=Lax; Max-Age=600`);
+    return response;
   }
 
   const status = await getConnectionStatus();
@@ -102,11 +105,22 @@ export async function POST(request: NextRequest) {
     mailboxEmail = body.mailbox || '';
   }
 
-  // Pull state= out of the URL if present — Google round-trips our mailbox email
+  // Pull the signed state out of the URL. The state is bound to the initiating
+  // browser by the HttpOnly cookie set above; mailbox names are not authority.
+  let oauthState = '';
   if (codeInput.includes('state=')) {
     const m = codeInput.match(/[?&]state=([^&]+)/);
-    if (m && !mailboxEmail) mailboxEmail = decodeURIComponent(m[1]);
+    if (m) oauthState = decodeURIComponent(m[1]);
+  }
+
+  const cookieState = request.cookies.get('gmail-oauth-state')?.value;
+  const state = oauthState && cookieState && decodeURIComponent(cookieState) === oauthState
+    ? verifyOAuthState(oauthState)
+    : null;
+  if (!state || state.username !== auth.username || (mailboxEmail && mailboxEmail.toLowerCase() !== state.mailboxEmail)) {
+    return NextResponse.json({ error: 'OAuth state is invalid or expired. Restart the Gmail connection.' }, { status: 400 });
   }
+  mailboxEmail = state.mailboxEmail;
 
   // Extract code
   let code = codeInput.trim();
@@ -116,7 +130,7 @@ export async function POST(request: NextRequest) {
   }
 
   if (!code || !mailboxEmail) {
-    return new NextResponse(
+    const response = new NextResponse(
       `<html><body style="font-family:system-ui;background:#0C0F1A;color:#f0f0f5;padding:40px">
         <h2 style="color:#f43f5e">Missing data</h2>
         <p>code: ${code ? 'ok' : 'MISSING'}, mailbox: ${mailboxEmail || 'MISSING'}</p>
@@ -124,6 +138,8 @@ export async function POST(request: NextRequest) {
       </body></html>`,
       { headers: { 'Content-Type': 'text/html' } },
     );
+    response.headers.append('Set-Cookie', 'gmail-oauth-state=; Path=/api/gmail/oauth; HttpOnly; SameSite=Lax; Max-Age=0');
+    return response;
   }
 
   // Non-admins still restricted to their own mailbox
diff --git a/app/api/webhooks/pulse-topic/route.ts b/app/api/webhooks/pulse-topic/route.ts
index ad47408..8ff54e8 100644
--- a/app/api/webhooks/pulse-topic/route.ts
+++ b/app/api/webhooks/pulse-topic/route.ts
@@ -1,10 +1,36 @@
 import { NextRequest, NextResponse } from 'next/server';
-import { readJson } from '@/lib/body';
+import { createHmac, timingSafeEqual } from 'crypto';
 import { query } from '@/lib/db';
 import { auditLog } from '@/lib/audit';
 import { getBrand } from '@/lib/brand';
 
 const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
+const MAX_BODY_BYTES = 256 * 1024;
+const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
+
+interface PulseWebhookPayload {
+  topic?: Record<string, unknown>;
+  topic_id?: string;
+}
+
+function validSignature(request: NextRequest, rawBody: string): boolean {
+  const timestamp = request.headers.get('x-pulse-webhook-timestamp') || '';
+  const signature = request.headers.get('x-pulse-webhook-signature') || '';
+  if (!WEBHOOK_SECRET || !timestamp || !/^\d+$/.test(timestamp)) return false;
+  const timestampMs = Number(timestamp) * 1000;
+  if (!Number.isFinite(timestampMs) || Math.abs(Date.now() - timestampMs) > MAX_CLOCK_SKEW_MS) return false;
+  const expected = createHmac('sha256', WEBHOOK_SECRET)
+    .update(`${timestamp}.${rawBody}`)
+    .digest('hex');
+  const provided = signature.startsWith('v1=') ? signature.slice(3) : signature;
+  try {
+    const expectedBuffer = Buffer.from(expected, 'hex');
+    const providedBuffer = Buffer.from(provided, 'hex');
+    return providedBuffer.length === expectedBuffer.length && timingSafeEqual(providedBuffer, expectedBuffer);
+  } catch {
+    return false;
+  }
+}
 
 function getGeminiUrl(): string {
   const geminiApiKey = process.env.GEMINI_API_KEY;
@@ -31,13 +57,20 @@ export async function POST(request: NextRequest) {
     return NextResponse.json({ error: 'Webhook not configured' }, { status: 503 });
   }
 
-  const secret = request.headers.get('x-pulse-webhook-secret');
-  if (secret !== WEBHOOK_SECRET) {
-    return NextResponse.json({ error: 'Invalid webhook secret' }, { status: 401 });
-  }
-
   try {
-    const data = await readJson(request);
+    const rawBody = await request.text();
+    if (Buffer.byteLength(rawBody, 'utf8') > MAX_BODY_BYTES) {
+      return NextResponse.json({ error: 'Payload too large' }, { status: 413 });
+    }
+    if (!validSignature(request, rawBody)) {
+      return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 401 });
+    }
+    let data: PulseWebhookPayload;
+    try {
+      data = JSON.parse(rawBody) as PulseWebhookPayload;
+    } catch {
+      return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
+    }
     const topic = data.topic;
 
     if (!topic || !topic.title) {
diff --git a/lib/gmail.ts b/lib/gmail.ts
index 709acd0..a86fc34 100644
--- a/lib/gmail.ts
+++ b/lib/gmail.ts
@@ -4,11 +4,65 @@
  * GCP Project: chromatic-pride-490518-s8
  */
 import { google, gmail_v1 } from 'googleapis';
+import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
 import { query } from './db';
 
 const CLIENT_ID = process.env.GMAIL_CLIENT_ID || '';
 const CLIENT_SECRET = process.env.GMAIL_CLIENT_SECRET || '';
-const REDIRECT_URI = process.env.GMAIL_REDIRECT_URI || 'http://45.61.58.125:7400/api/gmail/oauth/callback';
+const REDIRECT_URI = process.env.GMAIL_REDIRECT_URI;
+
+const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
+
+function stateSecret(): string {
+  const secret = process.env.GMAIL_OAUTH_STATE_SECRET || process.env.SESSION_SECRET;
+  if (!secret) throw new Error('[gmail] GMAIL_OAUTH_STATE_SECRET or SESSION_SECRET is required');
+  return secret;
+}
+
+function encode(value: string): string {
+  return Buffer.from(value).toString('base64url');
+}
+
+function decode(value: string): string {
+  return Buffer.from(value, 'base64url').toString('utf8');
+}
+
+export interface GmailOAuthState {
+  username: string;
+  mailboxEmail: string;
+  nonce: string;
+  issuedAt: number;
+}
+
+/** Create a short-lived, signed state value bound to the initiating user. */
+export function createOAuthState(username: string, mailboxEmail: string): string {
+  const payload = encode(JSON.stringify({
+    username,
+    mailboxEmail: mailboxEmail.toLowerCase(),
+    nonce: randomBytes(24).toString('hex'),
+    issuedAt: Date.now(),
+  } satisfies GmailOAuthState));
+  const signature = createHmac('sha256', stateSecret()).update(payload).digest('hex');
+  return `${payload}.${signature}`;
+}
+
+/** Verify signature, age, and shape of an OAuth state value. */
+export function verifyOAuthState(value: string): GmailOAuthState | null {
+  try {
+    const [payload, signature] = value.split('.');
+    if (!payload || !signature) return null;
+    const expected = createHmac('sha256', stateSecret()).update(payload).digest('hex');
+    const provided = Buffer.from(signature, 'hex');
+    const expectedBuffer = Buffer.from(expected, 'hex');
+    if (provided.length !== expectedBuffer.length || !timingSafeEqual(provided, expectedBuffer)) return null;
+    const parsed = JSON.parse(decode(payload)) as Partial<GmailOAuthState>;
+    if (!parsed.username || !parsed.mailboxEmail || !parsed.nonce || typeof parsed.issuedAt !== 'number') return null;
+    if (Date.now() - parsed.issuedAt > OAUTH_STATE_TTL_MS || Date.now() - parsed.issuedAt < -60_000) return null;
+    return parsed as GmailOAuthState;
+  } catch {
+    return null;
+  }
+}
 
 const SCOPES = [
   'https://www.googleapis.com/auth/gmail.modify',
@@ -23,6 +77,7 @@ const MAILBOX_EMAIL = 'natalia@studentdebtcrisis.org';
 /* ─── OAuth2 Client ─────────────────────────────────────────────────────── */
 
 export function getOAuth2Client() {
+  if (!REDIRECT_URI) throw new Error('[gmail] GMAIL_REDIRECT_URI env var is required');
   return new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI);
 }
 
diff --git a/next.config.ts b/next.config.ts
index a39050d..fd9863e 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -8,10 +8,15 @@ const nextConfig: NextConfig = {
   outputFileTracingRoot: import.meta.dirname,
   allowedDevOrigins: ['http://45.61.58.125:7400', 'http://127.0.0.1:7400'],
   typescript: {
-    ignoreBuildErrors: true,
+    // Type errors must block release builds. Local development still reports
+    // diagnostics through Next's overlay, while CI catches regressions before
+    // PM2 is restarted.
+    ignoreBuildErrors: false,
   },
   eslint: {
-    ignoreDuringBuilds: true,
+    // Keep lint failures visible to the release gate instead of shipping a
+    // build that only passed because lint was skipped.
+    ignoreDuringBuilds: false,
   },
   async headers() {
     return [

← db3be27 auto-data-snapshot: 2026-09-08T20:11:26 (1 data files) — age  ·  back to Norma  ·  Make session secret narrowing explicit 318ab90 →