diff --git a/__tests__/authorize.test.ts b/__tests__/authorize.test.ts new file mode 100644 index 0000000..5843c41 --- /dev/null +++ b/__tests__/authorize.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { authorizeRequest } from "@/lib/auth/authorize"; + +const url = (path: string, host = "app.router.so") => + new URL(path, `https://${host}`); + +describe("authorizeRequest", () => { + it("redirects signed-out dashboard requests to the sign-in page", () => { + const result = authorizeRequest({ + authenticated: false, + nextUrl: url("/endpoints?tab=all"), + }); + expect(result).not.toBe(true); + if (result === true) throw new Error("expected a redirect"); + expect(result.status).toBe(307); + const location = new URL(result.headers.get("location")!); + expect(location.pathname).toBe("/login"); + expect(location.searchParams.get("callbackUrl")).toBe( + "https://app.router.so/endpoints?tab=all" + ); + }); + + it("does not redirect the sign-in page itself", () => { + expect( + authorizeRequest({ authenticated: false, nextUrl: url("/login") }) + ).toBe(true); + }); + + it("allows signed-in requests", () => { + expect( + authorizeRequest({ authenticated: true, nextUrl: url("/") }) + ).toBe(true); + }); + + it("keeps public form surfaces reachable without a session", () => { + for (const path of [ + "/f/abc", + "/embed/v1.js", + "/api/public/forms/abc/definition", + "/api/integrations/wordpress/connect", + ]) { + expect( + authorizeRequest({ authenticated: false, nextUrl: url(path) }) + ).toBe(true); + } + expect( + authorizeRequest({ + authenticated: false, + nextUrl: url("/abc", "forms.router.so"), + }) + ).toBe(true); + }); +}); diff --git a/lib/auth/authorize.ts b/lib/auth/authorize.ts new file mode 100644 index 0000000..e0fa1af --- /dev/null +++ b/lib/auth/authorize.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; + +export const SIGN_IN_PATH = "/login"; + +/** Surfaces that must stay reachable without a dashboard session. */ +export function isPublicFormSurface(url: { + hostname: string; + pathname: string; +}): boolean { + return ( + url.hostname === "forms.router.so" || + url.pathname.startsWith("/f/") || + url.pathname.startsWith("/embed/") || + url.pathname.startsWith("/api/public/") || + url.pathname.startsWith("/api/integrations/wordpress/") + ); +} + +/** + * next-auth only redirects unauthenticated requests to the sign-in page on + * its own when no custom middleware function is supplied. middleware.ts wraps + * `auth()` to rewrite the forms host, so the redirect must be returned from + * the `authorized` callback instead; next-auth honours a Response there. + */ +export function authorizeRequest({ + authenticated, + nextUrl, +}: { + authenticated: boolean; + nextUrl: URL; +}): true | NextResponse { + if ( + authenticated || + isPublicFormSurface(nextUrl) || + nextUrl.pathname === SIGN_IN_PATH + ) { + return true; + } + const signInUrl = new URL(SIGN_IN_PATH, nextUrl.href); + signInUrl.searchParams.set("callbackUrl", nextUrl.href); + return NextResponse.redirect(signInUrl); +} diff --git a/lib/auth/index.ts b/lib/auth/index.ts index c63313d..ee45be1 100644 --- a/lib/auth/index.ts +++ b/lib/auth/index.ts @@ -5,6 +5,7 @@ import type { NextAuthConfig } from "next-auth"; import { User } from "next-auth"; import Resend from "next-auth/providers/resend"; import GitHub from "next-auth/providers/github"; +import { authorizeRequest } from "./authorize"; declare module "next-auth" { interface Session extends User { @@ -38,17 +39,8 @@ export const config = { } return token; }, - authorized: async ({ auth, request }) => { - const hostname = request.nextUrl.hostname; - const pathname = request.nextUrl.pathname; - const isPublicFormSurface = - hostname === "forms.router.so" || - pathname.startsWith("/f/") || - pathname.startsWith("/embed/") || - pathname.startsWith("/api/public/") || - pathname.startsWith("/api/integrations/wordpress/"); - return isPublicFormSurface || !!auth; - }, + authorized: async ({ auth, request }) => + authorizeRequest({ authenticated: !!auth, nextUrl: request.nextUrl }), }, pages: { signIn: "/login",