From 4e669cd0ff23a6bc1b66cc80aa7eac35f3e88323 Mon Sep 17 00:00:00 2001 From: pitoi Date: Mon, 25 May 2026 13:06:27 +0000 Subject: [PATCH] Generated with Hive: Remove obsolete pre-auth landing page gate and related code --- .../api/auth-verify-landing.test.ts | 548 ------------------ .../unit/components/LandingPage.test.tsx | 143 ----- src/__tests__/unit/middleware/config.test.ts | 1 - .../unit/middleware/landing-cookie.test.ts | 440 -------------- src/app/api/auth/verify-landing/route.ts | 71 --- src/app/page.tsx | 19 +- src/components/LandingPage.tsx | 88 --- src/lib/auth/landing-cookie.ts | 129 ----- src/middleware.ts | 21 - 9 files changed, 1 insertion(+), 1459 deletions(-) delete mode 100644 src/__tests__/integration/api/auth-verify-landing.test.ts delete mode 100644 src/__tests__/unit/components/LandingPage.test.tsx delete mode 100644 src/__tests__/unit/middleware/landing-cookie.test.ts delete mode 100644 src/app/api/auth/verify-landing/route.ts delete mode 100644 src/components/LandingPage.tsx delete mode 100644 src/lib/auth/landing-cookie.ts diff --git a/src/__tests__/integration/api/auth-verify-landing.test.ts b/src/__tests__/integration/api/auth-verify-landing.test.ts deleted file mode 100644 index a81b9bd8d0..0000000000 --- a/src/__tests__/integration/api/auth-verify-landing.test.ts +++ /dev/null @@ -1,548 +0,0 @@ -import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; -import { POST } from "@/app/api/auth/verify-landing/route"; -import { - signCookie, - verifyCookie, - constantTimeCompare, - LANDING_COOKIE_NAME, - LANDING_COOKIE_MAX_AGE, -} from "@/lib/auth/landing-cookie"; -import { createPostRequest } from "@/__tests__/support/helpers/request-builders"; - -describe("POST /api/auth/verify-landing Integration Tests", () => { - const originalEnv = process.env; - - beforeEach(() => { - vi.clearAllMocks(); - // Reset environment to clean state - process.env = { ...originalEnv }; - process.env.LANDING_PAGE_PASSWORD = "test-password-123"; - process.env.NEXTAUTH_SECRET = "test-nextauth-secret-for-hmac-signing-32chars"; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - describe("Success scenarios", () => { - test("should return 200 and set signed cookie on valid password", async () => { - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password-123", - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.success).toBe(true); - expect(data.message).toBe("Access granted"); - - // Verify cookie was set - const setCookieHeader = response.headers.get("set-cookie"); - expect(setCookieHeader).toBeTruthy(); - expect(setCookieHeader).toContain(LANDING_COOKIE_NAME); - }); - - test("should set cookie with correct security flags", async () => { - // Test in production mode - const originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "production"; - - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password-123", - }); - - const response = await POST(request); - const setCookieHeader = response.headers.get("set-cookie"); - - expect(setCookieHeader).toContain("HttpOnly"); - expect(setCookieHeader).toContain("SameSite=lax"); - expect(setCookieHeader).toContain("Secure"); - expect(setCookieHeader).toContain("Path=/"); - expect(setCookieHeader).toContain(`Max-Age=${LANDING_COOKIE_MAX_AGE}`); - - process.env.NODE_ENV = originalNodeEnv; - }); - - test("should not set Secure flag in non-production environment", async () => { - process.env.NODE_ENV = "development"; - - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password-123", - }); - - const response = await POST(request); - const setCookieHeader = response.headers.get("set-cookie"); - - expect(setCookieHeader).toContain("HttpOnly"); - expect(setCookieHeader).toContain("SameSite=lax"); - expect(setCookieHeader).not.toContain("Secure"); - }); - - test("should generate valid HMAC-signed cookie that passes verification", async () => { - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password-123", - }); - - const response = await POST(request); - const setCookieHeader = response.headers.get("set-cookie"); - - // Extract cookie value - const cookieMatch = setCookieHeader?.match( - new RegExp(`${LANDING_COOKIE_NAME}=([^;]+)`) - ); - expect(cookieMatch).toBeTruthy(); - const cookieValue = cookieMatch![1]; - - // Verify the signed cookie - const isValid = await verifyCookie(cookieValue); - expect(isValid).toBe(true); - }); - - test("should generate cookie with timestamp that is within valid range", async () => { - const beforeTimestamp = Date.now(); - - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password-123", - }); - - const response = await POST(request); - const afterTimestamp = Date.now(); - - // Extract timestamp from the actual response cookie - const setCookieHeader = response.headers.get("set-cookie"); - expect(setCookieHeader).toBeTruthy(); - const cookieMatch = setCookieHeader?.match( - new RegExp(`${LANDING_COOKIE_NAME}=([^;]+)`) - ); - expect(cookieMatch).toBeTruthy(); - const cookieValue = cookieMatch![1]; - const [timestamp] = cookieValue.split("."); - - const timestampNum = parseInt(timestamp, 10); - expect(timestampNum).toBeGreaterThanOrEqual(beforeTimestamp); - expect(timestampNum).toBeLessThanOrEqual(afterTimestamp); - }); - }); - - describe("Authentication failures", () => { - test("should return 401 for incorrect password", async () => { - const request = createPostRequest("/api/auth/verify-landing", { - password: "wrong-password", - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(401); - expect(data.success).toBe(false); - expect(data.message).toBe("Incorrect password"); - - // Verify no cookie was set - const setCookieHeader = response.headers.get("set-cookie"); - expect(setCookieHeader).toBeNull(); - }); - - test("should return 400 for missing password field", async () => { - const request = createPostRequest("/api/auth/verify-landing", {}); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.message).toBe("Password is required"); - - const setCookieHeader = response.headers.get("set-cookie"); - expect(setCookieHeader).toBeNull(); - }); - - test("should return 400 for empty password string", async () => { - const request = createPostRequest("/api/auth/verify-landing", { - password: "", - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.message).toBe("Password is required"); - }); - - test("should return 400 for non-string password value", async () => { - const request = createPostRequest("/api/auth/verify-landing", { - password: 12345, - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.message).toBe("Password is required"); - }); - - test("should return 400 for null password", async () => { - const request = createPostRequest("/api/auth/verify-landing", { - password: null, - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.message).toBe("Password is required"); - }); - }); - - describe("Configuration validation", () => { - test("should return 400 when LANDING_PAGE_PASSWORD is not set", async () => { - delete process.env.LANDING_PAGE_PASSWORD; - - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password", - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.message).toBe("Landing page password is not enabled"); - }); - - test("should return 400 when LANDING_PAGE_PASSWORD is empty string", async () => { - process.env.LANDING_PAGE_PASSWORD = ""; - - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password", - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.message).toBe("Landing page password is not enabled"); - }); - - test("should return 400 when LANDING_PAGE_PASSWORD is whitespace only", async () => { - process.env.LANDING_PAGE_PASSWORD = " "; - - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password", - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.message).toBe("Landing page password is not enabled"); - }); - }); - - describe("Cookie signature security", () => { - test("should generate cookie with HMAC-SHA256 signature", async () => { - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password-123", - }); - - const response = await POST(request); - const setCookieHeader = response.headers.get("set-cookie"); - - const cookieMatch = setCookieHeader?.match( - new RegExp(`${LANDING_COOKIE_NAME}=([^;]+)`) - ); - const cookieValue = cookieMatch![1]; - - // Verify format: timestamp.signature - expect(cookieValue).toMatch(/^\d+\.[a-f0-9]{64}$/); - }); - - test("should reject cookie with tampered signature", async () => { - const timestamp = Date.now().toString(); - const tamperedCookie = `${timestamp}.invalid_signature_hash`; - - const isValid = await verifyCookie(tamperedCookie); - expect(isValid).toBe(false); - }); - - test("should reject cookie with tampered timestamp", async () => { - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password-123", - }); - - const response = await POST(request); - const setCookieHeader = response.headers.get("set-cookie"); - - const cookieMatch = setCookieHeader?.match( - new RegExp(`${LANDING_COOKIE_NAME}=([^;]+)`) - ); - const [originalTimestamp, signature] = cookieMatch![1].split("."); - - // Tamper with timestamp but keep original signature - const tamperedTimestamp = (parseInt(originalTimestamp, 10) + 1000).toString(); - const tamperedCookie = `${tamperedTimestamp}.${signature}`; - - const isValid = await verifyCookie(tamperedCookie); - expect(isValid).toBe(false); - }); - - test("should reject cookie with invalid format", async () => { - const invalidFormats = [ - "no-dot-separator", - "timestamp-only.", - ".signature-only", - "too.many.dots", - "", - ]; - - for (const invalidCookie of invalidFormats) { - const isValid = await verifyCookie(invalidCookie); - expect(isValid).toBe(false); - } - }); - }); - - describe("Cookie expiry validation", () => { - test("should reject expired cookie (older than 24 hours)", async () => { - const expiredTimestamp = (Date.now() - (LANDING_COOKIE_MAX_AGE + 1) * 1000).toString(); - const signedExpiredCookie = await signCookie(expiredTimestamp); - - const isValid = await verifyCookie(signedExpiredCookie); - expect(isValid).toBe(false); - }); - - test("should accept cookie within 24-hour validity window", async () => { - const recentTimestamp = (Date.now() - 1000 * 60 * 60).toString(); // 1 hour ago - const signedRecentCookie = await signCookie(recentTimestamp); - - const isValid = await verifyCookie(signedRecentCookie); - expect(isValid).toBe(true); - }); - - test("should reject cookie with future timestamp", async () => { - const futureTimestamp = (Date.now() + 1000 * 60 * 60).toString(); // 1 hour in future - const signedFutureCookie = await signCookie(futureTimestamp); - - const isValid = await verifyCookie(signedFutureCookie); - expect(isValid).toBe(false); - }); - - test("should reject cookie with non-numeric timestamp", async () => { - const invalidTimestamp = "not-a-number"; - const signature = "a".repeat(64); // Valid hex length - const invalidCookie = `${invalidTimestamp}.${signature}`; - - const isValid = await verifyCookie(invalidCookie); - expect(isValid).toBe(false); - }); - }); - - describe("Timing-attack resistance", () => { - test("constantTimeCompare should return true for identical strings", () => { - const password = "test-password-123"; - expect(constantTimeCompare(password, password)).toBe(true); - }); - - test("constantTimeCompare should return false for different strings of same length", () => { - const password1 = "test-password-123"; - const password2 = "test-password-456"; - expect(constantTimeCompare(password1, password2)).toBe(false); - }); - - test("constantTimeCompare should return false for different strings of different lengths", () => { - const password1 = "short"; - const password2 = "much-longer-password"; - expect(constantTimeCompare(password1, password2)).toBe(false); - }); - - test("constantTimeCompare should handle empty strings", () => { - expect(constantTimeCompare("", "")).toBe(true); - expect(constantTimeCompare("", "non-empty")).toBe(false); - expect(constantTimeCompare("non-empty", "")).toBe(false); - }); - - test("constantTimeCompare should handle special characters", () => { - const special1 = "p@ssw0rd!#$%"; - const special2 = "p@ssw0rd!#$%"; - const special3 = "p@ssw0rd!#$*"; - - expect(constantTimeCompare(special1, special2)).toBe(true); - expect(constantTimeCompare(special1, special3)).toBe(false); - }); - - test("endpoint should use constant-time comparison for password validation", async () => { - // This test verifies the endpoint behavior remains consistent - // regardless of password similarity (preventing timing attacks) - const correctPassword = "test-password-123"; - const veryClosePassword = "test-password-12X"; // Only last char different - const veryDifferentPassword = "zzzzzzzzzzzzzzzzz"; - - const request1 = createPostRequest("/api/auth/verify-landing", { - password: veryClosePassword, - }); - const request2 = createPostRequest("/api/auth/verify-landing", { - password: veryDifferentPassword, - }); - - const response1 = await POST(request1); - const response2 = await POST(request2); - - // Both should fail with same status code and message - expect(response1.status).toBe(401); - expect(response2.status).toBe(401); - - const data1 = await response1.json(); - const data2 = await response2.json(); - - expect(data1.message).toBe("Incorrect password"); - expect(data2.message).toBe("Incorrect password"); - }); - }); - - describe("Error handling", () => { - test("should return 400 for malformed JSON body", async () => { - const request = new Request("http://localhost:3000/api/auth/verify-landing", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: "invalid-json{", - }); - - const response = await POST(request as any); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.message).toBe("Invalid or missing JSON body"); - }); - - test("should handle missing NEXTAUTH_SECRET gracefully during signCookie", async () => { - const originalSecret = process.env.NEXTAUTH_SECRET; - delete process.env.NEXTAUTH_SECRET; - - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password-123", - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(500); - expect(data.success).toBe(false); - expect(data.message).toBe("An error occurred"); - - process.env.NEXTAUTH_SECRET = originalSecret; - }); - }); - - describe("Integration with middleware flow", () => { - test("should generate cookie that middleware can verify", async () => { - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password-123", - }); - - const response = await POST(request); - const setCookieHeader = response.headers.get("set-cookie"); - - const cookieMatch = setCookieHeader?.match( - new RegExp(`${LANDING_COOKIE_NAME}=([^;]+)`) - ); - const cookieValue = cookieMatch![1]; - - // Simulate middleware verification - const isValid = await verifyCookie(cookieValue); - expect(isValid).toBe(true); - }); - - test("should set cookie path to root for middleware access", async () => { - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password-123", - }); - - const response = await POST(request); - const setCookieHeader = response.headers.get("set-cookie"); - - expect(setCookieHeader).toContain("Path=/"); - }); - - test("should set httpOnly flag to prevent JavaScript access", async () => { - const request = createPostRequest("/api/auth/verify-landing", { - password: "test-password-123", - }); - - const response = await POST(request); - const setCookieHeader = response.headers.get("set-cookie"); - - expect(setCookieHeader).toContain("HttpOnly"); - }); - }); - - describe("Password validation edge cases", () => { - test("should validate password with leading/trailing whitespace as different", async () => { - process.env.LANDING_PAGE_PASSWORD = "test-password"; - - const request = createPostRequest("/api/auth/verify-landing", { - password: " test-password ", - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(401); - expect(data.success).toBe(false); - }); - - test("should validate password case-sensitively", async () => { - process.env.LANDING_PAGE_PASSWORD = "Test-Password"; - - const request1 = createPostRequest("/api/auth/verify-landing", { - password: "Test-Password", - }); - const request2 = createPostRequest("/api/auth/verify-landing", { - password: "test-password", - }); - - const response1 = await POST(request1); - const response2 = await POST(request2); - - expect(response1.status).toBe(200); - expect(response2.status).toBe(401); - }); - - test("should handle very long passwords correctly", async () => { - const longPassword = "a".repeat(1000); - process.env.LANDING_PAGE_PASSWORD = longPassword; - - const request = createPostRequest("/api/auth/verify-landing", { - password: longPassword, - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.success).toBe(true); - }); - - test("should handle unicode characters in password", async () => { - const unicodePassword = "test-ε―†η’Ό-πŸ”’-ΠΏΠ°Ρ€ΠΎΠ»ΡŒ"; - process.env.LANDING_PAGE_PASSWORD = unicodePassword; - - const request = createPostRequest("/api/auth/verify-landing", { - password: unicodePassword, - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.success).toBe(true); - }); - }); -}); diff --git a/src/__tests__/unit/components/LandingPage.test.tsx b/src/__tests__/unit/components/LandingPage.test.tsx deleted file mode 100644 index b1d1e2fa17..0000000000 --- a/src/__tests__/unit/components/LandingPage.test.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -globalThis.React = React; - -// ─── Mocks ──────────────────────────────────────────────────────────────────── - -const mockRouterPush = vi.fn(); - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: mockRouterPush }), -})); - -// Stub ui components with minimal HTML equivalents -vi.mock("@/components/ui/button", () => ({ - Button: ({ - children, - disabled, - onClick, - type, - className, - }: React.ButtonHTMLAttributes) => ( - - ), -})); - -vi.mock("@/components/ui/input", () => ({ - Input: (props: React.InputHTMLAttributes) => , -})); - -vi.mock("@/components/ui/alert", () => ({ - Alert: ({ children }: { children: React.ReactNode }) =>
{children}
, - AlertDescription: ({ children }: { children: React.ReactNode }) => {children}, -})); - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -describe("LandingPage", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - async function importComponent() { - const mod = await import("@/components/LandingPage"); - return mod.default; - } - - it("renders password gate by default", async () => { - const LandingPage = await importComponent(); - render(); - expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /continue/i })).toBeInTheDocument(); - }); - - it("shows error on wrong password response", async () => { - const LandingPage = await importComponent(); - mockFetch.mockResolvedValueOnce({ - json: async () => ({ success: false, message: "Incorrect password" }), - }); - - const user = userEvent.setup(); - render(); - - await user.type(screen.getByPlaceholderText("Password"), "wrongpass"); - await user.click(screen.getByRole("button", { name: /continue/i })); - - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - expect(screen.getByText("Incorrect password")).toBeInTheDocument(); - }); - }); - - it("shows generic error on fetch failure", async () => { - const LandingPage = await importComponent(); - mockFetch.mockRejectedValueOnce(new Error("Network error")); - - const user = userEvent.setup(); - render(); - - await user.type(screen.getByPlaceholderText("Password"), "somepass"); - await user.click(screen.getByRole("button", { name: /continue/i })); - - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - expect(screen.getByText("An error occurred. Please try again.")).toBeInTheDocument(); - }); - }); - - it("calls router.push('/onboarding/workspace') on correct password", async () => { - const LandingPage = await importComponent(); - mockFetch.mockResolvedValueOnce({ - json: async () => ({ success: true }), - }); - - const user = userEvent.setup(); - render(); - - await user.type(screen.getByPlaceholderText("Password"), "correctpass"); - await user.click(screen.getByRole("button", { name: /continue/i })); - - await waitFor(() => { - expect(mockRouterPush).toHaveBeenCalledWith("/onboarding/workspace"); - }); - }); - - it("Continue button is disabled when password is empty", async () => { - const LandingPage = await importComponent(); - render(); - expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled(); - }); - - it("shows loading state while verifying", async () => { - const LandingPage = await importComponent(); - // Never resolves β€” keeps the component in loading state - mockFetch.mockImplementationOnce(() => new Promise(() => {})); - - const user = userEvent.setup(); - render(); - - await user.type(screen.getByPlaceholderText("Password"), "somepass"); - await user.click(screen.getByRole("button", { name: /continue/i })); - - await waitFor(() => { - expect(screen.getByText(/verifying/i)).toBeInTheDocument(); - }); - }); -}); diff --git a/src/__tests__/unit/middleware/config.test.ts b/src/__tests__/unit/middleware/config.test.ts index a8ca460cf7..75bc87a7b3 100644 --- a/src/__tests__/unit/middleware/config.test.ts +++ b/src/__tests__/unit/middleware/config.test.ts @@ -238,7 +238,6 @@ describe("resolveRouteAccess", () => { expect(resolveRouteAccess("/auth/signin")).toBe("public"); expect(resolveRouteAccess("/api/auth/callback/github")).toBe("public"); expect(resolveRouteAccess("/api/auth/session")).toBe("public"); - expect(resolveRouteAccess("/api/auth/verify-landing")).toBe("public"); }); it("treats workspace page routes as middleware-public", () => { diff --git a/src/__tests__/unit/middleware/landing-cookie.test.ts b/src/__tests__/unit/middleware/landing-cookie.test.ts deleted file mode 100644 index 06dc3eb5b7..0000000000 --- a/src/__tests__/unit/middleware/landing-cookie.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; -import { - signCookie, - verifyCookie, - constantTimeCompare, - isLandingPageEnabled, - LANDING_COOKIE_MAX_AGE, -} from "@/lib/auth/landing-cookie"; - -// Store original env vars -const originalEnv = process.env; - -describe("signCookie", () => { - beforeEach(() => { - process.env.NEXTAUTH_SECRET = "test-secret-key-for-hmac-signing"; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it("signs a cookie value with HMAC-SHA256", async () => { - const timestamp = "1234567890"; - const signed = await signCookie(timestamp); - - expect(signed).toContain("."); - const [value, signature] = signed.split("."); - expect(value).toBe(timestamp); - expect(signature).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex = 64 chars - }); - - it("produces consistent signatures for the same input", async () => { - const timestamp = "1234567890"; - const signed1 = await signCookie(timestamp); - const signed2 = await signCookie(timestamp); - - expect(signed1).toBe(signed2); - }); - - it("produces different signatures for different inputs", async () => { - const signed1 = await signCookie("1234567890"); - const signed2 = await signCookie("9876543210"); - - expect(signed1).not.toBe(signed2); - }); - - it("throws error when NEXTAUTH_SECRET is missing", async () => { - delete process.env.NEXTAUTH_SECRET; - - await expect(signCookie("123")).rejects.toThrow( - "NEXTAUTH_SECRET is required for cookie signing" - ); - }); - - it("signs timestamps as strings", async () => { - const timestamp = Date.now().toString(); - const signed = await signCookie(timestamp); - - expect(signed).toContain(timestamp); - }); - - it("handles empty string values", async () => { - const signed = await signCookie(""); - - expect(signed).toMatch(/^\.[a-f0-9]{64}$/); - }); - - it("uses Web Crypto API for HMAC signing", async () => { - const timestamp = "1234567890"; - const signed = await signCookie(timestamp); - - // Verify signature format matches Web Crypto output - const [, signature] = signed.split("."); - expect(signature.length).toBe(64); // SHA-256 produces 64 hex chars - }); -}); - -describe("verifyCookie", () => { - beforeEach(() => { - process.env.NEXTAUTH_SECRET = "test-secret-key-for-hmac-signing"; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it("verifies valid signed cookie", async () => { - const timestamp = Date.now().toString(); - const signed = await signCookie(timestamp); - - const isValid = await verifyCookie(signed); - - expect(isValid).toBe(true); - }); - - it("rejects cookie with invalid signature", async () => { - const timestamp = Date.now().toString(); - const invalidSigned = `${timestamp}.invalid1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab`; - - const isValid = await verifyCookie(invalidSigned); - - expect(isValid).toBe(false); - }); - - it("rejects cookie with tampered timestamp", async () => { - const timestamp = Date.now().toString(); - const signed = await signCookie(timestamp); - - // Tamper with timestamp but keep signature - const [, signature] = signed.split("."); - const tamperedTimestamp = (parseInt(timestamp) + 1000).toString(); - const tampered = `${tamperedTimestamp}.${signature}`; - - const isValid = await verifyCookie(tampered); - - expect(isValid).toBe(false); - }); - - it("rejects expired cookie (> 24 hours old)", async () => { - const oldTimestamp = (Date.now() - (LANDING_COOKIE_MAX_AGE + 3600) * 1000).toString(); - const signed = await signCookie(oldTimestamp); - - const isValid = await verifyCookie(signed); - - expect(isValid).toBe(false); - }); - - it("accepts cookie within 24-hour window", async () => { - const recentTimestamp = (Date.now() - 3600 * 1000).toString(); // 1 hour ago - const signed = await signCookie(recentTimestamp); - - const isValid = await verifyCookie(signed); - - expect(isValid).toBe(true); - }); - - it("rejects cookie with future timestamp (negative age)", async () => { - const futureTimestamp = (Date.now() + 3600 * 1000).toString(); // 1 hour in future - const signed = await signCookie(futureTimestamp); - - const isValid = await verifyCookie(signed); - - expect(isValid).toBe(false); - }); - - it("rejects malformed cookie (no dot separator)", async () => { - const malformed = "1234567890abcdef"; - - const isValid = await verifyCookie(malformed); - - expect(isValid).toBe(false); - }); - - it("rejects malformed cookie (multiple dots)", async () => { - const malformed = "123.456.789"; - - const isValid = await verifyCookie(malformed); - - expect(isValid).toBe(false); - }); - - it("rejects cookie with non-numeric timestamp", async () => { - const malformed = "not-a-timestamp.abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; - - const isValid = await verifyCookie(malformed); - - expect(isValid).toBe(false); - }); - - it("returns false when NEXTAUTH_SECRET is missing", async () => { - const timestamp = Date.now().toString(); - const signed = await signCookie(timestamp); - - delete process.env.NEXTAUTH_SECRET; - - const isValid = await verifyCookie(signed); - - expect(isValid).toBe(false); - }); - - it("returns false on crypto exception", async () => { - const timestamp = Date.now().toString(); - const signed = await signCookie(timestamp); - - // Mock crypto.subtle to throw error - const originalSubtle = crypto.subtle; - Object.defineProperty(crypto, 'subtle', { - value: { - importKey: vi.fn().mockRejectedValue(new Error("Crypto error")), - }, - configurable: true, - }); - - const isValid = await verifyCookie(signed); - - expect(isValid).toBe(false); - - // Restore crypto.subtle - Object.defineProperty(crypto, 'subtle', { - value: originalSubtle, - configurable: true, - }); - }); - - it("handles empty signature part", async () => { - const timestamp = Date.now().toString(); - const malformed = `${timestamp}.`; - - const isValid = await verifyCookie(malformed); - - expect(isValid).toBe(false); - }); - - it("handles empty timestamp part", async () => { - const malformed = ".abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; - - const isValid = await verifyCookie(malformed); - - expect(isValid).toBe(false); - }); -}); - -describe("constantTimeCompare", () => { - it("returns true for identical strings", () => { - const result = constantTimeCompare("test", "test"); - - expect(result).toBe(true); - }); - - it("returns false for different strings of same length", () => { - const result = constantTimeCompare("test", "best"); - - expect(result).toBe(false); - }); - - it("returns false for different strings of different lengths", () => { - const result = constantTimeCompare("short", "longer string"); - - expect(result).toBe(false); - }); - - it("handles empty strings", () => { - const result1 = constantTimeCompare("", ""); - const result2 = constantTimeCompare("", "test"); - const result3 = constantTimeCompare("test", ""); - - expect(result1).toBe(true); - expect(result2).toBe(false); - expect(result3).toBe(false); - }); - - it("compares strings with special characters", () => { - const str1 = "test@example.com"; - const str2 = "test@example.com"; - const str3 = "test@example.net"; - - expect(constantTimeCompare(str1, str2)).toBe(true); - expect(constantTimeCompare(str1, str3)).toBe(false); - }); - - it("handles unicode characters", () => { - const str1 = "hello δΈ–η•Œ"; - const str2 = "hello δΈ–η•Œ"; - const str3 = "hello world"; - - expect(constantTimeCompare(str1, str2)).toBe(true); - expect(constantTimeCompare(str1, str3)).toBe(false); - }); - - it("pads shorter string to prevent timing leaks", () => { - // This test ensures the function doesn't short-circuit on length mismatch - const short = "abc"; - const long = "abcdefgh"; - - // Should take same time regardless of length difference - const start = performance.now(); - constantTimeCompare(short, long); - const end = performance.now(); - - // Verify it actually compares (doesn't just return false immediately) - expect(end - start).toBeGreaterThan(0); - expect(constantTimeCompare(short, long)).toBe(false); - }); - - it("performs constant-time comparison for security", () => { - // Test that comparison time doesn't leak information about where strings differ - const base = "a".repeat(100); - const diff1 = "b" + "a".repeat(99); // Differs at position 0 - const diff2 = "a".repeat(99) + "b"; // Differs at position 99 - - const times: number[] = []; - - // Measure multiple comparisons - for (let i = 0; i < 10; i++) { - const start1 = performance.now(); - constantTimeCompare(base, diff1); - const time1 = performance.now() - start1; - - const start2 = performance.now(); - constantTimeCompare(base, diff2); - const time2 = performance.now() - start2; - - times.push(Math.abs(time1 - time2)); - } - - // Time differences should be minimal (timing should be constant) - const avgDiff = times.reduce((a, b) => a + b, 0) / times.length; - expect(avgDiff).toBeLessThan(1); // Less than 1ms difference on average - }); - - it("compares hex strings (signature format)", () => { - const sig1 = "abcdef1234567890"; - const sig2 = "abcdef1234567890"; - const sig3 = "fedcba0987654321"; - - expect(constantTimeCompare(sig1, sig2)).toBe(true); - expect(constantTimeCompare(sig1, sig3)).toBe(false); - }); - - it("is case-sensitive", () => { - const result = constantTimeCompare("Test", "test"); - - expect(result).toBe(false); - }); -}); - -describe("isLandingPageEnabled", () => { - beforeEach(() => { - process.env.NODE_ENV = "development"; - process.env.LANDING_PAGE_PASSWORD = "test-password"; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it("returns true when landing page password is set", () => { - const enabled = isLandingPageEnabled(); - - expect(enabled).toBe(true); - }); - - it("returns false in test environment", () => { - process.env.NODE_ENV = "test"; - - const enabled = isLandingPageEnabled(); - - expect(enabled).toBe(false); - }); - - it("returns false when password is not set", () => { - delete process.env.LANDING_PAGE_PASSWORD; - - const enabled = isLandingPageEnabled(); - - expect(enabled).toBe(false); - }); - - it("returns false when password is empty string", () => { - process.env.LANDING_PAGE_PASSWORD = ""; - - const enabled = isLandingPageEnabled(); - - expect(enabled).toBe(false); - }); - - it("returns false when password is only whitespace", () => { - process.env.LANDING_PAGE_PASSWORD = " "; - - const enabled = isLandingPageEnabled(); - - expect(enabled).toBe(false); - }); - - it("returns true in production with valid password", () => { - process.env.NODE_ENV = "production"; - process.env.LANDING_PAGE_PASSWORD = "secure-password"; - - const enabled = isLandingPageEnabled(); - - expect(enabled).toBe(true); - }); - - it("returns true in development with valid password", () => { - process.env.NODE_ENV = "development"; - process.env.LANDING_PAGE_PASSWORD = "dev-password"; - - const enabled = isLandingPageEnabled(); - - expect(enabled).toBe(true); - }); -}); - -describe("Landing Cookie Integration", () => { - beforeEach(() => { - process.env.NEXTAUTH_SECRET = "test-secret-key"; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it("verifies freshly signed cookie", async () => { - const timestamp = Date.now().toString(); - const signed = await signCookie(timestamp); - const verified = await verifyCookie(signed); - - expect(verified).toBe(true); - }); - - it("rejects cookie signed with different secret", async () => { - const timestamp = Date.now().toString(); - const signed = await signCookie(timestamp); - - // Change secret - process.env.NEXTAUTH_SECRET = "different-secret"; - - const verified = await verifyCookie(signed); - - expect(verified).toBe(false); - }); - - it("handles cookie lifecycle from creation to expiration", async () => { - const timestamp = Date.now().toString(); - const signed = await signCookie(timestamp); - - // Verify immediately - expect(await verifyCookie(signed)).toBe(true); - - // Simulate time passing (but within 24 hours) - const oneHourAgo = (Date.now() - 3600 * 1000).toString(); - const recentSigned = await signCookie(oneHourAgo); - expect(await verifyCookie(recentSigned)).toBe(true); - - // Simulate expiration (> 24 hours) - const expiredTimestamp = (Date.now() - (LANDING_COOKIE_MAX_AGE + 1) * 1000).toString(); - const expiredSigned = await signCookie(expiredTimestamp); - expect(await verifyCookie(expiredSigned)).toBe(false); - }); -}); \ No newline at end of file diff --git a/src/app/api/auth/verify-landing/route.ts b/src/app/api/auth/verify-landing/route.ts deleted file mode 100644 index a0b7287f5a..0000000000 --- a/src/app/api/auth/verify-landing/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - signCookie, - constantTimeCompare, - LANDING_COOKIE_NAME, - LANDING_COOKIE_MAX_AGE, -} from "@/lib/auth/landing-cookie"; - -export async function POST(request: NextRequest) { - try { - let password = "" - try { - const body = await request.json(); - password = body.password - } catch (_) { - return NextResponse.json( - { success: false, message: "Invalid or missing JSON body"}, - { status: 400 } - ); - } - - // Check if landing page password is set - const landingPassword = process.env.LANDING_PAGE_PASSWORD; - if (!landingPassword || landingPassword.trim() === "") { - return NextResponse.json( - { success: false, message: "Landing page password is not enabled" }, - { status: 400 } - ); - } - - // Validate password input - if (!password || typeof password !== "string") { - return NextResponse.json( - { success: false, message: "Password is required" }, - { status: 400 } - ); - } - - // Use constant-time comparison to prevent timing attacks - const isValid = constantTimeCompare(password, landingPassword); - - if (!isValid) { - return NextResponse.json( - { success: false, message: "Incorrect password" }, - { status: 401 } - ); - } - - // Password correct - set signed verification cookie - const timestamp = Date.now().toString(); - const signedValue = await signCookie(timestamp); - - const response = NextResponse.json({ success: true, message: "Access granted" }); - - response.cookies.set(LANDING_COOKIE_NAME, signedValue, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: LANDING_COOKIE_MAX_AGE, - path: "/", - }); - - return response; - } catch (error) { - console.error("Error verifying landing page password:", error); - return NextResponse.json( - { success: false, message: "An error occurred" }, - { status: 500 } - ); - } -} diff --git a/src/app/page.tsx b/src/app/page.tsx index db6a5ba813..7a5a0cfb8c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,13 +1,7 @@ -import LandingPage from "@/components/LandingPage"; -import { - isLandingPageEnabled, - LANDING_COOKIE_NAME, - verifyCookie, -} from "@/lib/auth/landing-cookie"; import { authOptions } from "@/lib/auth/nextauth"; import { handleWorkspaceRedirect } from "@/lib/auth/workspace-resolver"; import { getServerSession } from "next-auth/next"; -import { cookies, headers } from "next/headers"; +import { headers } from "next/headers"; import { redirect } from "next/navigation"; export default async function HomePage() { @@ -19,17 +13,6 @@ export default async function HomePage() { return null; } - if (isLandingPageEnabled()) { - const cookieStore = await cookies(); - const landingCookie = cookieStore.get(LANDING_COOKIE_NAME); - const hasValidCookie = landingCookie && (await verifyCookie(landingCookie.value)); - if (hasValidCookie) { - redirect("/onboarding/workspace"); - } - return ; - } - - // Landing page disabled if (process.env.POD_URL) { redirect("/auth/signin"); } diff --git a/src/components/LandingPage.tsx b/src/components/LandingPage.tsx deleted file mode 100644 index 6a16286722..0000000000 --- a/src/components/LandingPage.tsx +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; - -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Loader2 } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { FormEvent, useState } from "react"; - -export default function LandingPage() { - const router = useRouter(); - const [password, setPassword] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(null); - setIsLoading(true); - - try { - const response = await fetch("/api/auth/verify-landing", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), - }); - - const data = await response.json(); - - if (data.success) { - router.push("/onboarding/workspace"); - } else { - setError(data.message || "Incorrect password"); - setIsLoading(false); - } - } catch { - setError("An error occurred. Please try again."); - setIsLoading(false); - } - }; - - return ( -
-
-
-
-

Welcome to Hive

-

Enter your access password to continue.

-
- -
- setPassword(e.target.value)} - disabled={isLoading} - autoFocus - required - className="h-12 text-base bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 focus-visible:ring-blue-500" - /> - - {error && ( - - {error} - - )} - - -
-
-
-
- ); -} diff --git a/src/lib/auth/landing-cookie.ts b/src/lib/auth/landing-cookie.ts deleted file mode 100644 index 3b401bca23..0000000000 --- a/src/lib/auth/landing-cookie.ts +++ /dev/null @@ -1,129 +0,0 @@ -export const LANDING_COOKIE_NAME = "landing_verified"; -export const LANDING_COOKIE_MAX_AGE = 60 * 60 * 24; // 24 hours in seconds - -/** - * Signs a cookie value with HMAC using Web Crypto API (Edge Runtime compatible) - * @param value - The value to sign (typically a timestamp) - * @returns Signed cookie value in format "value.signature" - */ -export async function signCookie(value: string): Promise { - const secret = process.env.NEXTAUTH_SECRET; - - if (!secret) { - throw new Error("NEXTAUTH_SECRET is required for cookie signing"); - } - - const encoder = new TextEncoder(); - const keyData = encoder.encode(secret); - const messageData = encoder.encode(value); - - // Import key for HMAC - const key = await crypto.subtle.importKey( - "raw", - keyData, - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - - // Sign the message - const signatureBuffer = await crypto.subtle.sign("HMAC", key, messageData); - - // Convert to hex string - const signatureArray = Array.from(new Uint8Array(signatureBuffer)); - const signature = signatureArray.map(b => b.toString(16).padStart(2, '0')).join(''); - - return `${value}.${signature}`; -} - -/** - * Verifies a signed cookie value using Web Crypto API (Edge Runtime compatible) - * @param signedValue - The signed cookie value to verify - * @returns true if signature is valid and not expired, false otherwise - */ -export async function verifyCookie(signedValue: string): Promise { - try { - const secret = process.env.NEXTAUTH_SECRET; - - if (!secret) { - return false; - } - - const parts = signedValue.split("."); - if (parts.length !== 2) { - return false; - } - - const [timestamp, signature] = parts; - - // Verify timestamp is a valid number - const timestampNum = parseInt(timestamp, 10); - if (isNaN(timestampNum)) { - return false; - } - - // Check if cookie has expired (24 hours) - const now = Date.now(); - const age = (now - timestampNum) / 1000; // Convert to seconds - if (age > LANDING_COOKIE_MAX_AGE || age < 0) { - return false; - } - - // Compute expected signature using Web Crypto API - const encoder = new TextEncoder(); - const keyData = encoder.encode(secret); - const messageData = encoder.encode(timestamp); - - const key = await crypto.subtle.importKey( - "raw", - keyData, - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - - const expectedSignatureBuffer = await crypto.subtle.sign("HMAC", key, messageData); - const expectedSignatureArray = Array.from(new Uint8Array(expectedSignatureBuffer)); - const expectedSignature = expectedSignatureArray.map(b => b.toString(16).padStart(2, '0')).join(''); - - // Constant-time comparison - return constantTimeCompare(signature, expectedSignature); - } catch (error) { - console.error("Error verifying landing cookie:", error); - return false; - } -} - -/** - * Constant-time string comparison to prevent timing attacks - * Works with both equal and unequal length strings - * @param a - First string to compare - * @param b - Second string to compare - * @returns true if strings are equal, false otherwise - */ -export function constantTimeCompare(a: string, b: string): boolean { - // If lengths differ, still compare to prevent timing leaks - const maxLength = Math.max(a.length, b.length); - const paddedA = a.padEnd(maxLength, '\0'); - const paddedB = b.padEnd(maxLength, '\0'); - - let result = 0; - for (let i = 0; i < maxLength; i++) { - result |= paddedA.charCodeAt(i) ^ paddedB.charCodeAt(i); - } - - return result === 0; -} - -/** - * Checks if landing page password protection is enabled - * @returns true if landing page is enabled and not in test environment - */ -export function isLandingPageEnabled(): boolean { - const landingPassword = process.env.LANDING_PAGE_PASSWORD; - return ( - process.env.NODE_ENV !== "test" && - !!landingPassword && - landingPassword.trim() !== "" - ); -} diff --git a/src/middleware.ts b/src/middleware.ts index 6645d1bd09..7e8968037b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,7 +2,6 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { getToken } from "next-auth/jwt"; import { MIDDLEWARE_HEADERS, resolveRouteAccess } from "@/config/middleware"; -import { verifyCookie, isLandingPageEnabled, LANDING_COOKIE_NAME } from "@/lib/auth/landing-cookie"; import type { ApiError } from "@/types/errors"; // Environment validation - fail fast if required secrets are missing if (!process.env.NEXTAUTH_SECRET) { @@ -136,26 +135,6 @@ export async function middleware(request: NextRequest) { return continueRequest(requestHeaders, "api-token"); } - // Landing page protection (when enabled) for all non-system/webhook routes - if (isLandingPageEnabled()) { - const token = await getToken({ - req: request, - secret: process.env.NEXTAUTH_SECRET, - secureCookie: shouldUseSecureCookie(request), - }); - const landingCookie = request.cookies.get(LANDING_COOKIE_NAME); - const hasValidCookie = landingCookie && (await verifyCookie(landingCookie.value)); - if (!hasValidCookie && !token) { - if (pathname === "/") { - return continueRequest(requestHeaders, "landing_required"); - } - if (pathname === "/api/auth/verify-landing") { - return continueRequest(requestHeaders, "landing_required"); - } - return redirectTo("/", request, { requestId, authStatus: "landing_required" }); - } - } - // Public routes are reachable without a session, but if the caller // DOES have a session we must still stamp the user headers so that // route handlers can distinguish "authenticated member" from