// lambdas/authorizer/index.js // Lambda Authorizer — validates Google ID tokens sent as Bearer tokens // Attached to every protected API Gateway route const { OAuth2Client } = require("google-auth-library"); const { DynamoDBClient } = require("@aws-sdk/client-dynamodb"); const { DynamoDBDocumentClient, GetCommand, PutCommand } = require("@aws-sdk/lib-dynamodb"); const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; const ALLOWED_DOMAINS = (process.env.ALLOWED_DOMAINS || "").split(",").filter(Boolean); // e.g. "yourcompany.com" const SESSIONS_TABLE = process.env.SESSIONS_TABLE || "ledgerflow-sessions"; const googleClient = new OAuth2Client(GOOGLE_CLIENT_ID); const dynamo = DynamoDBDocumentClient.from( new DynamoDBClient({ region: process.env.AWS_REGION || "us-east-1" }), { marshallOptions: { removeUndefinedValues: true } } ); // ─── Main Handler ──────────────────────────────────────────────────────────── exports.handler = async (event) => { // Allow CORS preflight through without auth const method = event.requestContext?.http?.method || event.httpMethod; if (method === "OPTIONS") { return allowPolicy("preflight", {}); } const token = extractToken(event); if (!token) { console.warn("[AUTH] No token provided"); return denyPolicy("anonymous", "No token provided"); } try { const payload = await verifyGoogleToken(token); // Optional: restrict to specific Google Workspace domains if (ALLOWED_DOMAINS.length > 0) { const domain = payload.email?.split("@")[1]; if (!ALLOWED_DOMAINS.includes(domain)) { console.warn(`[AUTH] Domain not allowed: ${domain}`); return denyPolicy(payload.sub, "Domain not authorized"); } } // Persist/update session record for audit trail await upsertSession(payload); console.info(`[AUTH] Granted: ${payload.email}`); return allowPolicy(payload.sub, { userId: payload.sub, email: payload.email, name: payload.name, picture: payload.picture, }); } catch (err) { console.error("[AUTH] Token verification failed:", err.message); return denyPolicy("unknown", err.message); } }; // ─── Token Extraction ──────────────────────────────────────────────────────── function extractToken(event) { // API Gateway v2 (HTTP API) passes headers differently const authHeader = event.headers?.authorization || event.headers?.Authorization || ""; if (authHeader.startsWith("Bearer ")) { return authHeader.slice(7); } // Also accept token in query string for WebSocket / SSE use cases return event.queryStringParameters?.token || null; } // ─── Google Token Verification ─────────────────────────────────────────────── async function verifyGoogleToken(idToken) { const ticket = await googleClient.verifyIdToken({ idToken, audience: GOOGLE_CLIENT_ID, }); const payload = ticket.getPayload(); if (!payload) throw new Error("Empty token payload"); if (!payload.email_verified) throw new Error("Email not verified by Google"); return payload; } // ─── Session Persistence ───────────────────────────────────────────────────── async function upsertSession(payload) { try { await dynamo.send(new PutCommand({ TableName: SESSIONS_TABLE, Item: { userId: payload.sub, email: payload.email, name: payload.name, picture: payload.picture, lastSeen: new Date().toISOString(), // TTL: auto-expire session record after 7 days of inactivity ttl: Math.floor(Date.now() / 1000) + (7 * 24 * 60 * 60), }, })); } catch (err) { // Non-fatal — don't block auth if session write fails console.warn("[AUTH] Session upsert failed:", err.message); } } // ─── IAM Policy Builders ───────────────────────────────────────────────────── function allowPolicy(principalId, context = {}) { return { isAuthorized: true, context, // passed to downstream Lambdas via event.requestContext.authorizer.lambda }; } function denyPolicy(principalId, reason = "") { return { isAuthorized: false, context: { reason }, }; }