From 2452ca2d931d374fb1f968931af490ab4bf5f7e6 Mon Sep 17 00:00:00 2001 From: maramina Date: Thu, 22 Oct 2026 00:21:17 +0100 Subject: [PATCH] Validate API paths, sessions, and wallet CSP --- next.config.mjs | 13 ++- src/app/courses/[courseId]/layout.tsx | 5 +- .../courses/[courseId]/opengraph-image.tsx | 10 +- src/app/credentials/[credentialId]/layout.tsx | 3 +- .../[credentialId]/opengraph-image.tsx | 8 +- src/app/verify/[credentialId]/layout.tsx | 3 +- .../verify/[credentialId]/opengraph-image.tsx | 3 +- src/lib/api/auth.ts | 3 +- src/lib/api/client.ts | 29 +----- src/lib/api/courses.ts | 13 ++- src/lib/api/credentials.test.ts | 19 ++++ src/lib/api/credentials.ts | 28 +----- src/lib/api/index.ts | 3 +- src/lib/api/notifications.ts | 8 +- src/lib/api/path.test.ts | 22 +++++ src/lib/api/path.ts | 6 ++ src/lib/api/quizzes.ts | 7 +- src/middleware.test.ts | 64 +++++++++---- src/middleware.ts | 95 +++++++++++++++---- src/types/api.ts | 5 +- 20 files changed, 232 insertions(+), 115 deletions(-) create mode 100644 src/lib/api/path.test.ts create mode 100644 src/lib/api/path.ts diff --git a/next.config.mjs b/next.config.mjs index 754a019..6a74bee 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -7,8 +7,8 @@ const withBundleAnalyzer = bundleAnalyzer({ /** * CSP sources for Freighter wallet messaging and Stellar network/CDN resources. - * Freighter communicates via window.postMessage; chrome-extension: covers - * residual extension fetches. Horizon + Soroban RPC cover on-chain calls. + * Wallet extension origins are explicitly scoped. Horizon + Soroban RPC cover + * on-chain calls. */ function resolveApiOrigin() { try { @@ -23,6 +23,11 @@ function resolveApiOrigin() { const isDev = process.env.NODE_ENV !== "production"; const apiOrigin = resolveApiOrigin(); +const walletExtensionOrigins = [ + "chrome-extension://bcacfldlkkdogcmkkibnjlakofdplcbk", + "chrome-extension://ldiagbjmlmjiieclmdkagofdjcgodjle", + "chrome-extension://hgmoaheomcjnaheggkfafnjilfcefbmo", +]; const ContentSecurityPolicy = [ "default-src 'self'", @@ -45,9 +50,9 @@ const ContentSecurityPolicy = [ "https://*.stellar.gateway.fm", "https://stellar.expert", "https://*.stellar.expert", - "chrome-extension:", + ...walletExtensionOrigins, ].join(" "), - "frame-src 'self' chrome-extension:", + ["frame-src 'self'", ...walletExtensionOrigins].join(" "), "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", diff --git a/src/app/courses/[courseId]/layout.tsx b/src/app/courses/[courseId]/layout.tsx index 5d13fd9..d13e96b 100644 --- a/src/app/courses/[courseId]/layout.tsx +++ b/src/app/courses/[courseId]/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { apiClient } from "@/lib/api/client"; +import { encodePathParam } from "@/lib/api/path"; import type { Course } from "@/types/course"; interface PageProps { @@ -10,7 +11,9 @@ export async function generateMetadata({ params }: PageProps): Promise const { courseId } = params; try { - const response = await apiClient.get(`/courses/${courseId}`); + const response = await apiClient.get( + `/courses/${encodePathParam(courseId, "courseId")}` + ); const course = response.data; const images = course.imageUrl ? [ diff --git a/src/app/courses/[courseId]/opengraph-image.tsx b/src/app/courses/[courseId]/opengraph-image.tsx index 4973ad2..be8b5db 100644 --- a/src/app/courses/[courseId]/opengraph-image.tsx +++ b/src/app/courses/[courseId]/opengraph-image.tsx @@ -1,4 +1,5 @@ import { createOgImage, OG_SIZE } from "@/lib/utils/og-image"; +import { encodePathParam } from "@/lib/api/path"; import type { Course } from "@/types/course"; export const alt = "ChainLearn course"; @@ -18,9 +19,12 @@ export default async function Image({ params }: ImageProps) { "Earn tokens and verifiable credentials while learning on Stellar."; try { - const response = await fetch(`${API_URL}/courses/${courseId}`, { - next: { revalidate: 3600 }, - }); + const response = await fetch( + `${API_URL}/courses/${encodePathParam(courseId, "courseId")}`, + { + next: { revalidate: 3600 }, + } + ); if (response.ok) { const payload = (await response.json()) as { data?: Course } & Course; const course = payload.data ?? payload; diff --git a/src/app/credentials/[credentialId]/layout.tsx b/src/app/credentials/[credentialId]/layout.tsx index 501eb32..59b1dda 100644 --- a/src/app/credentials/[credentialId]/layout.tsx +++ b/src/app/credentials/[credentialId]/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { apiClient } from "@/lib/api/client"; +import { encodePathParam } from "@/lib/api/path"; import type { CredentialNFT } from "@/types/stellar"; interface PageProps { @@ -11,7 +12,7 @@ export async function generateMetadata({ params }: PageProps): Promise try { const response = await apiClient.get( - `/credentials/${credentialId}` + `/credentials/${encodePathParam(credentialId, "credentialId")}` ); const credential = response.data; const title = `${credential.courseTitle} - Credential`; diff --git a/src/app/credentials/[credentialId]/opengraph-image.tsx b/src/app/credentials/[credentialId]/opengraph-image.tsx index 5397fc6..e951641 100644 --- a/src/app/credentials/[credentialId]/opengraph-image.tsx +++ b/src/app/credentials/[credentialId]/opengraph-image.tsx @@ -1,4 +1,5 @@ import { createOgImage, OG_SIZE } from "@/lib/utils/og-image"; +import { encodePathParam } from "@/lib/api/path"; import type { CredentialNFT } from "@/types/stellar"; export const alt = "ChainLearn credential"; @@ -17,9 +18,10 @@ export default async function Image({ params }: ImageProps) { let description = "On-chain verifiable credential issued on the Stellar network."; try { - const response = await fetch(`${API_URL}/credentials/${credentialId}`, { - next: { revalidate: 3600 }, - }); + const response = await fetch( + `${API_URL}/credentials/${encodePathParam(credentialId, "credentialId")}`, + { next: { revalidate: 3600 } } + ); if (response.ok) { const payload = (await response.json()) as { data?: CredentialNFT; diff --git a/src/app/verify/[credentialId]/layout.tsx b/src/app/verify/[credentialId]/layout.tsx index 3d7fb0f..9c887fc 100644 --- a/src/app/verify/[credentialId]/layout.tsx +++ b/src/app/verify/[credentialId]/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { apiClient } from "@/lib/api/client"; +import { encodePathParam } from "@/lib/api/path"; interface PageProps { params: { credentialId: string }; @@ -12,7 +13,7 @@ export async function generateMetadata({ params }: PageProps): Promise const response = await apiClient.get<{ valid: boolean; metadata: { courseTitle: string }; - }>(`/credentials/${credentialId}/verify`); + }>(`/credentials/${encodePathParam(credentialId, "credentialId")}/verify`); const data = response.data; const title = `Verify - ${data.metadata.courseTitle}`; const description = `On-chain verification of ${data.metadata.courseTitle} credential on ChainLearn.`; diff --git a/src/app/verify/[credentialId]/opengraph-image.tsx b/src/app/verify/[credentialId]/opengraph-image.tsx index f989503..3c3c37c 100644 --- a/src/app/verify/[credentialId]/opengraph-image.tsx +++ b/src/app/verify/[credentialId]/opengraph-image.tsx @@ -1,4 +1,5 @@ import { createOgImage, OG_SIZE } from "@/lib/utils/og-image"; +import { encodePathParam } from "@/lib/api/path"; export const alt = "ChainLearn credential verification"; export const size = OG_SIZE; @@ -18,7 +19,7 @@ export default async function Image({ params }: ImageProps) { try { const response = await fetch( - `${API_URL}/credentials/${credentialId}/verify`, + `${API_URL}/credentials/${encodePathParam(credentialId, "credentialId")}/verify`, { next: { revalidate: 3600 } } ); if (response.ok) { diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index b4fcc8d..25daf6f 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -1,4 +1,5 @@ import { apiClient } from "./client"; +import { encodePathParam } from "./path"; import type { AuthTokens, UserProfile, UserSession } from "@/types/api"; import { useAuthStore } from "@/store/auth-store"; import { getTokenExpiry, isTokenExpired } from "@/lib/utils/jwt"; @@ -193,7 +194,7 @@ export async function revokeSession( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.delete<{ success: boolean }>( - `/auth/sessions/${sessionId}`, + `/auth/sessions/${encodePathParam(sessionId, "sessionId")}`, token, signal ); diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 5456539..5b7064b 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,7 +1,10 @@ -import { ApiError, ApiResponse } from "@/types/api"; +import { ApiError } from "@/types/api"; +import type { ApiResponse, ResponseInterceptor } from "@/types/api"; import { useAuthStore } from "@/store/auth-store"; import { useErrorStore } from "@/store/error-store"; +export type { ResponseInterceptor } from "@/types/api"; + /** * AbortController support (#315): * - signal parameter on all methods (get, post, put, delete) @@ -139,30 +142,6 @@ export function isAbortError(error: unknown): boolean { ); } -/** - * ResponseInterceptor (#314): - * - onResponse: pre-processing before handleResponse (transform raw Response) - * - onSuccess: post-processing after handleResponse (transform parsed data) - * - onError: error handler for logging/analytics - */ -export type ResponseInterceptor = { - /** - * Pre-processing interceptor running before `handleResponse`. - * Can inspect or transform the raw fetch `Response`. - */ - onResponse?: (response: Response) => Response | Promise; - /** - * Post-processing interceptor running after `handleResponse`. - * Can inspect or transform the parsed response data. - */ - onSuccess?: (data: ApiResponse) => ApiResponse | Promise>; - /** - * Error interceptor running whenever an error is encountered during a request. - * Can be used for global error logging or analytics events. - */ - onError?: (error: unknown) => void | Promise; -}; - class ApiClient { private baseUrl: string; private responseInterceptors: ResponseInterceptor[] = []; diff --git a/src/lib/api/courses.ts b/src/lib/api/courses.ts index af1931f..748f029 100644 --- a/src/lib/api/courses.ts +++ b/src/lib/api/courses.ts @@ -1,4 +1,5 @@ import { apiClient } from "./client"; +import { encodePathParam } from "./path"; import { getValidToken } from "./auth"; import type { PaginatedResponse } from "@/types/api"; import { getQuiz, submitQuiz } from "./quizzes"; @@ -77,7 +78,11 @@ export async function getCourse( jwt?: string, signal?: AbortSignal ): Promise { - const response = await apiClient.get(`/courses/${courseId}`, jwt, signal); + const response = await apiClient.get( + `/courses/${encodePathParam(courseId, "courseId")}`, + jwt, + signal + ); return response.data; } @@ -92,7 +97,7 @@ export async function enrollInCourse( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.post( - `/courses/${courseId}/enroll`, + `/courses/${encodePathParam(courseId, "courseId")}/enroll`, {}, token, signal @@ -118,7 +123,7 @@ export async function getModule( signal?: AbortSignal ): Promise { const response = await apiClient.get( - `/courses/${courseId}/modules/${moduleId}`, + `/courses/${encodePathParam(courseId, "courseId")}/modules/${encodePathParam(moduleId, "moduleId")}`, jwt, signal ); @@ -147,7 +152,7 @@ export async function markModuleComplete( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.post( - `/courses/${courseId}/modules/${moduleId}/complete`, + `/courses/${encodePathParam(courseId, "courseId")}/modules/${encodePathParam(moduleId, "moduleId")}/complete`, {}, token, signal diff --git a/src/lib/api/credentials.test.ts b/src/lib/api/credentials.test.ts index 627d47f..312ee2c 100644 --- a/src/lib/api/credentials.test.ts +++ b/src/lib/api/credentials.test.ts @@ -100,6 +100,25 @@ describe("verifyCredential", () => { expect(url).toMatch(/\/credentials\/verify\/cred-abc$/); }); + it("encodes credential IDs as one path segment", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: { valid: false }, success: true }) + ); + + await verifyCredential("cred/id?x#fragment"); + + expect(String(fetchMock.mock.calls[0][0])).toContain( + "/credentials/verify/cred%2Fid%3Fx%23fragment" + ); + }); + + it("rejects a blank credential ID before making a request", async () => { + await expect(verifyCredential(" ")).rejects.toThrow( + "credentialId must be a non-empty path identifier" + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("returns { valid: false, error: 'not_found' } on 404 without throwing", async () => { fetchMock.mockResolvedValueOnce( jsonResponse({ message: "Credential not found" }, 404) diff --git a/src/lib/api/credentials.ts b/src/lib/api/credentials.ts index 7b8e0b2..6b920cc 100644 --- a/src/lib/api/credentials.ts +++ b/src/lib/api/credentials.ts @@ -1,5 +1,7 @@ import { apiClient } from "./client"; import { ApiError } from "@/types/api"; +import { getValidToken } from "./auth"; +import { encodePathParam } from "./path"; import type { CredentialNFT } from "@/types/stellar"; /** Reason a public credential verification did not succeed (issue #309). */ @@ -20,9 +22,6 @@ export interface VerifyCredentialResult { verifiedAt?: string; error?: VerifyCredentialError; } -import { getValidToken } from "./auth"; -import type { CredentialNFT, CredentialMetadata } from "@/types/stellar"; - /** * Fetch all credentials for the authenticated user. */ @@ -49,7 +48,7 @@ export async function getCredential( signal?: AbortSignal ): Promise { const response = await apiClient.get( - `/credentials/${credentialId}`, + `/credentials/${encodePathParam(credentialId, "credentialId")}`, jwt, signal ); @@ -57,25 +56,6 @@ export async function getCredential( } /** - * Verify a credential publicly (no auth required). - * #507 — Fix property name: API returns data wrapped in ApiResponse, so we - * need to extract .data from the response envelope. - */ -export async function verifyCredential( - credentialId: string -): Promise<{ - valid: boolean; - metadata: CredentialMetadata; - verifiedAt: string; -}> { - const response = await apiClient.get<{ - valid: boolean; - metadata: CredentialMetadata; - verifiedAt: string; - }>(`/credentials/${credentialId}/verify`); - // ApiResponse wraps data in { data, message?, success }, so extract .data - return response.data; - * * Calls `GET /credentials/verify/:id` and returns a typed * `VerifyCredentialResult`. A 404 for a nonexistent credential is folded * into `{ valid: false, error: "not_found" }` so callers do not have to @@ -90,7 +70,7 @@ export async function verifyCredential( ): Promise { try { const response = await apiClient.get( - `/credentials/verify/${credentialId}`, + `/credentials/verify/${encodePathParam(credentialId, "credentialId")}`, undefined, signal ); diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index cec567e..c2cbec3 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -71,5 +71,6 @@ export { isAbortError, createLoggingInterceptor, createAnalyticsInterceptor, - type ResponseInterceptor, } from "./client"; + +export type { ResponseInterceptor } from "@/types/api"; diff --git a/src/lib/api/notifications.ts b/src/lib/api/notifications.ts index 4aebced..8bb23a4 100644 --- a/src/lib/api/notifications.ts +++ b/src/lib/api/notifications.ts @@ -1,4 +1,5 @@ import { apiClient } from "./client"; +import { encodePathParam } from "./path"; import { getValidToken } from "./auth"; import type { AppNotification } from "@/types/notification"; @@ -29,7 +30,12 @@ export async function markNotificationAsRead( ): Promise { const validToken = await getValidToken(); const token = validToken || jwt; - await apiClient.post(`/notifications/${id}/read`, {}, token, signal); + await apiClient.post( + `/notifications/${encodePathParam(id, "notificationId")}/read`, + {}, + token, + signal + ); } /** diff --git a/src/lib/api/path.test.ts b/src/lib/api/path.test.ts new file mode 100644 index 0000000..cf0b0dc --- /dev/null +++ b/src/lib/api/path.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { encodePathParam } from "./path"; + +describe("encodePathParam", () => { + it("encodes reserved URL characters as a single path segment", () => { + expect(encodePathParam("id/part?query#fragment", "id")).toBe( + "id%2Fpart%3Fquery%23fragment" + ); + }); + + it.each([".", ".."])("rejects dot segment %s", (value) => { + expect(() => encodePathParam(value, "id")).toThrow( + "id must be a non-empty path identifier" + ); + }); + + it.each(["", " ", "\t\n"])("rejects blank values", (value) => { + expect(() => encodePathParam(value, "id")).toThrow( + "id must be a non-empty path identifier" + ); + }); +}); \ No newline at end of file diff --git a/src/lib/api/path.ts b/src/lib/api/path.ts new file mode 100644 index 0000000..b2da53c --- /dev/null +++ b/src/lib/api/path.ts @@ -0,0 +1,6 @@ +export function encodePathParam(value: string, name: string): string { + if (!value.trim() || value === "." || value === "..") { + throw new TypeError(`${name} must be a non-empty path identifier.`); + } + return encodeURIComponent(value); +} \ No newline at end of file diff --git a/src/lib/api/quizzes.ts b/src/lib/api/quizzes.ts index 807dad7..305c81a 100644 --- a/src/lib/api/quizzes.ts +++ b/src/lib/api/quizzes.ts @@ -1,4 +1,5 @@ import { apiClient } from "./client"; +import { encodePathParam } from "./path"; import { getValidToken } from "./auth"; import type { Quiz, @@ -35,7 +36,7 @@ export async function getQuiz( signal?: AbortSignal ): Promise { const response = await apiClient.get( - `/courses/${courseId}/quiz`, + `/courses/${encodePathParam(courseId, "courseId")}/quiz`, jwt, signal ); @@ -98,7 +99,7 @@ export async function submitQuiz( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.post( - `/quizzes/${submission.quizId}/submit`, + `/quizzes/${encodePathParam(submission.quizId, "quizId")}/submit`, submission, token, signal @@ -125,7 +126,7 @@ export async function getQuizAttempts( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.get( - `/quizzes/${quizId}/attempts`, + `/quizzes/${encodePathParam(quizId, "quizId")}/attempts`, token, signal ); diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 1195f07..fc605d7 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import { config, middleware } from "./middleware"; +import { makeJwt } from "@/tests/helpers/jwt"; + +const ORIGIN = "https://chainlearn.test"; +const VALID_SESSION = makeJwt({ + sub: "GABC123", + exp: Math.floor(Date.now() / 1000) + 3600, +}); /** * Route protection at the Edge. @@ -10,8 +17,6 @@ import { config, middleware } from "./middleware"; * which a signed-out visitor can see dashboard content. */ -const ORIGIN = "https://chainlearn.test"; - /** * Minimal NextRequest stand-in — middleware only reads nextUrl and cookies. * `nextUrl` is a URL plus the `clone()` NextURL adds, which the middleware uses @@ -32,7 +37,7 @@ function makeRequest(pathname: string, sessionToken?: string, search = "") { url: url.toString(), cookies: { get: (name: string) => - name === "chainlearn-session" && sessionToken + name === "chainlearn-session" && sessionToken !== undefined ? { name, value: sessionToken } : undefined, }, @@ -66,8 +71,10 @@ describe("protected routes", () => { expect(target?.pathname).toBe("/connect"); }); - it.each(PROTECTED)("allows %s with a session", (pathname) => { - expect(redirectTarget(middleware(makeRequest(pathname, "jwt-abc")))).toBeNull(); + it.each(PROTECTED)("allows %s with a valid session", (pathname) => { + expect( + redirectTarget(middleware(makeRequest(pathname, VALID_SESSION))) + ).toBeNull(); }); it("preserves the intended destination so login can return there", () => { @@ -81,10 +88,34 @@ describe("protected routes", () => { it("redirects rather than rendering, so no protected markup is sent", () => { const response = middleware(makeRequest("/dashboard")); - // A 3xx means the body was never produced — that is what removes the flash. expect(response.status).toBeGreaterThanOrEqual(300); expect(response.status).toBeLessThan(400); }); + + it("rejects an expired JWT and clears the session cookie", () => { + const expired = makeJwt({ sub: "GABC123", exp: 1 }); + const response = middleware(makeRequest("/dashboard", expired)); + + expect(redirectTarget(response)?.pathname).toBe("/connect"); + expect(response.cookies.get("chainlearn-session")?.value).toBe(""); + }); + + it.each([ + "", + "not-a-jwt", + "a.b.c", + "..", + "eyJhbGciOiJub25lIn0.e30.c2ln", + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE5MDAwMDAwMDB9.a", + ])( + "rejects malformed session token %s and clears the cookie", + (token) => { + const response = middleware(makeRequest("/dashboard", token)); + + expect(redirectTarget(response)?.pathname).toBe("/connect"); + expect(response.cookies.get("chainlearn-session")?.value).toBe(""); + } + ); }); describe("public routes", () => { @@ -93,11 +124,12 @@ describe("public routes", () => { }); it.each(PUBLIC)("leaves %s accessible when signed in", (pathname) => { - expect(redirectTarget(middleware(makeRequest(pathname, "jwt-abc")))).toBeNull(); + expect( + redirectTarget(middleware(makeRequest(pathname, VALID_SESSION))) + ).toBeNull(); }); it("treats the /courses listing as public but its detail pages as protected", () => { - // The prefix check must distinguish "/courses" from "/courses/". expect(redirectTarget(middleware(makeRequest("/courses")))).toBeNull(); expect(redirectTarget(middleware(makeRequest("/courses/abc")))?.pathname).toBe( "/connect" @@ -111,24 +143,23 @@ describe("public routes", () => { describe("auth routes", () => { it("bounces a signed-in visitor away from /connect", () => { - expect(redirectTarget(middleware(makeRequest("/connect", "jwt-abc")))?.pathname).toBe( - "/dashboard" - ); + expect( + redirectTarget(middleware(makeRequest("/connect", VALID_SESSION)))?.pathname + ).toBe("/dashboard"); }); it("bounces a signed-in visitor away from /onboarding", () => { expect( - redirectTarget(middleware(makeRequest("/onboarding", "jwt-abc")))?.pathname + redirectTarget(middleware(makeRequest("/onboarding", VALID_SESSION)))?.pathname ).toBe("/dashboard"); }); it("honours the redirect parameter after signing in", () => { const target = redirectTarget( - middleware(makeRequest("/connect", "jwt-abc", "?redirect=/rewards")) + middleware(makeRequest("/connect", VALID_SESSION, "?redirect=/rewards")) ); expect(target?.pathname).toBe("/rewards"); - // The parameter is consumed, not carried into the destination. expect(target?.searchParams.get("redirect")).toBeNull(); }); @@ -152,8 +183,6 @@ describe("matcher", () => { }); it("does not run on public routes", () => { - // Scoping the matcher is what keeps Edge cold-start cost off the marketing - // pages and the public verification view. for (const pattern of config.matcher) { expect(pattern).not.toBe("/"); expect(pattern).not.toMatch(/^\/verify/); @@ -164,9 +193,6 @@ describe("matcher", () => { describe("edge runtime compatibility", () => { it("runs without Node built-ins available", () => { - // The middleware must not reach for anything outside the Edge API surface; - // a stray Node import would fail at deploy, not in a unit test, so this - // asserts the request path stays on Web APIs only. expect(() => middleware(makeRequest("/dashboard"))).not.toThrow(); expect(() => middleware(makeRequest("/dashboard", "jwt"))).not.toThrow(); }); diff --git a/src/middleware.ts b/src/middleware.ts index 355483c..aaece41 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -19,6 +19,21 @@ const PROTECTED_PREFIXES = [ */ const AUTH_PREFIXES = ["/connect", "/onboarding"]; +function decodeBase64UrlJson(segment: string): unknown | null { + try { + const normalized = segment.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + "=" + ); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + return null; + } +} + /** * /courses (the listing page) is public — only sub-paths are protected. * This helper distinguishes "/courses" from "/courses//…". @@ -31,43 +46,81 @@ function isAuthRoute(pathname: string): boolean { return AUTH_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } -/** - * Decode JWT payload and check expiry (#495). - * Uses atob() for base64 decoding — works in Edge runtime. - * Malformed tokens are treated as expired. - */ -function isJwtExpired(token: string): boolean { +/** Validate JWT structure and expiry; signature verification remains server-side. */ +function isValidSessionToken(token: string): boolean { try { const parts = token.split("."); - if (parts.length !== 3) return true; - const payload = JSON.parse(atob(parts[1])); - if (typeof payload.exp !== "number") return true; - // exp is in seconds; compare with current time in seconds - return payload.exp * 1000 < Date.now(); + if ( + parts.length !== 3 || + parts.some( + (part) => + !part || part.length % 4 === 1 || !/^[A-Za-z0-9_-]+$/.test(part) + ) + ) { + return false; + } + + const header = decodeBase64UrlJson(parts[0]); + if ( + !header || + typeof header !== "object" || + typeof (header as { alg?: unknown }).alg !== "string" || + (header as { alg: string }).alg.toLowerCase() === "none" + ) { + return false; + } + + const payload = decodeBase64UrlJson(parts[1]); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return false; + } + const expiry = (payload as { exp?: unknown }).exp; + return ( + typeof expiry === "number" && + Number.isFinite(expiry) && + expiry * 1000 > Date.now() + ); } catch { - return true; // malformed tokens are treated as expired + return false; + } +} + +function redirectToConnect(request: NextRequest, clearCookie = false) { + const url = request.nextUrl.clone(); + url.pathname = "/connect"; + if (isProtected(request.nextUrl.pathname)) { + url.searchParams.set("redirect", request.nextUrl.pathname); } + const response = NextResponse.redirect(url); + if (clearCookie) response.cookies.delete("chainlearn-session"); + return response; } export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - const sessionToken = request.cookies.get("chainlearn-session")?.value; + const sessionCookie = request.cookies.get("chainlearn-session"); + const sessionToken = sessionCookie?.value; + const validSession = sessionToken ? isValidSessionToken(sessionToken) : false; + + if (sessionCookie && !validSession) { + if (pathname === "/connect") { + const response = NextResponse.next(); + response.cookies.delete("chainlearn-session"); + return response; + } + return redirectToConnect(request, true); + } // --- Guard protected routes --- if (isProtected(pathname)) { - if (!sessionToken || isJwtExpired(sessionToken)) { - const url = request.nextUrl.clone(); - url.pathname = "/connect"; - // Preserve the intended destination so the connect page can redirect - // back after a successful wallet login. - url.searchParams.set("redirect", pathname); - return NextResponse.redirect(url); + if (!validSession) { + return redirectToConnect(request); } } // --- Bounce authenticated users away from auth pages --- if (isAuthRoute(pathname)) { - if (sessionToken && !isJwtExpired(sessionToken)) { + if (validSession) { const redirect = request.nextUrl.searchParams.get("redirect"); const url = request.nextUrl.clone(); url.pathname = redirect || "/dashboard"; diff --git a/src/types/api.ts b/src/types/api.ts index 8637704..21ac723 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -27,8 +27,9 @@ export interface RequestConfig extends Omit { } export interface ResponseInterceptor { - onSuccess?: (response: T) => T | Promise; - onError?: (error: unknown) => never | Promise; + onResponse?: (response: Response) => Response | Promise; + onSuccess?: (data: ApiResponse) => ApiResponse | Promise>; + onError?: (error: unknown) => void | Promise; } export interface UserSession {