diff --git a/apps/dashboard/app/auth/layout.tsx b/apps/dashboard/app/auth/layout.tsx index f50fe55c3d..e0b0320bd9 100644 --- a/apps/dashboard/app/auth/layout.tsx +++ b/apps/dashboard/app/auth/layout.tsx @@ -1,7 +1,7 @@ import { FadeIn } from "@/components/landing/fade-in"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Separator } from "@/components/ui/separator"; -import { auth } from "@/lib/auth/server"; +import { getAuth } from "@/lib/auth/get-auth"; import { FileText } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -75,9 +75,9 @@ export default async function AuthenticatedLayout({ }: { children: React.ReactNode; }) { - const user = await auth.getCurrentUser(); + const { userId } = await getAuth(); // we want the uncached version since the cached version redirects to sign-in - if (user) { + if (userId) { return redirect("/apis"); } const quote = quotes[Math.floor(Math.random() * quotes.length)]; diff --git a/apps/dashboard/app/new/create-ratelimit.tsx b/apps/dashboard/app/new/create-ratelimit.tsx index 15e7f411a2..7518c7930e 100644 --- a/apps/dashboard/app/new/create-ratelimit.tsx +++ b/apps/dashboard/app/new/create-ratelimit.tsx @@ -1,7 +1,6 @@ import { CopyButton } from "@/components/dashboard/copy-button"; import { Code } from "@/components/ui/code"; -import { getOrgId } from "@/lib/auth"; -import { auth } from "@/lib/auth/server"; +import { getCurrentUser } from "@/lib/auth"; import { router } from "@/lib/trpc/routers"; import { createCallerFactory } from "@trpc/server"; import type { Workspace } from "@unkey/db"; @@ -14,11 +13,10 @@ type Props = { }; export const CreateRatelimit: React.FC = async (props) => { - const user = await auth.getCurrentUser(); + const user = await getCurrentUser(); if (!user) { return null; } - const orgId = await getOrgId(); const trpc = createCallerFactory()(router)({ req: {} as any, @@ -27,7 +25,7 @@ export const CreateRatelimit: React.FC = async (props) => { }, workspace: props.workspace, tenant: { - id: orgId, + id: user.orgId!, // if you have a workspace, you will have an orgId }, audit: { location: "", diff --git a/apps/dashboard/app/new/page.tsx b/apps/dashboard/app/new/page.tsx index 4778cc512d..3d61397f5c 100644 --- a/apps/dashboard/app/new/page.tsx +++ b/apps/dashboard/app/new/page.tsx @@ -1,6 +1,6 @@ import { PageHeader } from "@/components/dashboard/page-header"; import { Separator } from "@/components/ui/separator"; -import { auth } from "@/lib/auth/server"; +import { getAuth } from "@/lib/auth"; import { db } from "@/lib/db"; import { Button } from "@unkey/ui"; import { ArrowRight, GlobeLock, KeySquare } from "lucide-react"; @@ -22,28 +22,10 @@ type Props = { }; }; -// Currently unused in this page. -/* function getBaseUrl() { - if (typeof window !== "undefined") { - // browser should use relative path - return ""; - } - - if (process.env.VERCEL_URL) { - // reference for vercel.com - return `https://${process.env.VERCEL_URL}`; - } - - // assume localhost - return `http://localhost:${process.env.PORT ?? 3000}`; -} */ - export default async function (props: Props) { - const user = await auth.getCurrentUser(); - // make typescript happy - if (!user) { - return redirect("/auth/sign-in"); - } + // ensure we have an authenticated user + // we don't actually need any user data though + await getAuth(); if (props.searchParams.apiId) { const api = await db.query.apis.findFirst({ diff --git a/apps/dashboard/lib/auth.ts b/apps/dashboard/lib/auth.ts index f204db2cd3..a9c4b17b87 100644 --- a/apps/dashboard/lib/auth.ts +++ b/apps/dashboard/lib/auth.ts @@ -1,29 +1,77 @@ +import { getAuth as noCacheGetAuth } from "@/lib/auth/get-auth"; import { auth } from "@/lib/auth/server"; +import type { User } from "@/lib/auth/types"; import { redirect } from "next/navigation"; +import { cache } from "react"; + +type GetAuthResult = { + userId: string | null; + orgId: string | null; +}; + +export async function getIsImpersonator(): Promise { + const user = await getCurrentUser(); + return user.impersonator !== undefined; +} /** - * Return the org id or a 404 not found page. + * Validates the current user session and performs token refresh if needed. + * + * This function checks for a valid authentication cookie, validates the session, + * and handles token refreshing if the current token is expired but refreshable. + * Results are cached for the duration of the server request to prevent + * multiple validation calls. * - * The auth check should already be done at a higher level, and we're just returning 404 to make typescript happy. + * @param _req - Optional request object (not used but maintained for compatibility) + * @returns Authentication result containing userId and orgId if authenticated, null values otherwise + * @throws Redirects to sign-in or organization/workspace creation pages if requirements aren't met */ -export async function getOrgId(): Promise { - const user = await auth.getCurrentUser(); - if (!user) { +export const getAuth = cache(async (_req?: Request): Promise => { + const authResult = await noCacheGetAuth(); + if (!authResult.userId) { redirect("/auth/sign-in"); } - const { orgId } = user; + return authResult; +}); + +/** + * Retrieves the current organization ID or redirects if unavailable. + * + * This function checks authentication status and organization membership. + * It will redirect to the sign-in page if the user is not authenticated, + * or to the workspace creation page if the user has no organization. + * Results are cached for the duration of the server request. + * + * @returns The current user's organization ID + */ +export const getOrgId = cache(async (): Promise => { + const { orgId } = await getAuth(); + if (!orgId) { redirect("/new"); } return orgId; -} +}); -export async function getIsImpersonator(): Promise { - const user = await auth.getCurrentUser(); +/** + * Retrieves the complete current user object with organization information. + * + * This function fetches the authenticated user from the database along with + * their organization ID. It will redirect to the sign-in page if the user + * is not authenticated or cannot be found in the database. + * Results are cached for the duration of the server request. + * + * @returns Full user object with organization ID + * @throws Redirects to sign-in page if user is not authenticated or not found + */ +export const getCurrentUser = cache(async (): Promise => { + const { userId, orgId } = await getAuth(); + + const user = await auth.getUser(userId!); // getAuth will redirect if there's no userId if (!user) { - return false; + redirect("/auth/sign-in"); } - return user.impersonator !== undefined; -} + return { ...user, orgId }; +}); diff --git a/apps/dashboard/lib/auth/base-provider.ts b/apps/dashboard/lib/auth/base-provider.ts index 8346a8b607..8f6cc97d6b 100644 --- a/apps/dashboard/lib/auth/base-provider.ts +++ b/apps/dashboard/lib/auth/base-provider.ts @@ -1,5 +1,4 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getCookie } from "./cookies"; import { AuthErrorCode, type AuthErrorResponse, @@ -135,10 +134,30 @@ export abstract class BaseAuthProvider { signInUrl.searchParams.set("redirect", request.nextUrl.pathname); const response = NextResponse.redirect(signInUrl); response.cookies.delete(config.cookieName); + response.headers.set("x-middleware-processed", "true"); return response; } - // Public middleware factory method + /** + * Creates a Next.js edge middleware function for basic authentication screening. + * + * This factory generates a middleware function that performs lightweight authentication + * checks at the edge. It only verifies the presence of a session cookie and handles + * public path exclusions, delegating full authentication validation to server components. + * + * @param config - Optional configuration to override default middleware settings + * @returns A Next.js middleware function that performs basic auth screening and handles redirects + * + * @example + * // Create middleware with custom public paths + * const authMiddleware = authService.createMiddleware({ + * publicPaths: ['/about', '/pricing', '/api/public'], + * loginPath: '/custom-login' + * }); + * + * // In middleware.ts + * export default authMiddleware; + */ public createMiddleware(config: Partial = {}) { const middlewareConfig = { ...DEFAULT_MIDDLEWARE_CONFIG, @@ -146,89 +165,21 @@ export abstract class BaseAuthProvider { }; return async (request: NextRequest): Promise => { - if (!middlewareConfig.enabled) { - return NextResponse.next(); - } - const { pathname } = request.nextUrl; - const allPublicPaths = [ - ...middlewareConfig.publicPaths, - "/api/auth/refresh", - "/api/auth/create-tenant", - ]; - - if (this.isPublicPath(pathname, allPublicPaths)) { - console.debug("Public path detected, proceeding without auth check"); + // Skip public paths + if (this.isPublicPath(pathname, middlewareConfig.publicPaths)) { return NextResponse.next(); } - try { - const token = await getCookie(middlewareConfig.cookieName, request); - if (!token) { - console.debug("No session token found, redirecting to login"); - return this.redirectToLogin(request, middlewareConfig); - } - - const validationResult = await this.validateSession(token); - - if (validationResult.isValid) { - return NextResponse.next(); - } - - if (validationResult.shouldRefresh) { - try { - // Call the refresh route handler because you can only modify cookies in a route handlers or server action - // and you can't call a server action from middleware - const refreshResponse = await fetch(`${request.nextUrl.origin}/api/auth/refresh`, { - method: "POST", - headers: { - "x-current-token": token, - }, - }); - - if (!refreshResponse.ok) { - console.debug( - "Session refresh failed, redirecting to login: ", - await refreshResponse.text(), - ); - const response = this.redirectToLogin(request, middlewareConfig); - response.cookies.delete(middlewareConfig.cookieName); - return response; - } - - // Create a next response - const response = NextResponse.next(); - - // Copy cookies from refresh response - refreshResponse.headers.forEach((value, key) => { - if (key.toLowerCase() === "set-cookie") { - response.headers.append("Set-Cookie", value); - } - }); - - return response; - } catch (error) { - console.debug("Session refresh failed, redirecting to login: ", error); - const response = this.redirectToLogin(request, middlewareConfig); - response.cookies.delete(middlewareConfig.cookieName); - return response; - } - } - - console.debug("Invalid session, redirecting to login"); - const response = this.redirectToLogin(request, middlewareConfig); - response.cookies.delete(middlewareConfig.cookieName); - return response; - } catch (error) { - console.error("Authentication middleware error:", { - error: error instanceof Error ? error.message : "Unknown error", - stack: error instanceof Error ? error.stack : undefined, - url: request.url, - pathname, - }); + // Check if cookie exists at all (lightweight check) + const hasSessionCookie = request.cookies.has(middlewareConfig.cookieName); + if (!hasSessionCookie) { return this.redirectToLogin(request, middlewareConfig); } + + // Allow request to proceed to server components for full auth check + return NextResponse.next(); }; } } diff --git a/apps/dashboard/lib/auth/get-auth.ts b/apps/dashboard/lib/auth/get-auth.ts index 7e2f3d50ed..d469c775bc 100644 --- a/apps/dashboard/lib/auth/get-auth.ts +++ b/apps/dashboard/lib/auth/get-auth.ts @@ -50,14 +50,6 @@ export async function getAuth(_req?: Request): Promise { return { userId: null, orgId: null }; } - // fetch org from memberships if we have an org - if (orgId) { - return { - userId, - orgId, - }; - } - return { userId, orgId: orgId ?? null, diff --git a/apps/dashboard/lib/auth/utils.ts b/apps/dashboard/lib/auth/utils.ts index e7dc48db4c..bcc8f89938 100644 --- a/apps/dashboard/lib/auth/utils.ts +++ b/apps/dashboard/lib/auth/utils.ts @@ -1,17 +1,18 @@ "use server"; import { redirect } from "next/navigation"; +import { getAuth } from "../auth"; import { deleteCookie } from "./cookies"; import { auth } from "./server"; -import { UNKEY_SESSION_COOKIE, type User } from "./types"; +import { UNKEY_SESSION_COOKIE } from "./types"; // Helper function for ensuring a signed-in user -export async function requireAuth(): Promise { - const user = await auth.getCurrentUser(); - if (!user) { +export async function requireAuth(): Promise<{ userId: string | null; orgId: string | null }> { + const authResult = await getAuth(); + if (!authResult.userId) { redirect("/auth/sign-in"); } - return user; + return authResult; } // Helper to check invite email matches diff --git a/apps/dashboard/lib/trpc/context.ts b/apps/dashboard/lib/trpc/context.ts index c969fb2dba..af33a57104 100644 --- a/apps/dashboard/lib/trpc/context.ts +++ b/apps/dashboard/lib/trpc/context.ts @@ -1,7 +1,7 @@ import type { inferAsyncReturnType } from "@trpc/server"; import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"; -import { getAuth } from "@/lib/auth/get-auth"; +import { getAuth } from "../auth/get-auth"; import { db } from "../db"; export async function createContext({ req }: FetchCreateContextFnOptions) { diff --git a/apps/dashboard/middleware.ts b/apps/dashboard/middleware.ts index 47c17b8680..69bf6d7677 100644 --- a/apps/dashboard/middleware.ts +++ b/apps/dashboard/middleware.ts @@ -25,6 +25,7 @@ export default async function (req: NextRequest, _evt: NextFetchEvent) { "/api/webhooks/stripe", "/api/v1/workos/webhooks", "/api/v1/github/verify", + "/api/auth/refresh", "/_next", ], })(req);