feat(frontend): handle cross-tab login/logout + auth architecture refactor (#10150)

## 🏗️ Changes 

### 🧢 Authentication improvements
- Updates for [CASA compliance](https://appdefensealliance.dev/casa)
  - implemented cross-tab login/logout
  - logout now triggers cross-device logout 
  - forgot password triggers cross-device logout
- we are already able to revoke sessions given Supabase stores sessions
🙌🏽

### 📙 Cross-tab login/logout implementation

I implemented some session validation debouncing ( _2-second cooldown_ )
to prevent excessive API calls when switching tabs fast ( _more of an
edge-case but could happen_ ). Cross tab implementation is done via
`localStorage` and `window.visibility` events.

### Refactor and cleanup

Smol things to improve our auth logic on the Frontend:
- created `helpers.ts` with utilities for protected page detection,
admin page routing, and cross-tab communication
- added `STORAGE_KEYS`, `PROTECTED_PAGES`, and `ADMIN_PAGES` constants
for better organization
- refactored server-side Supabase utilities and middleware
- updated import paths to use named exports

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Cross-tab logout synchronization works correctly
  - [x] Session validation debouncing prevents excessive API calls
  - [x] Protected page redirects function properly
  - [x] Authentication state persists correctly across tabs
  - [x] Role-based access controls work as expected
  - [x] Cross-device logout is performed after forgot password change

### Cross-tab login/logout 


https://github.com/user-attachments/assets/5dbdd204-faa2-419f-b989-e31f69ddabd6

### Cross-device logout


https://github.com/user-attachments/assets/aac9c97a-beec-4519-a391-f94f988dc7c8
This commit is contained in:
Ubbe
2025-06-13 19:28:03 +04:00
committed by GitHub
parent 6e253ecade
commit fb18ddf95d
28 changed files with 286 additions and 130 deletions

View File

@@ -1,4 +1,4 @@
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import BackendAPI from "@/lib/autogpt-server-api";
import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";

View File

@@ -2,7 +2,7 @@ import { type EmailOtpType } from "@supabase/supabase-js";
import { type NextRequest } from "next/server";
import { redirect } from "next/navigation";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
// Email confirmation route
export async function GET(request: NextRequest) {

View File

@@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import BackendAPI from "@/lib/autogpt-server-api";
import { loginFormSchema, LoginProvider } from "@/types/auth";
import { verifyTurnstileToken } from "@/lib/turnstile";

View File

@@ -1,5 +1,5 @@
import { useTurnstile } from "@/hooks/useTurnstile";
import useSupabase from "@/lib/supabase/useSupabase";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { loginFormSchema, LoginProvider } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";

View File

@@ -6,7 +6,7 @@ import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
import { BecomeACreator } from "@/components/agptui/BecomeACreator";
import { Separator } from "@/components/ui/separator";
import { Metadata } from "next";
import getServerUser from "@/lib/supabase/getServerUser";
import { getServerUser } from "@/lib/supabase/server/getServerUser";
// Force dynamic rendering to avoid static generation issues with cookies
export const dynamic = "force-dynamic";

View File

@@ -11,7 +11,7 @@ import {
StoreSubmissionsResponse,
StoreSubmissionRequest,
} from "@/lib/autogpt-server-api/types";
import useSupabase from "@/lib/supabase/useSupabase";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
export default function Page({}: {}) {

View File

@@ -26,7 +26,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import useSupabase from "@/lib/supabase/useSupabase";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import LoadingBox from "@/components/ui/loading";
export default function UserIntegrationsPage() {

View File

@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import BackendApi from "@/lib/autogpt-server-api";
import { NotificationPreferenceDTO } from "@/lib/autogpt-server-api/types";

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import { Metadata } from "next";
import SettingsForm from "@/components/profile/settings/SettingsForm";
import getServerUser from "@/lib/supabase/getServerUser";
import { getServerUser } from "@/lib/supabase/server/getServerUser";
import { redirect } from "next/navigation";
import { getUserPreferences } from "./actions";

View File

@@ -1,5 +1,5 @@
"use server";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import { redirect } from "next/navigation";
import * as Sentry from "@sentry/nextjs";
import { verifyTurnstileToken } from "@/lib/turnstile";
@@ -64,7 +64,7 @@ export async function changePassword(password: string, turnstileToken: string) {
return error.message;
}
await supabase.auth.signOut();
await supabase.auth.signOut({ scope: "global" });
redirect("/login");
},
);

View File

@@ -17,7 +17,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import useSupabase from "@/lib/supabase/useSupabase";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { sendEmailFormSchema, changePasswordFormSchema } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useState } from "react";

View File

@@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import { signupFormSchema } from "@/types/auth";
import BackendAPI from "@/lib/autogpt-server-api";
import { verifyTurnstileToken } from "@/lib/turnstile";

View File

@@ -1,5 +1,5 @@
import { useTurnstile } from "@/hooks/useTurnstile";
import useSupabase from "@/lib/supabase/useSupabase";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { signupFormSchema, LoginProvider } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";

View File

@@ -1,5 +1,5 @@
// components/RoleBasedAccess.tsx
import useSupabase from "@/lib/supabase/useSupabase";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import React from "react";
interface RoleBasedAccessProps {

View File

@@ -7,8 +7,9 @@ import { Button } from "./Button";
import Wallet from "./Wallet";
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
import { NavbarLink } from "./NavbarLink";
import getServerUser from "@/lib/supabase/getServerUser";
import BackendAPI from "@/lib/autogpt-server-api";
import { getServerUser } from "@/lib/supabase/server/getServerUser";
// Disable theme toggle for now
// import { ThemeToggle } from "./ThemeToggle";

View File

@@ -9,7 +9,7 @@ import { Button } from "./Button";
import { IconPersonFill } from "@/components/ui/icons";
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
import { Separator } from "@/components/ui/separator";
import useSupabase from "@/lib/supabase/useSupabase";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
export const ProfileInfoForm = ({ profile }: { profile: ProfileDetails }) => {

View File

@@ -1,5 +1,5 @@
"use client";
import useSupabase from "@/lib/supabase/useSupabase";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { IconLogOut } from "@/components/ui/icons";
import { useTransition } from "react";
import { LoadingSpinner } from "../ui/loading";
@@ -16,7 +16,7 @@ export function ProfilePopoutMenuLogoutButton() {
function handleLogout() {
startTransition(async () => {
try {
await supabase.logOut();
await supabase.logOut({ scope: "global" });
router.refresh();
} catch (e) {
Sentry.captureException(e);

View File

@@ -5,6 +5,7 @@ import { SearchBar } from "@/components/agptui/SearchBar";
import { FilterChips } from "@/components/agptui/FilterChips";
import { useRouter } from "next/navigation";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
export const HeroSection: React.FC = () => {
const router = useRouter();

View File

@@ -1,5 +1,5 @@
import { createContext, useCallback, useEffect, useState } from "react";
import useSupabase from "@/lib/supabase/useSupabase";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import {
APIKeyCredentials,
CredentialsDeleteNeedConfirmationResponse,

View File

@@ -1,5 +1,5 @@
"use client";
import useSupabase from "@/lib/supabase/useSupabase";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { OnboardingStep, UserOnboarding } from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { usePathname, useRouter } from "next/navigation";

View File

@@ -1,4 +1,4 @@
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import { createBrowserClient } from "@supabase/ssr";
import type { SupabaseClient } from "@supabase/supabase-js";
import type {

View File

@@ -0,0 +1,83 @@
// Session management constants and utilities
export const PROTECTED_PAGES = [
"/monitor",
"/build",
"/onboarding",
"/profile",
"/library",
"/monitoring",
] as const;
export const ADMIN_PAGES = ["/admin"] as const;
export const STORAGE_KEYS = {
LOGOUT: "supabase-logout",
} as const;
// Page protection utilities
export function isProtectedPage(pathname: string): boolean {
return PROTECTED_PAGES.some((page) => pathname.startsWith(page));
}
export function isAdminPage(pathname: string): boolean {
return ADMIN_PAGES.some((page) => pathname.startsWith(page));
}
export function shouldRedirectOnLogout(pathname: string): boolean {
return isProtectedPage(pathname) || isAdminPage(pathname);
}
// Cross-tab logout utilities
export function broadcastLogout(): void {
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEYS.LOGOUT, Date.now().toString());
}
}
export function isLogoutEvent(event: StorageEvent): boolean {
return event.key === STORAGE_KEYS.LOGOUT;
}
// Redirect utilities
export function getRedirectPath(
pathname: string,
userRole?: string,
): string | null {
if (shouldRedirectOnLogout(pathname)) {
return "/login";
}
if (isAdminPage(pathname) && userRole !== "admin") {
return "/marketplace";
}
return null;
}
// Event listener management
export interface EventListeners {
cleanup: () => void;
}
export function setupSessionEventListeners(
onVisibilityChange: () => void,
onFocus: () => void,
onStorageChange: (e: StorageEvent) => void,
): EventListeners {
if (typeof window === "undefined" || typeof document === "undefined") {
return { cleanup: () => {} };
}
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("focus", onFocus);
window.addEventListener("storage", onStorageChange);
return {
cleanup: () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("focus", onFocus);
window.removeEventListener("storage", onStorageChange);
},
};
}

View File

@@ -0,0 +1,158 @@
"use client";
import { useEffect, useMemo, useState, useRef } from "react";
import { createBrowserClient } from "@supabase/ssr";
import { SignOut, User } from "@supabase/supabase-js";
import { useRouter } from "next/navigation";
import {
broadcastLogout,
getRedirectPath,
isLogoutEvent,
setupSessionEventListeners,
} from "../helpers";
export function useSupabase() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [isUserLoading, setIsUserLoading] = useState(true);
const lastValidationRef = useRef<number>(0);
const supabase = useMemo(() => {
try {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ isSingleton: true },
);
} catch (error) {
console.error("Error creating Supabase client", error);
return null;
}
}, []);
async function logOut(options?: SignOut) {
if (!supabase) return;
broadcastLogout();
const { error } = await supabase.auth.signOut({
scope: "global",
});
if (error) console.error("Error logging out:", error);
router.push("/login");
}
async function validateSession() {
if (!supabase) return false;
// Simple debounce - only validate if 2 seconds have passed
const now = Date.now();
if (now - lastValidationRef.current < 2000) {
return true;
}
lastValidationRef.current = now;
try {
const {
data: { user: apiUser },
error,
} = await supabase.auth.getUser();
if (error || !apiUser) {
// Session is invalid, clear local state
setUser(null);
const redirectPath = getRedirectPath(window.location.pathname);
if (redirectPath) {
router.push(redirectPath);
}
return false;
}
// Update local state if we have a valid user but no local user
if (apiUser && !user) {
setUser(apiUser);
}
return true;
} catch (error) {
console.error("Session validation error:", error);
setUser(null);
const redirectPath = getRedirectPath(window.location.pathname);
if (redirectPath) {
router.push(redirectPath);
}
return false;
}
}
function handleCrossTabLogout(e: StorageEvent) {
if (!isLogoutEvent(e)) return;
// Clear the Supabase session first
if (supabase) {
supabase.auth.signOut({ scope: "global" }).catch(console.error);
}
// Clear local state immediately
setUser(null);
router.refresh();
const redirectPath = getRedirectPath(window.location.pathname);
if (redirectPath) {
router.push(redirectPath);
}
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") {
validateSession();
}
}
function handleFocus() {
validateSession();
}
useEffect(() => {
if (!supabase) {
setIsUserLoading(false);
return;
}
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
const newUser = session?.user ?? null;
// Only update if user actually changed to prevent unnecessary re-renders
setUser((currentUser) => {
if (currentUser?.id !== newUser?.id) {
return newUser;
}
return currentUser;
});
setIsUserLoading(false);
});
const eventListeners = setupSessionEventListeners(
handleVisibilityChange,
handleFocus,
handleCrossTabLogout,
);
return () => {
subscription.unsubscribe();
eventListeners.cleanup();
};
}, [supabase]);
return {
supabase,
user,
isLoggedIn: !isUserLoading ? !!user : null,
isUserLoading,
logOut,
validateSession,
};
}

View File

@@ -1,16 +1,11 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
// TODO: Update the protected pages list
const PROTECTED_PAGES = [
"/monitor",
"/build",
"/onboarding",
"/profile",
"/library",
"/monitoring",
];
const ADMIN_PAGES = ["/admin"];
import {
PROTECTED_PAGES,
ADMIN_PAGES,
isProtectedPage,
isAdminPage,
} from "./helpers";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
@@ -59,41 +54,26 @@ export async function updateSession(request: NextRequest) {
error,
} = await supabase.auth.getUser();
// Get the user role
const userRole = user?.role;
const url = request.nextUrl.clone();
const pathname = request.nextUrl.pathname;
// AUTH REDIRECTS
// 1. Check if user is not authenticated but trying to access protected content
if (!user) {
// Check if the user is trying to access either a protected page or an admin page
const isAttemptingProtectedPage = PROTECTED_PAGES.some((page) =>
request.nextUrl.pathname.startsWith(page),
);
const attemptingProtectedPage = isProtectedPage(pathname);
const attemptingAdminPage = isAdminPage(pathname);
const isAttemptingAdminPage = ADMIN_PAGES.some((page) =>
request.nextUrl.pathname.startsWith(page),
);
// If trying to access any protected content without being logged in,
// redirect to login page
if (isAttemptingProtectedPage || isAttemptingAdminPage) {
url.pathname = `/login`;
if (attemptingProtectedPage || attemptingAdminPage) {
url.pathname = "/login";
return NextResponse.redirect(url);
}
}
// 2. Check if user is authenticated but lacks admin role when accessing admin pages
if (user && userRole !== "admin") {
const isAttemptingAdminPage = ADMIN_PAGES.some((page) =>
request.nextUrl.pathname.startsWith(page),
);
// If a non-admin user is trying to access admin pages,
// redirect to marketplace
if (isAttemptingAdminPage) {
url.pathname = `/marketplace`;
return NextResponse.redirect(url);
}
if (user && userRole !== "admin" && isAdminPage(pathname)) {
url.pathname = "/marketplace";
return NextResponse.redirect(url);
}
// IMPORTANT: You *must* return the supabaseResponse object as it is. If you're

View File

@@ -1,7 +1,7 @@
import type { UnsafeUnwrappedCookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
export default async function getServerSupabase() {
export async function getServerSupabase() {
// Need require here, so Next.js doesn't complain about importing this on client side
const { cookies } = require("next/headers");
const cookieStore = await cookies();

View File

@@ -1,6 +1,6 @@
import getServerSupabase from "./getServerSupabase";
import { getServerSupabase } from "./getServerSupabase";
const getServerUser = async () => {
export async function getServerUser() {
const supabase = await getServerSupabase();
if (!supabase) {
@@ -10,7 +10,7 @@ const getServerUser = async () => {
try {
const {
data: { user },
error,
error: _,
} = await supabase.auth.getUser();
// if (error) {
// // FIX: Suppressing error for now. Need to stop the nav bar calling this all the time
@@ -30,6 +30,4 @@ const getServerUser = async () => {
error: `Unexpected error: ${(error as Error).message}`,
};
}
};
export default getServerUser;
}

View File

@@ -1,65 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createBrowserClient } from "@supabase/ssr";
import { SignOut, User } from "@supabase/supabase-js";
import { useRouter } from "next/navigation";
export default function useSupabase() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [isUserLoading, setIsUserLoading] = useState(true);
const supabase = useMemo(() => {
try {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ isSingleton: true },
);
} catch (error) {
console.error("Error creating Supabase client", error);
return null;
}
}, []);
useEffect(() => {
if (!supabase) {
setIsUserLoading(false);
return;
}
// Sync up the current state and listen for changes
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
setIsUserLoading(false);
});
return () => {
subscription.unsubscribe();
};
}, [supabase]);
const logOut = useCallback(
async (options?: SignOut) => {
if (!supabase) return;
const { error } = await supabase.auth.signOut({
scope: options?.scope ?? "local",
});
if (error) console.error("Error logging out:", error);
router.push("/login");
},
[router, supabase],
);
if (!supabase || isUserLoading) {
return { supabase, user: null, isLoggedIn: null, isUserLoading, logOut };
}
if (!user) {
return { supabase, user, isLoggedIn: false, isUserLoading, logOut };
}
return { supabase, user, isLoggedIn: true, isUserLoading, logOut };
}

View File

@@ -1,7 +1,7 @@
import React from "react";
import * as Sentry from "@sentry/nextjs";
import { redirect } from "next/navigation";
import getServerUser from "./supabase/getServerUser";
import { getServerUser } from "./supabase/server/getServerUser";
export async function withRoleAccess(allowedRoles: string[]) {
"use server";