Skip to content

Commit

Permalink
feat: update middleware, route handling, & validation to match the AP…
Browse files Browse the repository at this point in the history
…I & RLS (#27)
  • Loading branch information
Citronnelle committed Jan 18, 2025
1 parent 922e37c commit d51aaa3
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 85 deletions.
8 changes: 4 additions & 4 deletions docs/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -159,13 +159,13 @@ CREATE POLICY "Enable insert for users based on user_id" ON "public"."participan

CREATE POLICY "Enable insert for users based on user_id" ON "public"."stat_blocks" FOR INSERT TO "authenticated" WITH CHECK ((( SELECT "auth"."uid"() AS "uid") = "user_id"));

CREATE POLICY "Enable read access for all users" ON "public"."combat_logs" FOR SELECT USING (true);
CREATE POLICY "Enable read access for authenticated or anonymous users" ON "public"."combat_logs" FOR SELECT TO "anon", "authenticated" USING (true);

CREATE POLICY "Enable read access for all users" ON "public"."encounters" FOR SELECT USING (true);
CREATE POLICY "Enable read access for authenticated or anonymous users" ON "public"."encounters" FOR SELECT TO "anon", "authenticated" USING (true);

CREATE POLICY "Enable read access for all users" ON "public"."participants" FOR SELECT USING (true);
CREATE POLICY "Enable read access for authenticated or anonymous users" ON "public"."participants" FOR SELECT TO "anon", "authenticated" USING (true);

CREATE POLICY "Enable read access for all users" ON "public"."stat_blocks" FOR SELECT USING (true);
CREATE POLICY "Enable read access for authenticated or anonymous users" ON "public"."stat_blocks" FOR SELECT TO "anon", "authenticated" USING (true);

CREATE POLICY "Enable update for users based on user_id" ON "public"."combat_logs" FOR UPDATE TO "authenticated" USING ((( SELECT "auth"."uid"() AS "uid") = "user_id")) WITH CHECK ((( SELECT "auth"."uid"() AS "uid") = "user_id"));

Expand Down
52 changes: 16 additions & 36 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,25 @@
import { verifyToken } from "@/utils/jwt"
import { updateSession } from "@/utils/supabase/middleware"
import { type NextRequest, NextResponse } from "next/server"
import { NextRequest, NextResponse } from "next/server"
import { getUserIdFromRequest } from "./utils/api/request-utils"

const jwtConfig = {
secret: new TextEncoder().encode(process.env.JWT_SECRET),
}

export async function middleware(request: NextRequest) {
const userId = await getUserIdFromRequest(request)
const requestHeaders = new Headers(request.headers)
let userId = await getUserIdFromRequest(request)

// Optionally attach userId to the request if present
if (userId) {
request.headers.set("x-user-id", userId)
requestHeaders.set("x-user-id", userId)
} else {
console.info("No user ID found in token, proceeding as unauthenticated")
requestHeaders.set("x-user-id", "anonymous")
}

return updateSession(request) // Continue with session logic
}

export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
}

/**
* Extracts user ID from the request's authorization header.
* Supports both `NextRequest` and `Request`.
* @param req - The incoming request.
* @returns User ID if valid, otherwise null.
*/
export async function getUserIdFromRequest(
req: Request | NextRequest,
): Promise<string | null> {
const authHeader = req.headers.get("Authorization")
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return null
}
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
})

const token = authHeader.slice(7) // Remove "Bearer " prefix
try {
const decoded = verifyToken<{ sub: string }>(token)
return decoded.sub
} catch {
return null // Token verification failed
}
return response
}
19 changes: 12 additions & 7 deletions utils/api/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { getAuthToken } from "@/utils/api/token-utils"

