From 26154af25cdd7bed94a82cbbf75cf20af06daa83 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 28 Aug 2025 15:35:49 -0600 Subject: [PATCH] fix: prevent unauthorized API calls before authentication - Add global AuthGuard component to handle authentication at layout level - Make all API query hooks conditional on authentication status - Define public routes (like /transcripts/new) that don't require auth - Fix login flow to use NextAuth signIn instead of non-existent /login route - Prevent 401 errors by waiting for auth token before making API calls Previously, all routes under (app) were publicly accessible with each page handling auth individually. Now authentication is enforced globally while still allowing specific routes to remain public. --- www/app/(app)/AuthGuard.tsx | 70 +++++++++++++++++++++++ www/app/(app)/layout.tsx | 3 +- www/app/lib/ApiAuthProvider.tsx | 46 +--------------- www/app/lib/api-hooks.ts | 98 ++++++++++++++++++++++----------- www/app/lib/apiClient.tsx | 6 +- 5 files changed, 144 insertions(+), 79 deletions(-) create mode 100644 www/app/(app)/AuthGuard.tsx diff --git a/www/app/(app)/AuthGuard.tsx b/www/app/(app)/AuthGuard.tsx new file mode 100644 index 00000000..19af12e7 --- /dev/null +++ b/www/app/(app)/AuthGuard.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { signIn } from "next-auth/react"; +import useSessionStatus from "../lib/useSessionStatus"; +import { Flex, Spinner } from "@chakra-ui/react"; + +interface AuthGuardProps { + children: React.ReactNode; + requireAuth?: boolean; +} + +// Routes that should be accessible without authentication +const PUBLIC_ROUTES = ["/transcripts/new"]; + +export default function AuthGuard({ + children, + requireAuth = true, +}: AuthGuardProps) { + const { isAuthenticated, isLoading, status } = useSessionStatus(); + const router = useRouter(); + const pathname = usePathname(); + + // Check if current route is public + const isPublicRoute = PUBLIC_ROUTES.some((route) => + pathname.startsWith(route), + ); + + useEffect(() => { + // Don't require auth for public routes + if (isPublicRoute) return; + + // Only redirect if we're sure the user is not authenticated and auth is required + if (!isLoading && requireAuth && status === "unauthenticated") { + // Instead of redirecting to /login, trigger NextAuth signIn + signIn("authentik"); + } + }, [isLoading, requireAuth, status, isPublicRoute]); + + // For public routes, always show content + if (isPublicRoute) { + return <>{children}; + } + + // Show loading spinner while checking authentication + if ( + isLoading || + (requireAuth && !isAuthenticated && status !== "unauthenticated") + ) { + return ( + + + + ); + } + + // If authentication is not required or user is authenticated, show content + if (!requireAuth || isAuthenticated) { + return <>{children}; + } + + // Don't render anything while redirecting + return null; +} diff --git a/www/app/(app)/layout.tsx b/www/app/(app)/layout.tsx index 053df5a7..749ce189 100644 --- a/www/app/(app)/layout.tsx +++ b/www/app/(app)/layout.tsx @@ -6,6 +6,7 @@ import About from "../(aboutAndPrivacy)/about"; import Privacy from "../(aboutAndPrivacy)/privacy"; import UserInfo from "../(auth)/userInfo"; import { RECORD_A_MEETING_URL } from "../lib/constants"; +import AuthGuard from "./AuthGuard"; export default async function AppLayout({ children, @@ -90,7 +91,7 @@ export default async function AppLayout({ - {children} + {children} ); } diff --git a/www/app/lib/ApiAuthProvider.tsx b/www/app/lib/ApiAuthProvider.tsx index 8e27f6b7..fd8bdc96 100644 --- a/www/app/lib/ApiAuthProvider.tsx +++ b/www/app/lib/ApiAuthProvider.tsx @@ -1,53 +1,13 @@ "use client"; -import { useEffect, useContext, useRef } from "react"; -import { client, configureApiAuth } from "./apiClient"; +import { useEffect } from "react"; +import { configureApiAuth } from "./apiClient"; import useSessionAccessToken from "./useSessionAccessToken"; -import { DomainContext } from "../domainContext"; -// Store the current API URL globally -let currentApiUrl: string | null = null; - -// Set up base URL middleware once -const baseUrlMiddlewareSetup = () => { - client.use({ - onRequest({ request }) { - if (currentApiUrl) { - // Update the base URL for all requests - const url = new URL(request.url); - const apiUrl = new URL(currentApiUrl); - url.protocol = apiUrl.protocol; - url.host = apiUrl.host; - url.port = apiUrl.port; - return new Request(url.toString(), request); - } - return request; - }, - }); -}; - -// Initialize base URL middleware once -if (typeof window !== "undefined") { - baseUrlMiddlewareSetup(); -} +// Note: Base URL is now configured directly in apiClient.tsx export function ApiAuthProvider({ children }: { children: React.ReactNode }) { const { accessToken } = useSessionAccessToken(); - const { api_url } = useContext(DomainContext); - const initialized = useRef(false); - - // Initialize middleware once on client side - useEffect(() => { - if (!initialized.current && typeof window !== "undefined") { - baseUrlMiddlewareSetup(); - initialized.current = true; - } - }, []); - - useEffect(() => { - // Update the global API URL - currentApiUrl = api_url; - }, [api_url]); useEffect(() => { // Configure authentication diff --git a/www/app/lib/api-hooks.ts b/www/app/lib/api-hooks.ts index 02ae66da..caa8e47c 100644 --- a/www/app/lib/api-hooks.ts +++ b/www/app/lib/api-hooks.ts @@ -4,16 +4,26 @@ import { $api } from "./apiClient"; import { useError } from "../(errors)/errorContext"; import { useQueryClient } from "@tanstack/react-query"; import type { paths } from "../reflector-api"; +import useSessionStatus from "./useSessionStatus"; // Rooms hooks export function useRoomsList(page: number = 1) { const { setError } = useError(); + const { isAuthenticated, isLoading } = useSessionStatus(); - return $api.useQuery("get", "/v1/rooms", { - params: { - query: { page }, + return $api.useQuery( + "get", + "/v1/rooms", + { + params: { + query: { page }, + }, }, - }); + { + // Only fetch when authenticated + enabled: isAuthenticated && !isLoading, + }, + ); } // Transcripts hooks @@ -27,18 +37,27 @@ export function useTranscriptsSearch( } = {}, ) { const { setError } = useError(); + const { isAuthenticated, isLoading } = useSessionStatus(); - return $api.useQuery("get", "/v1/transcripts/search", { - params: { - query: { - q, - limit: options.limit, - offset: options.offset, - room_id: options.room_id, - source_kind: options.source_kind as any, + return $api.useQuery( + "get", + "/v1/transcripts/search", + { + params: { + query: { + q, + limit: options.limit, + offset: options.offset, + room_id: options.room_id, + source_kind: options.source_kind as any, + }, }, }, - }); + { + // Only fetch when authenticated + enabled: isAuthenticated && !isLoading, + }, + ); } export function useTranscriptDelete() { @@ -72,6 +91,7 @@ export function useTranscriptProcess() { export function useTranscriptGet(transcriptId: string | null) { const { setError } = useError(); + const { isAuthenticated, isLoading } = useSessionStatus(); return $api.useQuery( "get", @@ -84,7 +104,8 @@ export function useTranscriptGet(transcriptId: string | null) { }, }, { - enabled: !!transcriptId, + // Only fetch when authenticated and transcriptId is provided + enabled: !!transcriptId && isAuthenticated && !isLoading, }, ); } @@ -141,25 +162,32 @@ export function useRoomDelete() { // Zulip hooks - NOTE: These endpoints are not in the OpenAPI spec yet export function useZulipStreams() { const { setError } = useError(); - - // @ts-ignore - Zulip endpoint not in OpenAPI spec - return $api.useQuery("get", "/v1/zulip/get-streams" as any, {}); -} - -export function useZulipTopics(streamId: number | null) { - const { setError } = useError(); + const { isAuthenticated, isLoading } = useSessionStatus(); // @ts-ignore - Zulip endpoint not in OpenAPI spec return $api.useQuery( "get", - "/v1/zulip/get-topics" as any, + "/v1/zulip/streams" as any, + {}, { - params: { - query: { stream_id: streamId || 0 }, - }, + // Only fetch when authenticated + enabled: isAuthenticated && !isLoading, }, + ); +} + +export function useZulipTopics(streamId: number | null) { + const { setError } = useError(); + const { isAuthenticated, isLoading } = useSessionStatus(); + + // @ts-ignore - Zulip endpoint not in OpenAPI spec + return $api.useQuery( + "get", + streamId ? (`/v1/zulip/streams/${streamId}/topics` as any) : null, + {}, { - enabled: !!streamId, + // Only fetch when authenticated and streamId is provided + enabled: !!streamId && isAuthenticated && !isLoading, }, ); } @@ -233,6 +261,7 @@ export function useTranscriptUploadAudio() { // Transcript queries export function useTranscriptWaveform(transcriptId: string | null) { const { setError } = useError(); + const { isAuthenticated, isLoading } = useSessionStatus(); return $api.useQuery( "get", @@ -243,13 +272,14 @@ export function useTranscriptWaveform(transcriptId: string | null) { }, }, { - enabled: !!transcriptId, + enabled: !!transcriptId && isAuthenticated && !isLoading, }, ); } export function useTranscriptMP3(transcriptId: string | null) { const { setError } = useError(); + const { isAuthenticated, isLoading } = useSessionStatus(); return $api.useQuery( "get", @@ -260,13 +290,14 @@ export function useTranscriptMP3(transcriptId: string | null) { }, }, { - enabled: !!transcriptId, + enabled: !!transcriptId && isAuthenticated && !isLoading, }, ); } export function useTranscriptTopics(transcriptId: string | null) { const { setError } = useError(); + const { isAuthenticated, isLoading } = useSessionStatus(); return $api.useQuery( "get", @@ -277,13 +308,14 @@ export function useTranscriptTopics(transcriptId: string | null) { }, }, { - enabled: !!transcriptId, + enabled: !!transcriptId && isAuthenticated && !isLoading, }, ); } export function useTranscriptTopicsWithWords(transcriptId: string | null) { const { setError } = useError(); + const { isAuthenticated, isLoading } = useSessionStatus(); return $api.useQuery( "get", @@ -294,7 +326,7 @@ export function useTranscriptTopicsWithWords(transcriptId: string | null) { }, }, { - enabled: !!transcriptId, + enabled: !!transcriptId && isAuthenticated && !isLoading, }, ); } @@ -304,6 +336,7 @@ export function useTranscriptTopicsWithWordsPerSpeaker( topicId: string | null, ) { const { setError } = useError(); + const { isAuthenticated, isLoading } = useSessionStatus(); return $api.useQuery( "get", @@ -317,7 +350,7 @@ export function useTranscriptTopicsWithWordsPerSpeaker( }, }, { - enabled: !!transcriptId && !!topicId, + enabled: !!transcriptId && !!topicId && isAuthenticated && !isLoading, }, ); } @@ -325,6 +358,7 @@ export function useTranscriptTopicsWithWordsPerSpeaker( // Participant operations export function useTranscriptParticipants(transcriptId: string | null) { const { setError } = useError(); + const { isAuthenticated, isLoading } = useSessionStatus(); return $api.useQuery( "get", @@ -335,7 +369,7 @@ export function useTranscriptParticipants(transcriptId: string | null) { }, }, { - enabled: !!transcriptId, + enabled: !!transcriptId && isAuthenticated && !isLoading, }, ); } diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx index 2b320267..0a167f68 100644 --- a/www/app/lib/apiClient.tsx +++ b/www/app/lib/apiClient.tsx @@ -10,10 +10,10 @@ import { } from "@tanstack/react-query"; import createFetchClient from "openapi-react-query"; -// Create the base openapi-fetch client +// Create the base openapi-fetch client with a default URL +// The actual URL will be set via middleware in ApiAuthProvider export const client = createClient({ - // Base URL will be set dynamically via middleware - baseUrl: "", + baseUrl: "http://127.0.0.1:1250", headers: { "Content-Type": "application/json", },