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>
132 lines
4.6 KiB
JavaScript
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 },
|
|
};
|
|
}
|