Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/dashboard/app/auth/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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)];
Expand Down
8 changes: 3 additions & 5 deletions apps/dashboard/app/new/create-ratelimit.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,11 +13,10 @@ type Props = {
};

export const CreateRatelimit: React.FC<Props> = 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,
Expand All @@ -27,7 +25,7 @@ export const CreateRatelimit: React.FC<Props> = async (props) => {
},
workspace: props.workspace,
tenant: {
id: orgId,
id: user.orgId!, // if you have a workspace, you will have an orgId
},
audit: {
location: "",
Expand Down
26 changes: 4 additions & 22 deletions apps/dashboard/app/new/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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({
Expand Down
72 changes: 60 additions & 12 deletions apps/dashboard/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<string> {
const user = await auth.getCurrentUser();
if (!user) {
export const getAuth = cache(async (_req?: Request): Promise<GetAuthResult> => {
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<string> => {
const { orgId } = await getAuth();

if (!orgId) {
redirect("/new");
}

return orgId;
}
});

export async function getIsImpersonator(): Promise<boolean> {
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<User> => {
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 };
});
107 changes: 29 additions & 78 deletions apps/dashboard/lib/auth/base-provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { type NextRequest, NextResponse } from "next/server";
import { getCookie } from "./cookies";
import {
AuthErrorCode,
type AuthErrorResponse,
Expand Down Expand Up @@ -135,100 +134,52 @@ 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<MiddlewareConfig> = {}) {
const middlewareConfig = {
...DEFAULT_MIDDLEWARE_CONFIG,
...config,
};

return async (request: NextRequest): Promise<NextResponse> => {
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();
};
}
}
8 changes: 0 additions & 8 deletions apps/dashboard/lib/auth/get-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,6 @@ export async function getAuth(_req?: Request): Promise<GetAuthResult> {
return { userId: null, orgId: null };
}

// fetch org from memberships if we have an org
if (orgId) {
return {
userId,
orgId,
};
}

return {
userId,
orgId: orgId ?? null,
Expand Down
11 changes: 6 additions & 5 deletions apps/dashboard/lib/auth/utils.ts
Original file line number Diff line number Diff line change
@@ -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<User> {
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
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/lib/trpc/context.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading