From 744115d2303a5dc7007cf13522b44c4fd8e9773b Mon Sep 17 00:00:00 2001 From: "open-swe-dev-andrinoid[bot]" <255810345+open-swe-dev-andrinoid[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:47:50 -0600 Subject: [PATCH] feat: Add Microsoft Azure AD authentication environment variables to .env.example (#115) * Apply patch [skip ci] * Empty commit to trigger CI * Apply patch [skip ci] * Apply patch [skip ci] * Apply patch [skip ci] * Apply patch [skip ci] * Apply patch [skip ci] * Apply patch [skip ci] * Apply patch [skip ci] * Apply patch [skip ci] * Apply patch [skip ci] * Apply patch [skip ci] --------- Co-authored-by: open-swe-dev-andrinoid[bot] --- .env.example | 45 +++++++++++++++++++++ deno.json | 3 +- routes/api/delete/lease_document.ts | 48 +++++++++++++++++++++- routes/api/delete/property_media.ts | 42 ++++++++++++++++++- routes/api/delete/user.ts | 44 +++++++++++++++++++- routes/api/delete/user_role.ts | 48 +++++++++++++++++++++- routes/api/payments/webhook.ts | 62 ++++++++++------------------- 7 files changed, 243 insertions(+), 49 deletions(-) mode change 100755 => 100644 deno.json diff --git a/.env.example b/.env.example index 52efed8..783efff 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,48 @@ STRIPE_WEBHOOK_SECRET="" # Get this from your Stripe Dashboard: https://dashboard.stripe.com/apikeys # Example: pk_test_51AbCdEfGhIjKlMnOpQrStUvWxYz1234567890 STRIPE_PUBLISHABLE_KEY="" + +# Microsoft Azure AD Authentication +# AUTHORITY: Microsoft authority URL for authentication +# Format: https://login.microsoftonline.com/{tenantId} +# Get tenantId from Azure Portal: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade +# Example: https://login.microsoftonline.com/8a8b8c8d-9e9f-0a0b-1c2d-3e4f5a6b7c8d +AUTHORITY="" + +# CLIENT_ID: Microsoft application client ID (from Azure AD app registration) +# Get this from Azure Portal: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade +# Navigate to your app registration and copy the Application (client) ID +# Example: 12345678-1234-1234-1234-123456789012 +CLIENT_ID="" + +# CLIENT_SECRET: Microsoft application client secret (server-side use only) +# Get this from Azure Portal: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade +# Navigate to your app registration > Certificates & secrets > New client secret +# IMPORTANT: This secret is used server-side only and should never be committed to version control +# Example: abcdefghijklmnopqrstuvwxyz1234567890 +CLIENT_SECRET="" + +# TENANT_ID: Microsoft Azure tenant ID +# Get this from Azure Portal: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade +# Navigate to your app registration and copy the Directory (tenant) ID +# Example: 8a8b8c8d-9e9f-0a0b-1c2d-3e4f5a6b7c8d +TENANT_ID="" + +# REDIRECT_URI: OAuth redirect URI (must match Azure AD app registration) +# This must exactly match the Redirect URI configured in your Azure AD app registration +# For local development: http://localhost:8080/auth/callback +# For production: https://yourdomain.com/auth/callback +# Example: http://localhost:8080/auth/callback +REDIRECT_URI="" + +# TOKEN_URL: Token endpoint URL for OAuth token exchange +# Format: https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token +# Example: https://login.microsoftonline.com/8a8b8c8d-9e9f-0a0b-1c2d-3e4f5a6b7c8d/oauth2/v2.0/token +TOKEN_URL="" + +# REDIRECT_HOME: Redirect home URL (base URL for the application) +# This is the base URL where users are redirected after authentication +# For local development: http://localhost:8080 +# For production: https://yourdomain.com +# Example: http://localhost:8080 +REDIRECT_HOME="" diff --git a/deno.json b/deno.json old mode 100755 new mode 100644 index ea4c61c..f15f885 --- a/deno.json +++ b/deno.json @@ -46,7 +46,8 @@ "google-cloud-storage": "npm:@google-cloud/storage", "gLog": "npm:google-logging-utils", "cosmos": "npm:@azure/cosmos", - "@playwright/test": "npm:@playwright/test" + "@playwright/test": "npm:@playwright/test", + "stripe": "npm:stripe@^17.3.0" }, "compilerOptions": { "lib": [ diff --git a/routes/api/delete/lease_document.ts b/routes/api/delete/lease_document.ts index 45ca5c9..3974506 100644 --- a/routes/api/delete/lease_document.ts +++ b/routes/api/delete/lease_document.ts @@ -1,12 +1,27 @@ // routes/api/delete/lease_document.ts import { define } from "../../../utils.ts"; -import { deleteLeaseDocument } from "../../../services/leaseDocumentService.ts"; +import { + deleteLeaseDocument, + getDocumentById, +} from "../../../services/leaseDocumentService.ts"; import { SessionCookie } from "../../auth/callback.ts"; +import { + getLeasesByOwner, + getLeasesByTenant, +} from "../../../services/leaseService.ts"; export const handler = define.handlers({ async POST(ctx) { try { const stateData: SessionCookie = ctx.state.data; + const requestingUserId = stateData.user_id; + + if (!requestingUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } const contentType = ctx.req.headers.get("content-type") || ""; let data: Record = {}; @@ -41,6 +56,37 @@ export const handler = define.handlers({ ); } + const document = await getDocumentById(id); + if (!document) { + return new Response( + JSON.stringify({ + error: "Document not found", + }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const lease = await getLeasesByTenant(requestingUserId).then((leases) => + leases.find((l) => l.id === document.lease_id) + ); + const ownerLeases = await getLeasesByOwner(requestingUserId); + const isOwner = ownerLeases.some((l) => l.id === document.lease_id); + + if (!lease && !isOwner) { + return new Response( + JSON.stringify({ + error: "Access denied - you must be the tenant or property owner", + }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + }, + ); + } + const result = await deleteLeaseDocument(id); // Return appropriate response based on request type diff --git a/routes/api/delete/property_media.ts b/routes/api/delete/property_media.ts index 7169274..db68111 100644 --- a/routes/api/delete/property_media.ts +++ b/routes/api/delete/property_media.ts @@ -1,12 +1,24 @@ // routes/api/delete/property_media.ts import { define } from "../../../utils.ts"; -import { deletePropertyMedia } from "../../../services/propertyMediaService.ts"; +import { + deletePropertyMedia, + getMediaByProperty, +} from "../../../services/propertyMediaService.ts"; import { SessionCookie } from "../../auth/callback.ts"; +import { getPropertiesByOwner } from "../../../services/propertyService.ts"; export const handler = define.handlers({ async POST(ctx) { try { const stateData: SessionCookie = ctx.state.data; + const requestingUserId = stateData.user_id; + + if (!requestingUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } const contentType = ctx.req.headers.get("content-type") || ""; let data: Record = {}; @@ -41,6 +53,34 @@ export const handler = define.handlers({ ); } + const media = await getMediaByProperty(id); + if (!media || media.length === 0) { + return new Response( + JSON.stringify({ + error: "Media not found", + }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const property = await getPropertiesByOwner(requestingUserId); + const isOwner = property.some((p) => p.id === media[0].property_id); + + if (!isOwner) { + return new Response( + JSON.stringify({ + error: "Access denied - you must be the property owner", + }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + }, + ); + } + const result = await deletePropertyMedia(id); // Return appropriate response based on request type diff --git a/routes/api/delete/user.ts b/routes/api/delete/user.ts index ec28aac..04c598c 100644 --- a/routes/api/delete/user.ts +++ b/routes/api/delete/user.ts @@ -1,10 +1,21 @@ // routes/api/delete/users.ts import { define } from "../../../utils.ts"; -import { deleteUser } from "../../../services/userService.ts"; +import { deleteUser, getUser } from "../../../services/userService.ts"; import { SessionCookie } from "../../auth/callback.ts"; export const handler = define.handlers({ async POST(ctx) { + const stateData: SessionCookie = ctx.state.data; + const requestingUserId = stateData.user_id; + const requestingUserRole = stateData.role; + + if (!requestingUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + const formData = await ctx.req.formData(); const user_id = formData.get("user_id"); const sender = formData.get("sender"); @@ -13,8 +24,37 @@ export const handler = define.handlers({ return new Response("Missing user_id or sender", { status: 400 }); } + const targetUserId = user_id.toString(); + + if (requestingUserId === targetUserId) { + return new Response( + JSON.stringify({ error: "Cannot delete your own account" }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const targetUser = await getUser(targetUserId); + if (!targetUser) { + return new Response(JSON.stringify({ error: "User not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + const isAdmin = requestingUserRole.includes("admin") || + targetUser.user_type === "admin"; + if (!isAdmin) { + return new Response(JSON.stringify({ error: "Admin access required" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + // Delete the user - await deleteUser(user_id.toString()); + await deleteUser(targetUserId); // Redirect back to the sender URL (must be absolute) const senderPath = sender.toString(); diff --git a/routes/api/delete/user_role.ts b/routes/api/delete/user_role.ts index 06ecc84..d4558a8 100644 --- a/routes/api/delete/user_role.ts +++ b/routes/api/delete/user_role.ts @@ -1,10 +1,25 @@ // routes/api/delete/user_roles.ts import { define } from "../../../utils.ts"; -import { deleteUserRole } from "../../../services/userService.ts"; +import { + deleteUserRole, + getAllRoles, + getUser, +} from "../../../services/userService.ts"; import { SessionCookie } from "../../auth/callback.ts"; export const handler = define.handlers({ async POST(ctx) { + const stateData: SessionCookie = ctx.state.data; + const requestingUserId = stateData.user_id; + const requestingUserRole = stateData.role; + + if (!requestingUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + const formData = await ctx.req.formData(); const user_id = formData.get("user_id"); const sender = formData.get("sender"); @@ -13,7 +28,36 @@ export const handler = define.handlers({ return new Response("Missing user_id or sender", { status: 400 }); } - await deleteUserRole(user_id.toString()); + const targetUserId = user_id.toString(); + + if (requestingUserId === targetUserId) { + return new Response( + JSON.stringify({ error: "Cannot remove your own role" }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const targetUser = await getUser(targetUserId); + if (!targetUser) { + return new Response(JSON.stringify({ error: "User not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + const isAdmin = requestingUserRole.includes("admin") || + targetUser.user_type === "admin"; + if (!isAdmin) { + return new Response(JSON.stringify({ error: "Admin access required" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + await deleteUserRole(targetUserId); // Redirect back to the sender URL (must be absolute) const senderPath = sender.toString(); diff --git a/routes/api/payments/webhook.ts b/routes/api/payments/webhook.ts index 1fb1112..ee79942 100644 --- a/routes/api/payments/webhook.ts +++ b/routes/api/payments/webhook.ts @@ -3,32 +3,7 @@ import { createPaymentTransaction, getTransactionByStripeId, } from "../../../services/paymentService.ts"; - -async function hmacSHA256Hex(key: string, message: string): Promise { - const enc = new TextEncoder(); - const cryptoKey = await crypto.subtle.importKey( - "raw", - enc.encode(key), - { name: "HMAC", hash: { name: "SHA-256" } }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(message)); - const bytes = new Uint8Array(sig); - return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -function parseStripeSignature(header: string | null) { - if (!header) return null; - const parts = header.split(",").map((p) => p.trim()); - const out: Record = {}; - for (const part of parts) { - const [k, v] = part.split("="); - if (k && v) out[k] = v; - } - if (!out["t"] || !out["v1"]) return null; - return { timestamp: out["t"], signature: out["v1"] }; -} +import Stripe from "stripe"; export const handler = define.handlers({ async POST(ctx) { @@ -72,23 +47,26 @@ export const handler = define.handlers({ headers: { "Content-Type": "application/json" }, }); } - const parsed = parseStripeSignature(sigHeader); - if (!parsed) { - return new Response(JSON.stringify({ error: "Bad signature header" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - const signedPayload = `${parsed.timestamp}.${raw}`; - const expected = await hmacSHA256Hex(secret, signedPayload); - if (expected !== parsed.signature) { - return new Response(JSON.stringify({ error: "Invalid signature" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - const event = JSON.parse(raw || "{}"); + let event; + try { + event = Stripe.webhooks.constructEvent( + raw, + sigHeader, + secret, + ); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + return new Response( + JSON.stringify({ + error: `Webhook verification failed: ${errorMessage}`, + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } if (event?.type === "payment_intent.succeeded") { const obj = event.data?.object ?? {}; const stripeId = obj.id as string;