ledgerflow-backend/lambdas/authorizer/index.js
Adam Moussa 59127d5ab8 Initial commit — LedgerFlow backend
Lambda-based serverless backend with Google SSO, purchase orders,
invoices, and X12 810 EDI generation for Amazon Payee Central.
Includes bill-to/ship-to address support from Coupa purchase-orders table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 18:19:38 -04:00

132 lines
4.6 KiB
JavaScript

// 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 },
};
}