diff --git a/apps/dashboard/app/(app)/api/auth/refresh/route.ts b/apps/dashboard/app/(app)/api/auth/refresh/route.ts
index 0314fbb6a1..54eabb924e 100644
--- a/apps/dashboard/app/(app)/api/auth/refresh/route.ts
+++ b/apps/dashboard/app/(app)/api/auth/refresh/route.ts
@@ -1,34 +1,146 @@
-import { setCookie } from "@/lib/auth/cookies";
+import { getCookie, setCookie } from "@/lib/auth/cookies";
import { auth } from "@/lib/auth/server";
-import { UNKEY_SESSION_COOKIE } from "@/lib/auth/types";
+import { tokenManager } from "@/lib/auth/token-management-service";
+import {
+ UNKEY_ACCESS_MAX_AGE,
+ UNKEY_ACCESS_TOKEN,
+ UNKEY_REFRESH_MAX_AGE,
+ UNKEY_REFRESH_TOKEN,
+ UNKEY_SESSION_COOKIE,
+ UNKEY_USER_IDENTITY_COOKIE,
+ UNKEY_USER_IDENTITY_MAX_AGE,
+} from "@/lib/auth/types";
+import { type NextRequest, NextResponse } from "next/server";
-export async function POST(request: Request) {
+export async function POST(req: NextRequest) {
try {
- // Get the current token from the request
- const currentToken = request.headers.get("x-current-token");
- if (!currentToken) {
- console.error("Session refresh failed: no current token");
- return Response.json({ success: false, error: "Failed to refresh session" }, { status: 401 });
+ // Get the refresh token from the cookie
+ const refreshToken = await getCookie(UNKEY_REFRESH_TOKEN);
+
+ // Get the user identity from the request headers and cookies
+ const requestUserIdentity = req.headers.get("x-user-identity");
+ const cookieUserIdentity = await getCookie(UNKEY_USER_IDENTITY_COOKIE);
+
+ // If we don't have a refresh token, return error
+ if (!refreshToken) {
+ return NextResponse.json({ error: "No refresh token available" }, { status: 401 });
+ }
+
+ // Check user identity consistency
+ // 1. If we have a user identity in the cookie, it should match the request
+ // 2. If we don't have one in the cookie but have one in the request, accept it
+ if (cookieUserIdentity && requestUserIdentity && cookieUserIdentity !== requestUserIdentity) {
+ console.warn("User identity mismatch during refresh");
+ return NextResponse.json({ error: "Invalid user identity" }, { status: 403 });
+ }
+
+ // Use either the cookie identity or request identity
+ const userIdentity = cookieUserIdentity || requestUserIdentity;
+
+ // If we have a user identity, verify token ownership
+ if (userIdentity) {
+ const isValidOwner = tokenManager.verifyTokenOwnership({
+ refreshToken,
+ userIdentity,
+ });
+
+ if (!isValidOwner) {
+ console.warn("Refresh token ownership verification failed");
+ return NextResponse.json({ error: "Invalid refresh token ownership" }, { status: 403 });
+ }
+
+ // If the user identity is only in the request, set it as a cookie
+ if (!cookieUserIdentity && requestUserIdentity) {
+ await setCookie({
+ name: UNKEY_USER_IDENTITY_COOKIE,
+ value: requestUserIdentity,
+ options: {
+ httpOnly: true,
+ secure: true,
+ sameSite: "strict",
+ maxAge: UNKEY_USER_IDENTITY_MAX_AGE,
+ path: "/",
+ },
+ });
+ }
+ }
+
+ // continue with token refresh
+ const result = await auth.refreshAccessToken(refreshToken);
+
+ if (!result || !result.session) {
+ // Remove the invalid token from our ownership mapping
+ tokenManager.removeToken(refreshToken);
+
+ return NextResponse.json({ error: "Failed to refresh session" }, { status: 401 });
}
- // Call refreshSession logic here and get new token
- const { newToken, expiresAt } = await auth.refreshSession(currentToken);
- // Set the new cookie
+ // Calculate remaining time for session in seconds
+ const sessionMaxAge = Math.floor((result.expiresAt.getTime() - Date.now()) / 1000);
+
+ // Update session cookie
await setCookie({
name: UNKEY_SESSION_COOKIE,
- value: newToken,
+ value: result.sessionToken,
options: {
httpOnly: true,
secure: true,
- sameSite: "lax",
+ sameSite: "strict",
+ maxAge: sessionMaxAge,
path: "/",
- maxAge: Math.floor((expiresAt.getTime() - Date.now()) / 1000), // Convert to seconds
},
});
- return Response.json({ success: true });
+ // Set access token cookie if available
+ if (result.accessToken) {
+ await setCookie({
+ name: UNKEY_ACCESS_TOKEN,
+ value: result.accessToken,
+ options: {
+ httpOnly: true,
+ secure: true,
+ sameSite: "strict",
+ maxAge: UNKEY_ACCESS_MAX_AGE,
+ path: "/",
+ },
+ });
+ }
+
+ // Update refresh token if available
+ if (result.refreshToken && result.refreshToken !== refreshToken) {
+ await setCookie({
+ name: UNKEY_REFRESH_TOKEN,
+ value: result.refreshToken,
+ options: {
+ httpOnly: true,
+ secure: true,
+ sameSite: "strict",
+ maxAge: UNKEY_REFRESH_MAX_AGE,
+ path: "/",
+ },
+ });
+
+ // Update token ownership mapping
+ if (userIdentity) {
+ tokenManager.updateTokenOwnership({
+ oldToken: refreshToken,
+ newToken: result.refreshToken,
+ userIdentity: userIdentity,
+ });
+ }
+ }
+
+ // Return success response with access token
+ return NextResponse.json({
+ success: true,
+ userId: result.session.userId,
+ orgId: result.session.orgId,
+ role: result.session.role,
+ accessToken: result.accessToken,
+ expiresAt: result.expiresAt,
+ });
} catch (error) {
- console.error("Session refresh failed:", error);
- return Response.json({ success: false, error: "Failed to refresh session" }, { status: 401 });
+ console.error("Refresh error:", error);
+ return NextResponse.json({ error: "Server error during refresh" }, { status: 500 });
}
}
diff --git a/apps/dashboard/app/(app)/api/auth/session/route.ts b/apps/dashboard/app/(app)/api/auth/session/route.ts
new file mode 100644
index 0000000000..287dbf9236
--- /dev/null
+++ b/apps/dashboard/app/(app)/api/auth/session/route.ts
@@ -0,0 +1,18 @@
+import { getAuth } from "@/lib/auth/get-auth";
+
+export const dynamic = "force-dynamic";
+export async function GET() {
+ try {
+ const { userId } = await getAuth();
+
+ if (!userId) {
+ return new Response(null, { status: 401 });
+ }
+
+ // Just return 200 OK if authenticated
+ return new Response(null, { status: 200 });
+ } catch (error) {
+ console.error("Error checking session:", error);
+ return new Response(null, { status: 401 });
+ }
+}
diff --git a/apps/dashboard/app/(app)/layout.tsx b/apps/dashboard/app/(app)/layout.tsx
index c90af8707f..00379b854f 100644
--- a/apps/dashboard/app/(app)/layout.tsx
+++ b/apps/dashboard/app/(app)/layout.tsx
@@ -1,8 +1,10 @@
+import { randomUUID } from "node:crypto";
import { AppSidebar } from "@/components/navigation/sidebar/app-sidebar";
import { SidebarMobile } from "@/components/navigation/sidebar/sidebar-mobile";
import { SidebarProvider } from "@/components/ui/sidebar";
import { getIsImpersonator, getOrgId } from "@/lib/auth";
import { db } from "@/lib/db";
+import { AuthProvider } from "@/providers/AuthProvider";
import { Empty } from "@unkey/ui";
import Link from "next/link";
import { redirect } from "next/navigation";
@@ -29,57 +31,63 @@ export default async function Layout({ children }: LayoutProps) {
return redirect("/new");
}
+ // Generate a secure random UUID on the server
+ // this will be a fall-back if there isn't already a cookie
+ const serverGeneratedIdentity = randomUUID();
+
return (
-
-
-
- {/* Desktop Sidebar */}
-
+
+
+
+
+ {/* Desktop Sidebar */}
+
- {/* Main content area */}
-
-
- {/* Mobile sidebar at the top of content */}
-
+ {/* Main content area */}
+
+
+ {/* Mobile sidebar at the top of content */}
+
-
- {workspace.enabled ? (
-
{children}
- ) : (
-
-
-
- This workspace is disabled
-
- Contact{" "}
-
- support@unkey.dev
-
-
-
-
- )}
-
-
- {isImpersonator ? (
-
-
- Impersonation Mode. Do not change anything and log out after you are done.
+
+ {workspace.enabled ? (
+
{children}
+ ) : (
+
+
+
+ This workspace is disabled
+
+ Contact{" "}
+
+ support@unkey.dev
+
+
+
+
+ )}
- ) : null}
+ {isImpersonator ? (
+
+
+ Impersonation Mode. Do not change anything and log out after you are done.
+
+
+ ) : null}
+
-
-
-
+
+
+
);
}
diff --git a/apps/dashboard/app/auth/actions.ts b/apps/dashboard/app/auth/actions.ts
index 9a74a585de..245d14cdb0 100644
--- a/apps/dashboard/app/auth/actions.ts
+++ b/apps/dashboard/app/auth/actions.ts
@@ -185,9 +185,9 @@ export async function completeOrgSelection(
// Used in route handlers, like join
export async function switchOrg(orgId: string): Promise<{ success: boolean; error?: string }> {
try {
- const { newToken, expiresAt } = await auth.switchOrg(orgId);
+ const { sessionToken, expiresAt } = await auth.switchOrg(orgId);
- await SetSessionCookie({ token: newToken, expiresAt });
+ await SetSessionCookie({ token: sessionToken, expiresAt });
return { success: true };
} catch (error) {
diff --git a/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts b/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts
index 4f510e1339..da4c61c7d1 100644
--- a/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts
+++ b/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts
@@ -4,7 +4,6 @@ import { AuthErrorCode, SIGN_IN_URL } from "@/lib/auth/types";
import { type NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const authResult = await auth.completeOAuthSignIn(request);
-
if (!authResult.success) {
if (
(authResult.code === AuthErrorCode.ORGANIZATION_SELECTION_REQUIRED ||
@@ -25,7 +24,6 @@ export async function GET(request: NextRequest) {
}
const response = NextResponse.redirect(url);
-
return await setCookiesOnResponse(response, authResult.cookies);
}
diff --git a/apps/dashboard/app/new/create-workspace.tsx b/apps/dashboard/app/new/create-workspace.tsx
index 2095b29158..1d5ae6e0d2 100644
--- a/apps/dashboard/app/new/create-workspace.tsx
+++ b/apps/dashboard/app/new/create-workspace.tsx
@@ -42,7 +42,7 @@ export const CreateWorkspace: React.FC = () => {
options: {
httpOnly: true,
secure: true,
- sameSite: "lax",
+ sameSite: "strict",
path: "/",
maxAge: Math.floor((sessionData.expiresAt!.getTime() - Date.now()) / 1000),
},
diff --git a/apps/dashboard/lib/auth.ts b/apps/dashboard/lib/auth.ts
index a9c4b17b87..30324254dc 100644
--- a/apps/dashboard/lib/auth.ts
+++ b/apps/dashboard/lib/auth.ts
@@ -1,14 +1,9 @@
-import { getAuth as noCacheGetAuth } from "@/lib/auth/get-auth";
+import { getAuth as baseGetAuth } from "@/lib/auth/get-auth";
import { auth } from "@/lib/auth/server";
-import type { User } from "@/lib/auth/types";
+import type { AuthResult, 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;
@@ -26,8 +21,8 @@ export async function getIsImpersonator(): Promise {
* @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 const getAuth = cache(async (_req?: Request): Promise => {
- const authResult = await noCacheGetAuth();
+export const getAuth = cache(async (_req?: Request): Promise => {
+ const authResult = await baseGetAuth();
if (!authResult.userId) {
redirect("/auth/sign-in");
}
diff --git a/apps/dashboard/lib/auth/base-provider.ts b/apps/dashboard/lib/auth/base-provider.ts
index 8f6cc97d6b..e7b9765727 100644
--- a/apps/dashboard/lib/auth/base-provider.ts
+++ b/apps/dashboard/lib/auth/base-provider.ts
@@ -25,57 +25,259 @@ import {
errorMessages,
} from "./types";
+/**
+ * Abstract base class providing authentication and authorization functionality.
+ * Implements core methods for session management, user authentication, and organization control.
+ *
+ * Extend this class to implement provider-specific authentication logic.
+ */
export abstract class BaseAuthProvider {
- // Session Management
+ /**
+ * Validates a user session token.
+ *
+ * @param sessionToken - The session token to validate
+ * @returns Promise resolving to a session validation result
+ */
abstract validateSession(sessionToken: string): Promise;
- abstract refreshSession(sessionToken: string): Promise;
- // Authentication
+ /**
+ * Refreshes an expired access token using a refresh token.
+ *
+ * @param currentRefreshToken - The refresh token to use for obtaining a new access token
+ * @returns Promise resolving to new session tokens
+ */
+ abstract refreshAccessToken(currentRefreshToken: string): Promise;
+
+ /**
+ * Initiates email-based authentication by sending an authentication code.
+ *
+ * @param email - The email address to authenticate
+ * @returns Promise resolving to email authentication result
+ */
abstract signInViaEmail(email: string): Promise;
+
+ /**
+ * Verifies an authentication code sent to a user's email.
+ *
+ * @param params - Object containing verification parameters
+ * @param params.email - The email address that received the code
+ * @param params.code - The authentication code to verify
+ * @param params.invitationToken - Optional invitation token for invited users
+ * @returns Promise resolving to verification result
+ */
abstract verifyAuthCode(params: {
email: string;
code: string;
invitationToken?: string;
}): Promise;
+
+ /**
+ * Verifies a user's email address using a verification code.
+ *
+ * @param params - Object containing verification parameters
+ * @param params.code - The verification code to check
+ * @param params.token - The token associated with this verification
+ * @returns Promise resolving to verification result
+ */
abstract verifyEmail(params: { code: string; token: string }): Promise;
+
+ /**
+ * Resends an authentication code to a user's email address.
+ *
+ * @param email - The email address to resend the code to
+ * @returns Promise resolving to email authentication result
+ */
abstract resendAuthCode(email: string): Promise;
+
+ /**
+ * Registers a new user via email.
+ *
+ * @param params - User data required for registration
+ * @returns Promise resolving to email authentication result
+ */
abstract signUpViaEmail(params: UserData): Promise;
+
+ /**
+ * Gets the URL used for signing out users.
+ *
+ * @returns Promise resolving to the sign-out URL or null if not applicable
+ */
abstract getSignOutUrl(): Promise;
+
+ /**
+ * Completes the organization selection process after authentication.
+ *
+ * @param params - Object containing selection parameters
+ * @param params.orgId - The ID of the selected organization
+ * @param params.pendingAuthToken - The pending authentication token
+ * @returns Promise resolving to verification result
+ */
abstract completeOrgSelection(params: {
orgId: string;
pendingAuthToken: string;
}): Promise;
- // OAuth Authentication
+ /**
+ * Generates a URL for OAuth authentication with a third-party provider.
+ *
+ * @param options - OAuth configuration options
+ * @returns URL string for redirecting to the OAuth provider
+ */
abstract signInViaOAuth(options: SignInViaOAuthOptions): string;
+
+ /**
+ * Handles the OAuth callback after successful third-party authentication.
+ *
+ * @param callbackRequest - The request object from the OAuth callback
+ * @returns Promise resolving to OAuth result
+ */
abstract completeOAuthSignIn(callbackRequest: Request): Promise;
- // User Management
+ /**
+ * Retrieves the currently authenticated user.
+ *
+ * @returns Promise resolving to the current user or null if not authenticated
+ */
abstract getCurrentUser(): Promise;
+
+ /**
+ * Retrieves a user by their unique ID.
+ *
+ * @param userId - The ID of the user to retrieve
+ * @returns Promise resolving to the user or null if not found
+ */
abstract getUser(userId: string): Promise;
+
+ /**
+ * Finds a user by their email address.
+ *
+ * @param email - The email address to search for
+ * @returns Promise resolving to the user or null if not found
+ */
abstract findUser(email: string): Promise;
- // Organization Management
+ /**
+ * Creates a new tenant (organization) for a specific user.
+ *
+ * @param params - Object containing tenant creation parameters
+ * @param params.name - The name of the tenant to create
+ * @param params.userId - The ID of the user who will own the tenant
+ * @returns Promise resolving to the ID of the created tenant
+ */
abstract createTenant(params: { name: string; userId: string }): Promise;
+
+ /**
+ * Updates an organization's details.
+ *
+ * @param params - Organization update parameters
+ * @returns Promise resolving to the updated organization
+ */
abstract updateOrg(params: UpdateOrgParams): Promise;
+
+ /**
+ * Creates a new organization with the given name.
+ * Protected method intended for internal use by subclasses.
+ *
+ * @param name - The name of the organization to create
+ * @returns Promise resolving to the created organization
+ */
protected abstract createOrg(name: string): Promise;
+
+ /**
+ * Retrieves an organization by its ID.
+ *
+ * @param orgId - The ID of the organization to retrieve
+ * @returns Promise resolving to the organization
+ */
abstract getOrg(orgId: string): Promise;
+
+ /**
+ * Switches the current session to a different organization.
+ *
+ * @param newOrgId - The ID of the organization to switch to
+ * @returns Promise resolving to new session tokens
+ */
abstract switchOrg(newOrgId: string): Promise;
- // Membership Management
+ /**
+ * Lists all memberships for a specific user.
+ *
+ * @param userId - The ID of the user whose memberships to list
+ * @returns Promise resolving to the membership list response
+ */
abstract listMemberships(userId: string): Promise;
+
+ /**
+ * Retrieves all members of a specific organization.
+ *
+ * @param orgId - The ID of the organization to list members for
+ * @returns Promise resolving to the membership list response
+ */
abstract getOrganizationMemberList(orgId: string): Promise;
+
+ /**
+ * Updates a user's membership properties.
+ *
+ * @param params - Membership update parameters
+ * @returns Promise resolving to the updated membership
+ */
abstract updateMembership(params: UpdateMembershipParams): Promise;
+
+ /**
+ * Removes a user from an organization.
+ *
+ * @param membershipId - The ID of the membership to remove
+ * @returns Promise resolving when the membership is removed
+ */
abstract removeMembership(membershipId: string): Promise;
- // Invitation Management
+ /**
+ * Invites a new member to join an organization.
+ *
+ * @param params - Organization invitation parameters
+ * @returns Promise resolving to the created invitation
+ */
abstract inviteMember(params: OrgInviteParams): Promise;
+
+ /**
+ * Retrieves all pending invitations for an organization.
+ *
+ * @param orgId - The ID of the organization to list invitations for
+ * @returns Promise resolving to the invitation list response
+ */
abstract getInvitationList(orgId: string): Promise;
+
+ /**
+ * Retrieves an invitation by its token.
+ *
+ * @param invitationToken - The token identifying the invitation
+ * @returns Promise resolving to the invitation or null if not found
+ */
abstract getInvitation(invitationToken: string): Promise;
+
+ /**
+ * Revokes a pending organization invitation.
+ *
+ * @param invitationId - The ID of the invitation to revoke
+ * @returns Promise resolving when the invitation is revoked
+ */
abstract revokeOrgInvitation(invitationId: string): Promise;
+
+ /**
+ * Accepts a pending organization invitation.
+ *
+ * @param invitationId - The ID of the invitation to accept
+ * @returns Promise resolving to the accepted invitation
+ */
abstract acceptInvitation(invitationId: string): Promise;
- // Error Handling
+ /**
+ * Standardizes error handling across all authentication operations.
+ * Converts various error types to a consistent AuthErrorResponse format.
+ *
+ * @param error - The error to handle
+ * @returns Standardized authentication error response
+ */
protected handleError(error: unknown): AuthErrorResponse {
console.error("Auth error:", error);
@@ -108,11 +310,22 @@ export abstract class BaseAuthProvider {
};
}
- // Utility Methods
+ /**
+ * Creates a standard response for state change operations.
+ *
+ * @returns Standard state change response object
+ */
protected createStateChangeResponse(): StateChangeResponse {
return { success: true };
}
+ /**
+ * Creates a standard response for operations requiring navigation.
+ *
+ * @param redirectTo - The URL to redirect to
+ * @param cookies - Cookies to include in the response
+ * @returns Standard navigation response object
+ */
protected createNavigationResponse(
redirectTo: string,
cookies: NavigationResponse["cookies"],
@@ -124,11 +337,25 @@ export abstract class BaseAuthProvider {
};
}
+ /**
+ * Determines if a path is public (exempt from authentication).
+ *
+ * @param pathname - The path to check
+ * @param publicPaths - Array of paths considered public
+ * @returns True if the path is public, false otherwise
+ */
protected isPublicPath(pathname: string, publicPaths: string[]): boolean {
const isPublic = publicPaths.some((path) => pathname.startsWith(path));
return isPublic;
}
+ /**
+ * Creates a response that redirects to the login page.
+ *
+ * @param request - The original request
+ * @param config - Middleware configuration
+ * @returns NextResponse configured to redirect to login
+ */
protected redirectToLogin(request: NextRequest, config: MiddlewareConfig): NextResponse {
const signInUrl = new URL(config.loginPath, request.url);
signInUrl.searchParams.set("redirect", request.nextUrl.pathname);
diff --git a/apps/dashboard/lib/auth/cookies.ts b/apps/dashboard/lib/auth/cookies.ts
index 870b9003b5..374f0195f3 100644
--- a/apps/dashboard/lib/auth/cookies.ts
+++ b/apps/dashboard/lib/auth/cookies.ts
@@ -70,7 +70,7 @@ export async function updateCookie(
options: {
httpOnly: true,
secure: true,
- sameSite: "lax",
+ sameSite: "strict",
},
});
return;
@@ -83,7 +83,7 @@ export async function updateCookie(
}
/**
- * Set cookies on a NextResponse object
+ * Set cookies on a Response object
* Useful when you need to set cookies during a redirect
*/
export async function setCookiesOnResponse(
@@ -112,7 +112,7 @@ export async function SetSessionCookie(params: {
options: {
httpOnly: true,
secure: true,
- sameSite: "lax",
+ sameSite: "strict",
path: "/",
maxAge: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
},
diff --git a/apps/dashboard/lib/auth/get-auth.ts b/apps/dashboard/lib/auth/get-auth.ts
index d469c775bc..b40fde8719 100644
--- a/apps/dashboard/lib/auth/get-auth.ts
+++ b/apps/dashboard/lib/auth/get-auth.ts
@@ -1,64 +1,245 @@
+import { cache } from "react";
+import { env } from "../env";
import { getCookie } from "./cookies";
import { auth } from "./server";
-import { UNKEY_SESSION_COOKIE } from "./types";
+import { tokenManager } from "./token-management-service";
+import {
+ type AuthResult,
+ type SessionValidationResult,
+ UNKEY_ACCESS_MAX_AGE,
+ UNKEY_ACCESS_TOKEN,
+ UNKEY_REFRESH_TOKEN,
+ UNKEY_SESSION_COOKIE,
+ UNKEY_USER_IDENTITY_COOKIE,
+} from "./types";
-type GetAuthResult = {
- userId: string | null;
- orgId: string | null;
-};
+const CACHE_TTL = 10 * 1000;
-export async function getAuth(_req?: Request): Promise {
+/**
+ * Simple cache for validation
+ *
+ * getAuth is called in the trpc context for every trpc route to ensure authentication.
+ * Pages may have client components that call multiple trpc routers to fetch data,
+ * resulting in validation being called multiple times.
+ * This cache will store the validation value for a short time, e.g. 10 seconds,
+ * just long enough for the concurrent trpc routes to use the same value before being invalidated.
+ */
+const sessionValidationCache = new Map<
+ string,
+ {
+ result: {
+ isValid: boolean;
+ shouldRefresh: boolean;
+ userId?: string;
+ orgId?: string | null;
+ role?: string | null;
+ accessToken?: string;
+ expiresAt?: Date;
+ };
+ expiresAt: number;
+ }
+>();
+
+export async function validateSession(
+ sessionToken: string | null,
+): Promise {
+ if (!sessionToken) {
+ return { isValid: false, shouldRefresh: false };
+ }
try {
- const sessionToken = await getCookie(UNKEY_SESSION_COOKIE);
+ return await auth.validateSession(sessionToken);
+ } catch (error) {
+ console.error("Session validation error:", error);
+ return { isValid: false, shouldRefresh: false };
+ }
+}
+
+// Per-user mutex tracking for refresh operations
+// maps user tokens to their refresh operation promises
+// TODO: move this to redis
+const refreshOperations = new Map>();
+
+// Refresh token with per-user mutex protection, using the refresh token as the key
+async function refreshTokenWithUserMutex(
+ refreshToken: string,
+ baseUrl: string,
+ userIdentity: string | null = null,
+): Promise {
+ // If we have a user identity, verify token ownership
+ if (userIdentity) {
+ const isValidOwner = tokenManager.verifyTokenOwnership({
+ refreshToken,
+ userIdentity,
+ });
+
+ if (!isValidOwner) {
+ console.warn("Refresh token ownership verification failed");
+ return { userId: null, orgId: null, role: null };
+ }
+ }
+
+ // If refresh is already in progress for this specific user, wait for it
+ if (refreshOperations.has(refreshToken)) {
+ try {
+ return await refreshOperations.get(refreshToken)!;
+ } catch (error) {
+ console.error("Error while waiting for refresh:", error);
+ // fall-through to continue with a new refresh attempt if the existing one fails
+ }
+ }
+
+ // Create a refresh promise for this specific user
+ const refreshPromise = (async (): Promise => {
+ try {
+ // Prepare headers
+ const headers: Record = {};
+
+ // Add the refresh token to headers
+ headers["x-refresh-token"] = refreshToken;
+
+ // Add user identity if available
+ if (userIdentity) {
+ headers["x-user-identity"] = userIdentity;
+ }
+
+ const refreshResult = await fetch(`${baseUrl}/api/auth/refresh`, {
+ method: "POST",
+ credentials: "include",
+ headers,
+ });
+
+ if (!refreshResult.ok) {
+ throw new Error(`Refresh failed: ${refreshResult.status}`);
+ }
+
+ const refreshedData = await refreshResult.json();
+
+ // If we received a new refresh token and have user identity, update mapping
+ if (
+ refreshedData.refreshToken &&
+ refreshedData.refreshToken !== refreshToken &&
+ userIdentity
+ ) {
+ tokenManager.updateTokenOwnership({
+ oldToken: refreshToken,
+ newToken: refreshedData.refreshToken,
+ userIdentity,
+ });
+ }
- if (!sessionToken) {
return {
- userId: null,
- orgId: null,
+ userId: refreshedData.userId || null,
+ orgId: refreshedData.orgId || null,
+ role: refreshedData.role || null,
+ accessToken: refreshedData.accessToken || null,
+ expiresAt: refreshedData.expiresAt || null,
};
+ } catch (error) {
+ console.error("Refresh error:", error);
+ return { userId: null, orgId: null, role: null };
+ } finally {
+ // only remove this user's refresh operation when done
+ refreshOperations.delete(refreshToken);
}
+ })();
- // Validate session
- const validationResult = await auth.validateSession(sessionToken);
-
- let userId: string | undefined;
- let orgId: string | null | undefined;
-
- if (!validationResult.isValid) {
- if (validationResult.shouldRefresh) {
- try {
- const refreshedData = await auth.refreshSession(sessionToken);
- if (!refreshedData.session) {
- return { userId: null, orgId: null };
- }
- userId = refreshedData.session.userId;
- orgId = refreshedData.session.orgId;
- } catch (error) {
- console.error(error);
- return { userId: null, orgId: null };
+ // store this user's refresh promise in the map
+ refreshOperations.set(refreshToken, refreshPromise);
+
+ // wait for the refresh to complete
+ return await refreshPromise;
+}
+
+// main getAuth function using both caching and per-user mutex
+export const getAuth = cache(async (_req?: Request): Promise => {
+ const VERCEL_URL = env().VERCEL_URL;
+ const baseUrl = VERCEL_URL || "http://localhost:3000";
+
+ try {
+ const sessionToken = await getCookie(UNKEY_SESSION_COOKIE);
+ const refreshToken = await getCookie(UNKEY_REFRESH_TOKEN);
+ const accessToken = await getCookie(UNKEY_ACCESS_TOKEN);
+ const userIdentity = await getCookie(UNKEY_USER_IDENTITY_COOKIE);
+
+ if (!sessionToken || !refreshToken) {
+ return { userId: null, orgId: null, role: null };
+ }
+
+ // Check if we have a valid access token first
+ if (accessToken) {
+ // Use cache to reduce validation calls
+ const cacheKey = `${sessionToken}:${accessToken}:${refreshToken}`;
+ const cachedValidation = sessionValidationCache.get(cacheKey);
+
+ if (cachedValidation && cachedValidation.expiresAt > Date.now()) {
+ const { result } = cachedValidation;
+
+ // If cached result says session is valid, return the user data with access token
+ if (result.isValid && result.userId) {
+ return {
+ userId: result.userId,
+ orgId: result.orgId ?? null,
+ role: result.role ?? null,
+ accessToken: result.accessToken ?? null,
+ expiresAt: result.expiresAt ?? null,
+ };
+ }
+
+ // If cached result says refresh is needed, do that
+ if (result.shouldRefresh) {
+ return await refreshTokenWithUserMutex(refreshToken, baseUrl, userIdentity);
}
- } else {
- return { userId: null, orgId: null };
+
+ // Session invalid and refresh not recommended
+ return { userId: null, orgId: null, role: null };
}
- } else {
- userId = validationResult.userId;
- orgId = validationResult.orgId;
}
- // we should have user data from either validation or refresh
- if (!userId) {
- return { userId: null, orgId: null };
+ // Validate the session
+ const validationResult = await validateSession(sessionToken);
+
+ // Only cache valid results that have an access token
+ if (validationResult.isValid && validationResult.accessToken) {
+ const cacheKey = `${sessionToken}:${validationResult.accessToken}:${refreshToken}`;
+ sessionValidationCache.set(cacheKey, {
+ result: validationResult,
+ expiresAt: Date.now() + CACHE_TTL,
+ });
}
- return {
- userId,
- orgId: orgId ?? null,
- };
+ // If session is valid, return user data with access token
+ if (validationResult.isValid && validationResult.userId) {
+ return {
+ userId: validationResult.userId,
+ orgId: validationResult.orgId ?? null,
+ role: validationResult.role ?? null,
+ accessToken: validationResult.accessToken ?? null,
+ expiresAt: new Date(Date.now() + UNKEY_ACCESS_MAX_AGE),
+ };
+ }
+
+ // If refresh is needed, use per-user mutex-protected refresh
+ if (validationResult.shouldRefresh) {
+ return await refreshTokenWithUserMutex(refreshToken, baseUrl, userIdentity);
+ }
+
+ // Session is invalid and refresh not recommended
+ return { userId: null, orgId: null, role: null };
} catch (error) {
- console.error("Auth validation error:", error);
- return {
- userId: null,
- orgId: null,
- };
+ console.error("Auth error:", error);
+ return { userId: null, orgId: null, role: null };
}
+});
+
+// Export a function to clear a specific user's refresh operation
+// useful to forcibly clear a stuck refresh operation
+export function clearUserRefreshOperation(refreshToken: string): boolean {
+ const hadOperation = refreshOperations.has(refreshToken);
+ refreshOperations.delete(refreshToken);
+ return hadOperation;
+}
+
+// for debugging: get count of ongoing refresh operations
+export function getOngoingRefreshCount(): number {
+ return refreshOperations.size;
}
diff --git a/apps/dashboard/lib/auth/token-management-service.ts b/apps/dashboard/lib/auth/token-management-service.ts
new file mode 100644
index 0000000000..8f3a3a737b
--- /dev/null
+++ b/apps/dashboard/lib/auth/token-management-service.ts
@@ -0,0 +1,80 @@
+/**
+ * Service for managing refresh token ownership verification
+ * TODO: Replace with Redis or other global implementation (Planetscale?)
+ */
+export class TokenManager {
+ private static instance: TokenManager;
+ private tokenOwners: Map = new Map();
+
+ private constructor() {
+ // Private constructor to enforce singleton
+ }
+
+ /**
+ * Get the singleton instance
+ */
+ public static getInstance(): TokenManager {
+ if (!TokenManager.instance) {
+ TokenManager.instance = new TokenManager();
+ }
+ return TokenManager.instance;
+ }
+
+ /**
+ * Check if a refresh token is owned by the given user identity
+ * If no ownership is recorded, associate the token with this user
+ */
+ public verifyTokenOwnership({
+ refreshToken,
+ userIdentity,
+ }: {
+ refreshToken: string;
+ userIdentity: string;
+ }): boolean {
+ const owner = this.tokenOwners.get(refreshToken);
+
+ // If we don't have ownership data, create it now
+ if (!owner) {
+ this.tokenOwners.set(refreshToken, userIdentity);
+ return true;
+ }
+
+ return owner === userIdentity;
+ }
+
+ /**
+ * Update token ownership mapping after a successful refresh
+ */
+ public updateTokenOwnership({
+ oldToken,
+ newToken,
+ userIdentity,
+ }: {
+ oldToken: string;
+ newToken: string;
+ userIdentity: string;
+ }): void {
+ // Remove the old mapping
+ this.tokenOwners.delete(oldToken);
+
+ // Add the new mapping
+ this.tokenOwners.set(newToken, userIdentity);
+ }
+
+ /**
+ * Remove a token from the ownership mapping
+ */
+ public removeToken(token: string): boolean {
+ return this.tokenOwners.delete(token);
+ }
+
+ /**
+ * Get the count of tracked tokens (for debugging)
+ */
+ public getTokenCount(): number {
+ return this.tokenOwners.size;
+ }
+}
+
+// Export a singleton instance
+export const tokenManager = TokenManager.getInstance();
diff --git a/apps/dashboard/lib/auth/types.ts b/apps/dashboard/lib/auth/types.ts
index e1e4d08dde..c2f616cda3 100644
--- a/apps/dashboard/lib/auth/types.ts
+++ b/apps/dashboard/lib/auth/types.ts
@@ -1,11 +1,19 @@
import type { Cookie } from "./cookies";
-// Core Types
+// consts
export const UNKEY_SESSION_COOKIE = "unkey-session";
+export const UNKEY_ACCESS_TOKEN = "unkey-access-token";
+export const UNKEY_REFRESH_TOKEN = "unkey-refresh-token";
+export const UNKEY_USER_IDENTITY_COOKIE = "unkey-user-identity";
export const PENDING_SESSION_COOKIE = "sess-temp";
export const SIGN_IN_URL = "/auth/sign-in";
export const SIGN_UP_URL = "/auth/sign-up";
+// Token expiration (in milliseconds)
+export const UNKEY_ACCESS_MAX_AGE = 5 * 60 * 1000; // 5 minutes
+export const UNKEY_REFRESH_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
+export const UNKEY_USER_IDENTITY_MAX_AGE = 90 * 24 * 60 * 60 * 1000; // 90 days
+
export interface User {
id: string;
orgId: string | null;
@@ -39,6 +47,14 @@ interface AuthResponse {
success: boolean;
}
+export type AuthResult = {
+ userId: string | null;
+ orgId: string | null;
+ role: string | null;
+ accessToken?: string | null;
+ expiresAt?: Date | null;
+};
+
// State change responses (for operations that update UI state)
export interface StateChangeResponse extends AuthResponse {
success: true;
@@ -96,16 +112,32 @@ export type MembershipListResponse = ListResponse;
export type InvitationListResponse = ListResponse;
// Session Types
-export interface SessionValidationResult {
+interface BaseSessionValidationResult {
isValid: boolean;
shouldRefresh: boolean;
- token?: string;
- userId?: string;
+}
+
+// valid sessions
+interface ValidSessionResult extends BaseSessionValidationResult {
+ isValid: true;
+ sessionToken: string;
+ accessToken: string;
+ userId: string;
orgId?: string | null;
+ role?: string | null;
}
+// invalid sessions
+interface InvalidSessionResult extends BaseSessionValidationResult {
+ isValid: false;
+}
+
+export type SessionValidationResult = ValidSessionResult | InvalidSessionResult;
+
export interface SessionRefreshResult {
- newToken: string;
+ sessionToken: string;
+ accessToken?: string;
+ refreshToken?: string;
expiresAt: Date;
session: SessionData | null;
}
@@ -113,6 +145,7 @@ export interface SessionRefreshResult {
export interface SessionData {
userId: string;
orgId: string | null;
+ role?: string | null;
}
// OAuth Types
diff --git a/apps/dashboard/lib/auth/workos.ts b/apps/dashboard/lib/auth/workos.ts
index 0437f9f4e9..54537098c9 100644
--- a/apps/dashboard/lib/auth/workos.ts
+++ b/apps/dashboard/lib/auth/workos.ts
@@ -21,6 +21,8 @@ import {
type SessionRefreshResult,
type SessionValidationResult,
type SignInViaOAuthOptions,
+ UNKEY_ACCESS_TOKEN,
+ UNKEY_REFRESH_TOKEN,
UNKEY_SESSION_COOKIE,
type UpdateMembershipParams,
type UpdateOrgParams,
@@ -68,13 +70,20 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
if (authResult.authenticated) {
return {
isValid: true,
+ sessionToken: sessionToken, //return the same sessionToken
+ accessToken: authResult.accessToken,
shouldRefresh: false,
userId: authResult.user.id,
orgId: authResult.organizationId ?? null,
+ role: authResult.role ?? null,
};
}
- return { isValid: false, shouldRefresh: true };
+ // signal attempt to refresh
+ return {
+ isValid: false,
+ shouldRefresh: true,
+ };
} catch (error) {
console.error("Session validation error:", {
error: error instanceof Error ? error.message : "Unknown error",
@@ -84,42 +93,45 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
}
}
- async refreshSession(sessionToken: string | null): Promise {
- if (!sessionToken) {
- throw new Error("No session token provided");
+ async refreshAccessToken(currentRefreshToken: string | null): Promise {
+ if (!currentRefreshToken) {
+ throw new Error("No refresh token provided");
}
- try {
- const session = this.provider.userManagement.loadSealedSession({
- sessionData: sessionToken,
- cookiePassword: this.cookiePassword,
- });
-
- const refreshResult = await session.refresh({
- cookiePassword: this.cookiePassword,
- });
+ const expiresAt = new Date();
+ expiresAt.setDate(expiresAt.getDate() + 7);
- if (refreshResult.authenticated && refreshResult.session) {
- // Set expiration to 7 days from now
- const expiresAt = new Date();
- expiresAt.setDate(expiresAt.getDate() + 7);
-
- return {
- newToken: refreshResult.sealedSession!,
- expiresAt,
+ try {
+ const { sealedSession, refreshToken, accessToken, user, organizationId } =
+ await this.provider.userManagement.authenticateWithRefreshToken({
+ clientId: this.clientId,
+ refreshToken: currentRefreshToken,
session: {
- userId: refreshResult.session.user.id,
- orgId: refreshResult.session.organizationId ?? null,
+ sealSession: true,
+ cookiePassword: this.cookiePassword,
},
- };
+ });
+
+ if (!sealedSession) {
+ // ensure that the sealedSession boolean is true
+ // otherwise WorkOS messed up
+ // make typescript happy with additional guard
+ throw new Error("Missing sealed session.");
}
- throw new Error("reason" in refreshResult ? refreshResult.reason : "Session refresh failed");
+ return {
+ expiresAt,
+ sessionToken: sealedSession,
+ accessToken,
+ refreshToken,
+ session: {
+ userId: user.id,
+ orgId: organizationId ?? null,
+ role: null, // not returned in authenticateWithRefreshToken
+ },
+ };
} catch (error) {
- console.error("Session refresh error:", {
- error: error instanceof Error ? error.message : "Unknown error",
- token: sessionToken ? `${sessionToken.substring(0, 10)}...` : "no token",
- });
+ console.error("Access token failed to refresh: ", error);
throw error;
}
}
@@ -285,16 +297,23 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
throw new Error(`Organization switch failed ${errMsg}`);
}
+ if (!refreshResult.sealedSession) {
+ // WorkOS messed up, it should always come back if authenticated
+ // make typescript happy with guard anyway
+ throw new Error("Missing sealed session");
+ }
+
// Set expiration to 7 days from now
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
return {
- newToken: refreshResult.sealedSession!,
+ sessionToken: refreshResult.sealedSession,
expiresAt,
session: {
userId: refreshResult.session.user.id,
- orgId: newOrgId,
+ orgId: refreshResult.session.organizationId ?? null,
+ role: null, // doesn't come back on this session helper for some reason, but it comes back in others that use this return type
},
};
} catch (error) {
@@ -618,7 +637,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
options: {
secure: true,
httpOnly: true,
- sameSite: "lax",
+ sameSite: "strict",
},
},
],
@@ -639,7 +658,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
options: {
secure: true,
httpOnly: true,
- sameSite: "lax",
+ sameSite: "strict",
},
},
],
@@ -681,7 +700,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
options: {
secure: true,
httpOnly: true,
- sameSite: "lax",
+ sameSite: "strict",
},
},
],
@@ -703,7 +722,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
options: {
secure: true,
httpOnly: true,
- sameSite: "lax",
+ sameSite: "strict",
},
},
],
@@ -718,7 +737,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
pendingAuthToken: string;
}): Promise {
try {
- const { sealedSession } =
+ const { sealedSession, accessToken, refreshToken } =
await this.provider.userManagement.authenticateWithOrganizationSelection({
pendingAuthenticationToken: params.pendingAuthToken,
organizationId: params.orgId,
@@ -743,7 +762,25 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
options: {
secure: true,
httpOnly: true,
- sameSite: "lax",
+ sameSite: "strict",
+ },
+ },
+ {
+ name: UNKEY_ACCESS_TOKEN,
+ value: accessToken,
+ options: {
+ secure: true,
+ httpOnly: true,
+ sameSite: "strict",
+ },
+ },
+ {
+ name: UNKEY_REFRESH_TOKEN,
+ value: refreshToken,
+ options: {
+ secure: true,
+ httpOnly: true,
+ sameSite: "strict",
},
},
],
@@ -798,14 +835,15 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
}
try {
- const { sealedSession } = await this.provider.userManagement.authenticateWithCode({
- clientId: this.clientId,
- code,
- session: {
- sealSession: true,
- cookiePassword: this.cookiePassword,
- },
- });
+ const { sealedSession, accessToken, refreshToken } =
+ await this.provider.userManagement.authenticateWithCode({
+ clientId: this.clientId,
+ code,
+ session: {
+ sealSession: true,
+ cookiePassword: this.cookiePassword,
+ },
+ });
if (!sealedSession) {
throw new Error("No sealed session returned");
@@ -825,7 +863,25 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
options: {
secure: true,
httpOnly: true,
- sameSite: "lax",
+ sameSite: "strict",
+ },
+ },
+ {
+ name: UNKEY_ACCESS_TOKEN,
+ value: accessToken,
+ options: {
+ secure: true,
+ httpOnly: true,
+ sameSite: "strict",
+ },
+ },
+ {
+ name: UNKEY_REFRESH_TOKEN,
+ value: refreshToken,
+ options: {
+ secure: true,
+ httpOnly: true,
+ sameSite: "strict",
},
},
],
@@ -845,7 +901,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
options: {
secure: true,
httpOnly: true,
- sameSite: "lax",
+ sameSite: "strict",
maxAge: 60, // user has 60 seconds to select an org before the cookie expires
},
},
@@ -869,7 +925,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider {
options: {
secure: true,
httpOnly: true,
- sameSite: "lax",
+ sameSite: "strict",
maxAge: 60 * 10, // user has 10 mins seconds to verify their email before the cookie expires
},
},
diff --git a/apps/dashboard/lib/trpc/routers/user/switchOrg.ts b/apps/dashboard/lib/trpc/routers/user/switchOrg.ts
index cf01e31743..cb0e72af34 100644
--- a/apps/dashboard/lib/trpc/routers/user/switchOrg.ts
+++ b/apps/dashboard/lib/trpc/routers/user/switchOrg.ts
@@ -7,10 +7,10 @@ export const switchOrg = t.procedure
.input(z.string())
.mutation(async ({ input: orgId }) => {
try {
- const { newToken, expiresAt } = await authProvider.switchOrg(orgId);
+ const { sessionToken, expiresAt } = await authProvider.switchOrg(orgId);
return {
success: true,
- token: newToken,
+ token: sessionToken,
expiresAt,
};
} catch (error) {
diff --git a/apps/dashboard/providers/AuthProvider.tsx b/apps/dashboard/providers/AuthProvider.tsx
new file mode 100644
index 0000000000..2f01cfade7
--- /dev/null
+++ b/apps/dashboard/providers/AuthProvider.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import { UNKEY_USER_IDENTITY_COOKIE, UNKEY_USER_IDENTITY_MAX_AGE } from "@/lib/auth/types";
+import { useRouter } from "next/navigation";
+import { createContext, useCallback, useEffect, useState } from "react";
+
+// Types
+type AuthContextType = {
+ isAuthenticated: boolean;
+ isLoading: boolean;
+ accessToken: string | null;
+ expiresAt: Date | null;
+ refreshSession: () => Promise;
+};
+
+// Create the context with a default value
+const AuthContext = createContext({
+ isAuthenticated: false,
+ isLoading: true,
+ accessToken: null,
+ expiresAt: null,
+ refreshSession: async () => false,
+});
+
+// Helper function to get a cookie client-side
+const getCookie = (name: string): string | null => {
+ const cookies = document.cookie.split(";");
+ for (let i = 0; i < cookies.length; i++) {
+ const cookie = cookies[i].trim();
+ if (cookie.startsWith(`${name}=`)) {
+ return cookie.substring(name.length + 1);
+ }
+ }
+ return null;
+};
+
+// Helper function to set a cookie client-side
+const setCookie = ({
+ name,
+ value,
+ maxAge,
+}: { name: string; value: string; maxAge: number }): void => {
+ document.cookie = `${name}=${value}; path=/; max-age=${maxAge}; samesite=strict; secure=${window.location.protocol === "https:"}`;
+};
+
+export function AuthProvider({
+ children,
+ requireAuth = false,
+ redirectTo = "/auth/sign-in",
+ serverGeneratedIdentity,
+}: {
+ children: React.ReactNode;
+ requireAuth?: boolean;
+ redirectTo?: string;
+ serverGeneratedIdentity: string;
+}) {
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+ const [accessToken, setAccessToken] = useState(null);
+ const [expiresAt, setExpiresAt] = useState(null);
+ const router = useRouter();
+
+ // Helper to ensure a user identity exists
+ const ensureUserIdentity = useCallback((): string => {
+ // First check for existing cookie
+ let userIdentity = getCookie(UNKEY_USER_IDENTITY_COOKIE);
+
+ // If we don't have an identity cookie yet, use the server-generated one or create a new one
+ if (!userIdentity) {
+ // Use server-generated identity if provided
+ userIdentity = serverGeneratedIdentity;
+ }
+
+ // Store for 90 days
+ setCookie({
+ name: UNKEY_USER_IDENTITY_COOKIE,
+ value: userIdentity,
+ maxAge: UNKEY_USER_IDENTITY_MAX_AGE,
+ });
+
+ return userIdentity;
+ }, [serverGeneratedIdentity]);
+
+ // Ensure we have a user identity as early as possible
+ useEffect(() => {
+ ensureUserIdentity();
+ }, [ensureUserIdentity]);
+
+ // Verify authentication status
+ const checkAuth = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ // This endpoint will use server-side cached getAuth
+ const response = await fetch("/api/auth/session", {
+ credentials: "include",
+ });
+
+ if (response.ok) {
+ const sessionData = await response.json();
+ setIsAuthenticated(true);
+
+ // Store access token in memory for easy access by client components
+ if (sessionData.accessToken) {
+ setAccessToken(sessionData.accessToken);
+ setExpiresAt(sessionData.expiresAt || null);
+ }
+ } else {
+ setIsAuthenticated(false);
+ setAccessToken(null);
+ setExpiresAt(null);
+
+ if (requireAuth) {
+ router.push(redirectTo);
+ }
+ }
+ } catch (error) {
+ console.error("Auth check failed:", error);
+ setIsAuthenticated(false);
+ setAccessToken(null);
+ setExpiresAt(null);
+
+ if (requireAuth) {
+ router.push(redirectTo);
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }, [requireAuth, router, redirectTo]);
+
+ // Refresh session - this will use mutex-protected refresh
+ const refreshSession = useCallback(async () => {
+ try {
+ const userIdentity = ensureUserIdentity();
+
+ const response = await fetch("/api/auth/refresh", {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ // Include the user identity in the request headers
+ "x-user-identity": userIdentity,
+ },
+ });
+
+ if (response.ok) {
+ const refreshData = await response.json();
+ setIsAuthenticated(true);
+
+ // Update access token in memory
+ if (refreshData.accessToken) {
+ setAccessToken(refreshData.accessToken);
+ setExpiresAt(refreshData.expiresAt || null);
+ }
+
+ return true;
+ }
+ setIsAuthenticated(false);
+ setAccessToken(null);
+ setExpiresAt(null);
+
+ if (requireAuth) {
+ router.push(redirectTo);
+ }
+
+ return false;
+ } catch (error) {
+ console.error("Session refresh failed:", error);
+ setIsAuthenticated(false);
+ setAccessToken(null);
+ setExpiresAt(null);
+
+ if (requireAuth) {
+ router.push(redirectTo);
+ }
+
+ return false;
+ }
+ }, [requireAuth, router, redirectTo, ensureUserIdentity]);
+
+ // Determine if we need to refresh the token
+ const shouldRefreshToken = useCallback(() => {
+ if (!expiresAt) {
+ return true;
+ }
+
+ // Refresh if we're within 1 minute of expiration
+ const now = new Date();
+ const timeRemaining = expiresAt.getTime() - now.getTime();
+ return timeRemaining < 60 * 1000; // Less than 1 minute remaining
+ }, [expiresAt]);
+
+ // Set up token refresh interval
+ useEffect(() => {
+ let refreshInterval: NodeJS.Timeout;
+
+ if (isAuthenticated) {
+ // Check tokens periodically and refresh if needed
+ refreshInterval = setInterval(() => {
+ if (shouldRefreshToken()) {
+ refreshSession();
+ }
+ }, 60 * 1000); // Check every minute
+ }
+
+ return () => {
+ if (refreshInterval) {
+ clearInterval(refreshInterval);
+ }
+ };
+ }, [isAuthenticated, refreshSession, shouldRefreshToken]);
+
+ // Add a refresh listener for when the tab becomes visible again
+ // helps with token expiration during long periods of inactivity
+ useEffect(() => {
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === "visible" && isAuthenticated && shouldRefreshToken()) {
+ refreshSession();
+ }
+ };
+
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+
+ return () => {
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
+ };
+ }, [isAuthenticated, refreshSession, shouldRefreshToken]);
+
+ // Initial auth check
+ useEffect(() => {
+ checkAuth();
+ }, [checkAuth]);
+
+ return (
+
+ {!requireAuth || (requireAuth && isAuthenticated) ? children : null}
+
+ );
+}
+
+export { AuthContext };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 90c634bb38..783171232a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1554,8 +1554,8 @@ packages:
js-yaml: 4.1.0
dev: false
- /@asamuzakjp/css-color@3.1.3:
- resolution: {integrity: sha512-u25AyjuNrRFGb1O7KmWEu0ExN6iJMlUmDSlOPW/11JF8khOrIGG6oCoYpC+4mZlthNVhFUahk68lNrNI91f6Yg==}
+ /@asamuzakjp/css-color@3.1.4:
+ resolution: {integrity: sha512-SeuBV4rnjpFNjI8HSgKUwteuFdkHwkboq31HWzznuqgySQir+jSTczoWVVL4jvOjKjuH80fMDG0Fvg1Sb+OJsA==}
dependencies:
'@csstools/css-calc': 2.1.3(@csstools/css-parser-algorithms@3.0.4)(@csstools/css-tokenizer@3.0.3)
'@csstools/css-color-parser': 3.0.9(@csstools/css-parser-algorithms@3.0.4)(@csstools/css-tokenizer@3.0.3)
@@ -1581,8 +1581,8 @@ packages:
'@stoplight/json': 3.21.0
'@stoplight/json-ref-readers': 1.2.2
'@stoplight/json-ref-resolver': 3.1.6
- '@stoplight/spectral-core': 1.19.5
- '@stoplight/spectral-functions': 1.9.4
+ '@stoplight/spectral-core': 1.20.0
+ '@stoplight/spectral-functions': 1.10.1
'@stoplight/spectral-parsers': 1.0.5
'@stoplight/spectral-ref-resolver': 1.0.5
'@stoplight/types': 13.20.0
@@ -5130,8 +5130,8 @@ packages:
'@babel/core': 7.26.10
'@babel/helper-module-imports': 7.25.9
'@babel/types': 7.27.0
- '@radix-ui/react-dialog': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-tooltip': 1.2.3(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-dialog': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-tooltip': 1.2.4(react-dom@18.3.1)(react@18.3.1)
'@rollup/pluginutils': 5.1.4
cmdk: 0.2.1(react-dom@18.3.1)(react@18.3.1)
esbuild: 0.20.2
@@ -6590,8 +6590,8 @@ packages:
react-dom: 18.3.1(react@18.3.1)
dev: false
- /@radix-ui/react-accordion@1.2.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
- resolution: {integrity: sha512-stDPylBV/3kFHBAFQK/GeyIFaN7q60zWaXthA5/p6egu8AclIN79zG+bv+Ps+exB4JE5rtW/u3Z7SDvmFuTzgA==}
+ /@radix-ui/react-accordion@1.2.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-c7OKBvO36PfQIUGIjj1Wko0hH937pYFU2tR5zbIJDUsmTzHoZVHHt4bmb7OOJbzTaWJtVELKWojBHa7OcnUHmQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -6604,7 +6604,7 @@ packages:
optional: true
dependencies:
'@radix-ui/primitive': 1.1.2
- '@radix-ui/react-collapsible': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-collapsible': 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-collection': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.11)(react@18.3.1)
@@ -6810,8 +6810,8 @@ packages:
react-dom: 18.3.1(react@18.3.1)
dev: false
- /@radix-ui/react-collapsible@1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
- resolution: {integrity: sha512-zGFsPcFJNdQa/UNd6MOgF40BS054FIGj32oOWBllixz42f+AkQg3QJ1YT9pw7vs+Ai+EgWkh839h69GEK8oH2A==}
+ /@radix-ui/react-collapsible@1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-hxEsLvK9WxIAPyxdDRULL4hcaSjMZCfP7fHB0Z1uUnDoDBat1Zh46hwYfa69DeZAbJrPckjf0AGAtEZyvDyJbw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -6827,7 +6827,7 @@ packages:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1)
- '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.11)(react@18.3.1)
@@ -7090,8 +7090,8 @@ packages:
react-remove-scroll: 2.5.5(@types/react@18.3.11)(react@18.3.1)
dev: false
- /@radix-ui/react-dialog@1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
- resolution: {integrity: sha512-m6pZb0gEM5uHPSb+i2nKKGQi/HMSVjARMsLMWQfKDP+eJ6B+uqryHnXhpnohTWElw+vEcMk/o4wJODtdRKHwqg==}
+ /@radix-ui/react-dialog@1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-yI7S1ipkP5/+99qhSI6nthfo/tR6bL6Zgxi/+1UO6qPa6UeM6nlafWcQ65vB4rU2XjgjMfMhI3k9Y5MztA62VQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -7111,7 +7111,7 @@ packages:
'@radix-ui/react-focus-scope': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-portal': 1.1.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-slot': 1.2.0(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1)
@@ -7631,8 +7631,8 @@ packages:
react-remove-scroll: 2.5.7(@types/react@18.3.11)(react@18.3.1)
dev: false
- /@radix-ui/react-navigation-menu@1.2.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
- resolution: {integrity: sha512-Z7lefjA5VAmEB5ZClxeHGWGQAqhGWgEc6u0MYviUmIVrgGCVLv5mv/jsfUY3tJWI71cVhpQ7dnf/Q6RtM3ylVA==}
+ /@radix-ui/react-navigation-menu@1.2.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-kGDqMVPj2SRB1vJmXN/jnhC66REAXNyDmDRubbbmJ+360zSIJUDmWGMKIJOf72PHMwPENrbtJVb3CMAUJDjEIA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -7651,7 +7651,7 @@ packages:
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1)
- '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1)
@@ -7699,8 +7699,8 @@ packages:
react-remove-scroll: 2.5.5(@types/react@18.3.11)(react@18.3.1)
dev: false
- /@radix-ui/react-popover@1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
- resolution: {integrity: sha512-IZN7b3sXqajiPsOzKuNJBSP9obF4MX5/5UhTgWNofw4r1H+eATWb0SyMlaxPD/kzA4vadFgy1s7Z1AEJ6WMyHQ==}
+ /@radix-ui/react-popover@1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-yFMfZkVA5G3GJnBgb2PxrrcLKm1ZLWXrbYVgdyTl//0TYEIHS9LJbnyz7WWcZ0qCq7hIlJZpRtxeSeIG5T5oJw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -7721,7 +7721,7 @@ packages:
'@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-popper': 1.2.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-portal': 1.1.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-slot': 1.2.0(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1)
@@ -8043,8 +8043,8 @@ packages:
react-dom: 18.3.1(react@18.3.1)
dev: false
- /@radix-ui/react-presence@1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
- resolution: {integrity: sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==}
+ /@radix-ui/react-presence@1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -8311,8 +8311,8 @@ packages:
react-dom: 18.3.1(react@18.3.1)
dev: false
- /@radix-ui/react-scroll-area@1.2.5(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
- resolution: {integrity: sha512-VyLjxI8/gXYn+Wij1FLpXjZp6Z/uNklUFQQ75tOpJNESeNaZ2kCRfjiEDmHgWmLeUPeJGwrqbgRmcdFjtYEkMA==}
+ /@radix-ui/react-scroll-area@1.2.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-lj8OMlpPERXrQIHlEQdlXHJoRT52AMpBrgyPYylOhXYq5e/glsEdtOc/kCQlsTdtgN5U0iDbrrolDadvektJGQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -8329,7 +8329,7 @@ packages:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.11)(react@18.3.1)
- '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.11)(react@18.3.1)
@@ -8575,8 +8575,8 @@ packages:
react-dom: 18.3.1(react@18.3.1)
dev: false
- /@radix-ui/react-tabs@1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
- resolution: {integrity: sha512-4iUaN9SYtG+/E+hJ7jRks/Nv90f+uAsRHbLYA6BcA9EsR6GNWgsvtS4iwU2SP0tOZfDGAyqIT0yz7ckgohEIFA==}
+ /@radix-ui/react-tabs@1.1.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-KIjtwciYvquiW/wAFkELZCVnaNLBsYNhTNcvl+zfMAbMhRkcvNuCLXDDd22L0j7tagpzVh/QwbFpwAATg7ILPw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -8592,7 +8592,7 @@ packages:
'@radix-ui/react-context': 1.1.2(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1)
- '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-roving-focus': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1)
@@ -8716,8 +8716,8 @@ packages:
react-dom: 18.3.1(react@18.3.1)
dev: false
- /@radix-ui/react-tooltip@1.2.3(react-dom@18.3.1)(react@18.3.1):
- resolution: {integrity: sha512-0KX7jUYFA02np01Y11NWkk6Ip6TqMNmD4ijLelYAzeIndl2aVeltjJFJ2gwjNa1P8U/dgjQ+8cr9Y3Ni+ZNoRA==}
+ /@radix-ui/react-tooltip@1.2.4(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-DyW8VVeeMSSLFvAmnVnCwvI3H+1tpJFHT50r+tdOoMse9XqYDBCcyux8u3G2y+LOpt7fPQ6KKH0mhs+ce1+Z5w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -8736,7 +8736,7 @@ packages:
'@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-popper': 1.2.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-portal': 1.1.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-slot': 1.2.0(@types/react@18.3.11)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1)
@@ -9742,8 +9742,8 @@ packages:
engines: {node: '>=8'}
dev: true
- /@stoplight/spectral-core@1.19.5:
- resolution: {integrity: sha512-i+njdliW7bAHGsHEgDvH0To/9IxiYiBELltkZ7ASVy4i+WXtZ40lQXpeRQRwePrBcSgQl0gcZFuKX10nmSHtbw==}
+ /@stoplight/spectral-core@1.20.0:
+ resolution: {integrity: sha512-5hBP81nCC1zn1hJXL/uxPNRKNcB+/pEIHgCjPRpl/w/qy9yC9ver04tw1W0l/PMiv0UeB5dYgozXVQ4j5a6QQQ==}
engines: {node: ^16.20 || ^18.18 || >= 20.17}
dependencies:
'@stoplight/better-ajv-errors': 1.0.3(ajv@8.17.1)
@@ -9776,20 +9776,20 @@ packages:
engines: {node: ^16.20 || ^18.18 || >= 20.17}
dependencies:
'@stoplight/json': 3.21.0
- '@stoplight/spectral-core': 1.19.5
+ '@stoplight/spectral-core': 1.20.0
'@types/json-schema': 7.0.15
tslib: 2.8.1
transitivePeerDependencies:
- encoding
dev: true
- /@stoplight/spectral-functions@1.9.4:
- resolution: {integrity: sha512-+dgu7QQ1JIZFsNLhNbQLPA9tniIT3KjOc9ORv0LYSCLvZjkWT2bN7vgmathbXsbmhnmhvl15H9sRqUIqzi+qoQ==}
+ /@stoplight/spectral-functions@1.10.1:
+ resolution: {integrity: sha512-obu8ZfoHxELOapfGsCJixKZXZcffjg+lSoNuttpmUFuDzVLT3VmH8QkPXfOGOL5Pz80BR35ClNAToDkdnYIURg==}
engines: {node: ^16.20 || ^18.18 || >= 20.17}
dependencies:
'@stoplight/better-ajv-errors': 1.0.3(ajv@8.17.1)
'@stoplight/json': 3.21.0
- '@stoplight/spectral-core': 1.19.5
+ '@stoplight/spectral-core': 1.20.0
'@stoplight/spectral-formats': 1.8.2
'@stoplight/spectral-runtime': 1.1.4
ajv: 8.17.1
@@ -10238,7 +10238,7 @@ packages:
/@types/accepts@1.3.7:
resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
dependencies:
- '@types/node': 20.14.9
+ '@types/node': 22.14.0
dev: false
/@types/aria-query@5.0.4:
@@ -10249,13 +10249,13 @@ packages:
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
dependencies:
'@types/connect': 3.4.38
- '@types/node': 20.14.9
+ '@types/node': 22.14.0
dev: false
/@types/connect@3.4.38:
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
dependencies:
- '@types/node': 20.14.9
+ '@types/node': 22.14.0
dev: false
/@types/content-disposition@0.5.8:
@@ -10265,7 +10265,7 @@ packages:
/@types/conventional-commits-parser@5.0.1:
resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==}
dependencies:
- '@types/node': 20.14.9
+ '@types/node': 22.14.0
dev: true
optional: true
@@ -10287,7 +10287,7 @@ packages:
'@types/connect': 3.4.38
'@types/express': 4.17.21
'@types/keygrip': 1.0.6
- '@types/node': 20.14.9
+ '@types/node': 22.14.0
dev: false
/@types/cors@2.8.17:
@@ -10395,7 +10395,7 @@ packages:
/@types/express-serve-static-core@4.19.6:
resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==}
dependencies:
- '@types/node': 20.14.9
+ '@types/node': 22.14.0
'@types/qs': 6.9.18
'@types/range-parser': 1.2.7
'@types/send': 0.17.4
@@ -10460,7 +10460,7 @@ packages:
'@types/http-errors': 2.0.4
'@types/keygrip': 1.0.6
'@types/koa-compose': 3.2.8
- '@types/node': 20.14.9
+ '@types/node': 22.14.0
dev: false
/@types/mdast@4.0.4:
@@ -10493,7 +10493,7 @@ packages:
/@types/node-fetch@2.6.12:
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
dependencies:
- '@types/node': 20.14.9
+ '@types/node': 22.14.0
form-data: 4.0.2
/@types/node@12.20.55:
@@ -10573,14 +10573,14 @@ packages:
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
dependencies:
'@types/mime': 1.3.5
- '@types/node': 20.14.9
+ '@types/node': 22.14.0
dev: false
/@types/serve-static@1.15.7:
resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
dependencies:
'@types/http-errors': 2.0.4
- '@types/node': 20.14.9
+ '@types/node': 22.14.0
'@types/send': 0.17.4
dev: false
@@ -11747,7 +11747,7 @@ packages:
hasBin: true
dependencies:
caniuse-lite: 1.0.30001715
- electron-to-chromium: 1.5.139
+ electron-to-chromium: 1.5.140
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.24.4)
@@ -12603,7 +12603,7 @@ packages:
resolution: {integrity: sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==}
engines: {node: '>=18'}
dependencies:
- '@asamuzakjp/css-color': 3.1.3
+ '@asamuzakjp/css-color': 3.1.4
rrweb-cssom: 0.8.0
dev: true
@@ -12851,7 +12851,7 @@ packages:
dependencies:
is-arguments: 1.2.0
is-date-object: 1.1.0
- is-regex: 1.2.1
+ is-regex: 1.1.4
object-is: 1.1.6
object-keys: 1.1.1
regexp.prototype.flags: 1.5.4
@@ -12963,8 +12963,8 @@ packages:
engines: {node: '>=8'}
dev: true
- /detect-libc@2.0.3:
- resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
+ /detect-libc@2.0.4:
+ resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
/detect-node-es@1.1.0:
@@ -13292,8 +13292,8 @@ packages:
jake: 10.9.2
dev: true
- /electron-to-chromium@1.5.139:
- resolution: {integrity: sha512-GGnRYOTdN5LYpwbIr0rwP/ZHOQSvAF6TG0LSzp28uCBb9JiXHJGmaaKw29qjNJc5bGnnp6kXJqRnGMQoELwi5w==}
+ /electron-to-chromium@1.5.140:
+ resolution: {integrity: sha512-o82Rj+ONp4Ip7Cl1r7lrqx/pXhbp/lh9DpKcMNscFJdh8ebyRofnc7Sh01B4jx403RI0oqTBvlZ7OBIZLMr2+Q==}
/emoji-regex-xs@1.0.0:
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
@@ -13399,6 +13399,12 @@ packages:
/entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
+ dev: false
+
+ /entities@6.0.0:
+ resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==}
+ engines: {node: '>=0.12'}
+ dev: true
/env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
@@ -13499,8 +13505,8 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
- /es-module-lexer@1.6.0:
- resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==}
+ /es-module-lexer@1.7.0:
+ resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
dev: false
/es-object-atoms@1.1.1:
@@ -14733,7 +14739,7 @@ packages:
openapi-sampler: 1.6.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
- react-hook-form: 7.56.0(react@18.3.1)
+ react-hook-form: 7.56.1(react@18.3.1)
remark: 15.0.1
remark-rehype: 11.1.2
shiki: 1.29.2
@@ -14753,7 +14759,7 @@ packages:
react: '>= 18'
shiki: 1.x.x
dependencies:
- '@radix-ui/react-popover': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-popover': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@shikijs/twoslash': 1.29.2(typescript@5.7.3)
fumadocs-ui: 14.4.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(next@14.2.15)(react-dom@18.3.1)(react@18.3.1)(tailwindcss@3.4.15)
mdast-util-from-markdown: 2.0.2
@@ -14799,15 +14805,15 @@ packages:
tailwindcss:
optional: true
dependencies:
- '@radix-ui/react-accordion': 1.2.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-collapsible': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-dialog': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-accordion': 1.2.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-collapsible': 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-dialog': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.11)(react@18.3.1)
- '@radix-ui/react-navigation-menu': 1.2.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-popover': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-scroll-area': 1.2.5(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-navigation-menu': 1.2.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-popover': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-scroll-area': 1.2.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-slot': 1.1.0(@types/react@18.3.11)(react@18.3.1)
- '@radix-ui/react-tabs': 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-tabs': 1.1.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@tailwindcss/typography': 0.5.16(tailwindcss@3.4.15)
class-variance-authority: 0.7.1
fumadocs-core: 14.4.0(@types/react@18.3.11)(next@14.2.15)(react-dom@18.3.1)(react@18.3.1)
@@ -14838,15 +14844,15 @@ packages:
tailwindcss:
optional: true
dependencies:
- '@radix-ui/react-accordion': 1.2.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-collapsible': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-dialog': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-accordion': 1.2.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-collapsible': 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-dialog': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.11)(react@18.3.1)
- '@radix-ui/react-navigation-menu': 1.2.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-popover': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
- '@radix-ui/react-scroll-area': 1.2.5(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-navigation-menu': 1.2.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-popover': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-scroll-area': 1.2.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-slot': 1.1.0(@types/react@18.3.11)(react@18.3.1)
- '@radix-ui/react-tabs': 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-tabs': 1.1.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
class-variance-authority: 0.7.1
fumadocs-core: 14.5.4(@types/react@18.3.11)(next@14.2.15)(react-dom@18.3.1)(react@18.3.1)
lodash.merge: 4.6.2
@@ -15308,7 +15314,7 @@ packages:
'@types/hast': 3.0.4
devlop: 1.1.0
hast-util-from-parse5: 8.0.3
- parse5: 7.2.1
+ parse5: 7.3.0
vfile: 6.0.3
vfile-message: 4.0.2
dev: true
@@ -16361,7 +16367,7 @@ packages:
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.20
- parse5: 7.2.1
+ parse5: 7.3.0
rrweb-cssom: 0.8.0
saxes: 6.0.0
symbol-tree: 3.2.4
@@ -18665,10 +18671,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
- /parse5@7.2.1:
- resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==}
+ /parse5@7.3.0:
+ resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
dependencies:
- entities: 4.5.0
+ entities: 6.0.0
dev: true
/parseley@0.12.1:
@@ -19416,8 +19422,8 @@ packages:
react: 18.3.1
dev: false
- /react-hook-form@7.56.0(react@18.3.1):
- resolution: {integrity: sha512-U2QQgx5z2Y8Z0qlXv3W19hWHJgfKdWMz0O/osuY+o+CYq568V2R/JhzC6OAXfR8k24rIN0Muan2Qliaq9eKs/g==}
+ /react-hook-form@7.56.1(react@18.3.1):
+ resolution: {integrity: sha512-qWAVokhSpshhcEuQDSANHx3jiAEFzu2HAaaQIzi/r9FNPm1ioAvuJSD4EuZzWd7Al7nTRKcKPnBKO7sRn+zavQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
@@ -20251,8 +20257,8 @@ packages:
dependencies:
loose-envify: 1.4.0
- /schema-utils@4.3.0:
- resolution: {integrity: sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==}
+ /schema-utils@4.3.2:
+ resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==}
engines: {node: '>= 10.13.0'}
dependencies:
'@types/json-schema': 7.0.15
@@ -20394,7 +20400,7 @@ packages:
requiresBuild: true
dependencies:
color: 4.2.3
- detect-libc: 2.0.3
+ detect-libc: 2.0.4
semver: 7.7.1
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.4
@@ -20423,7 +20429,7 @@ packages:
requiresBuild: true
dependencies:
color: 4.2.3
- detect-libc: 2.0.3
+ detect-libc: 2.0.4
semver: 7.7.1
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5
@@ -21408,7 +21414,7 @@ packages:
'@swc/core': 1.3.101
esbuild: 0.19.11
jest-worker: 27.5.1
- schema-utils: 4.3.0
+ schema-utils: 4.3.2
serialize-javascript: 6.0.2
terser: 5.39.0
webpack: 5.99.6(@swc/core@1.3.101)(esbuild@0.19.11)
@@ -22756,7 +22762,7 @@ packages:
browserslist: 4.24.4
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.1
- es-module-lexer: 1.6.0
+ es-module-lexer: 1.7.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
@@ -22765,7 +22771,7 @@ packages:
loader-runner: 4.3.0
mime-types: 2.1.35
neo-async: 2.6.2
- schema-utils: 4.3.0
+ schema-utils: 4.3.2
tapable: 2.2.1
terser-webpack-plugin: 5.3.14(@swc/core@1.3.101)(esbuild@0.19.11)(webpack@5.99.6)
watchpack: 2.4.2