← back to Norma
Harden Norma OAuth webhooks and release checks
9a97f8aad32eb388a17c5d4d486690dcb34b1db9 · 2026-09-08 20:53:39 -0700 · Steve Abrams
Files touched
M app/api/gmail/oauth/callback/route.tsM app/api/gmail/oauth/route.tsM app/api/webhooks/pulse-topic/route.tsM lib/gmail.tsM next.config.ts
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=…&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 →