export async function fetchMultipleResources<T>(url: string): Promise<T[]> {
export async function fetchMultipleResources<T>(
url: string,
): Promise<{ success: boolean; data: T[] }> {
const token = await getAuthToken()

const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Expand All @@ -17,20 +20,20 @@ export async function fetchMultipleResources<T>(url: string): Promise<T[]> {
}

export async function fetchByForeignKey<T>(
url: string,
baseUrl: string,
foreignKey: string,
value: string,
): Promise<T[]> {
const query = new URLSearchParams({ [foreignKey]: value }).toString()
const fullUrl = `${url}?${query}`
): Promise<{ success: boolean; data: T[] }> {
const fullUrl =
foreignKey === "path"
? `${baseUrl}/${value}`
: `${baseUrl}?${new URLSearchParams({ [foreignKey]: value }).toString()}`

return fetchMultipleResources<T>(fullUrl)
}

export async function fetchResource<T>(url: string): Promise<T> {
const token = await getAuthToken()
console.log(token)
console.log(url)
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Expand All @@ -47,6 +50,7 @@ export async function fetchResource<T>(url: string): Promise<T> {

export async function createResource<T, U>(url: string, data: U): Promise<T> {
const token = await getAuthToken()

const response = await fetch(url, {
method: "POST",
headers: {
Expand Down Expand Up @@ -83,6 +87,7 @@ export async function updateResource<T, U>(url: string, data: U): Promise<T> {

export async function deleteResource(url: string): Promise<void> {
const token = await getAuthToken()

const response = await fetch(url, {
method: "DELETE",
headers: {
Expand Down
7 changes: 6 additions & 1 deletion utils/api/create-route-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ type HandlerFunction = (id: string | null, req: NextRequest) => Promise<any>

export function createRouteHandler(
handler: HandlerFunction,
requiresAuth: boolean = true,
requiresId: boolean = true,
): (
req: NextRequest,
Expand All @@ -20,7 +21,11 @@ export function createRouteHandler(
)
}

const userIdOrError = await validateRequest(req, id || "")
// Skip user validation for read actions (when requiresAuth is false)
const userIdOrError = requiresAuth
? await validateRequest(req, true, id || null)
: null

if (userIdOrError instanceof NextResponse) {
return userIdOrError
}
Expand Down
75 changes: 75 additions & 0 deletions utils/api/request-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as jose from "jose"
import { NextRequest, NextResponse } from "next/server"

const jwtConfig = {
secret: new TextEncoder().encode(process.env.JWT_SECRET),
}

export async function getUserIdFromRequest(request: NextRequest) {
const token = request.headers.get("Authorization")
let userId: string | null = null

if (token) {
try {
const decoded = await jose.jwtVerify(
token.replace("Bearer ", ""),
jwtConfig.secret,
)

userId = decoded.payload.sub || null
} catch (err) {
console.error("Token verification failed:", err)
}
}

return userId
}

export async function getUserIdOrError(
request: NextRequest,
): Promise<string | NextResponse> {
const userId = await getUserIdFromRequest(request)

if (!userId) {
return NextResponse.json(
{ error: "User is not authenticated" },
{ status: 401 },
)
}

return userId
}

export async function fetchUserId(
baseUrl: string,
entityId: string,
pathName: string,
): Promise<string | null> {
try {
const url = `${baseUrl}/api/${pathName}/${entityId}/user-id`
const response = await fetch(url)
const data = await response.json()

if (response.ok && data?.user_id) {
return data.user_id
} else {
return null
}
} catch (error) {
console.error(`Error fetching user ID for resource:`, error)
return null
}
}

export async function getBaseUrl(request: NextRequest): Promise<string> {
// Retrieve the protocol (http or https) and host from the headers
const protocol = request.headers.get("x-forwarded-proto") || "http" // Default to "http"
const host = request.headers.get("host")

if (!host) {
throw new Error("Host header is missing")
}

// Return the full base URL (e.g., http://localhost:3000)
return `${protocol}://${host}`
}
9 changes: 0 additions & 9 deletions utils/api/user-utils.ts

This file was deleted.

69 changes: 41 additions & 28 deletions utils/api/validate-request.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,53 @@
import { getUserIdFromRequest } from "@/middleware"
import { createErrorResponse } from "./error-response"
import {
fetchUserId,
getBaseUrl,
getUserIdFromRequest,
} from "@/utils/api/request-utils"
import { createErrorResponse } from "@/utils/api/error-response"
import { NextRequest, NextResponse } from "next/server"

/**
* Validates that the request contains a valid user token.
* @param req - The incoming request.
* @returns User ID if valid, or an error response if invalid.
*/
export async function validateUserToken(
req: Request | NextRequest,
): Promise<string | NextResponse> {
const userId = await getUserIdFromRequest(req)
if (!userId) {
return createErrorResponse("Unauthorized", 401)
}
return userId
function getResourceTypeFromPath(path: string): string | null {
const pathname = new URL(path).pathname
const parts = pathname.split("/")
const resourceType = parts[2] // Assuming the path is like '/api/resource-type/{id}'
return resourceType || null
}

/**
* Validates that the request contains a valid user token.
* @param req - The incoming request.
* @param expectedUserId - The expected user ID.
* @returns The user ID if valid, or an error response if invalid.
*/
export async function validateRequest(
req: Request,
expectedUserId: string,
request: NextRequest,
requiresAuth: boolean = true,
resourceId: string | null = null,
): Promise<string | NextResponse> {
const userId = await getUserIdFromRequest(req as any);
const userId = await getUserIdFromRequest(request)

// Handle anonymous access if authentication is not required
if (!userId) {
return createErrorResponse("Unauthorized", 401);
if (requiresAuth) {
return createErrorResponse("Unauthorized", 401) // Authentication required
}
return NextResponse.next() // Allow anonymous access for read-only actions
}

if (userId !== expectedUserId) {
return createErrorResponse("Forbidden", 403);
// Enforce user ID check for authenticated actions
if (resourceId) {
const resourceType = getResourceTypeFromPath(request.url)
if (!resourceType) {
return createErrorResponse("Resource type not found", 404)
}

let expectedUserId: string | null = null

if (resourceType === "profiles") {
expectedUserId = resourceId
} else {
let baseUrl = await getBaseUrl(request)
expectedUserId = await fetchUserId(baseUrl, resourceId, resourceType)
}

if (expectedUserId && userId !== expectedUserId) {
return createErrorResponse("Forbidden", 403) // Forbidden if user IDs don't match
}
}

return userId;
return userId // Return valid user ID
}

0 comments on commit d51aaa3

Please sign in to comment.