From 08b82c76ce390cd6a5bacfefd33509d357cd3ba5 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Wed, 3 Sep 2025 07:10:20 -0400 Subject: [PATCH] normalize auth provider --- www/REDIS-FREE-AUTH.md | 354 ------------------------- www/app/(app)/transcripts/new/page.tsx | 21 +- www/app/(app)/transcripts/useMp3.ts | 9 +- www/app/(auth)/userInfo.tsx | 11 +- www/app/[roomName]/page.tsx | 4 +- www/app/layout.tsx | 23 +- www/app/lib/AuthProvider.tsx | 47 ++-- www/app/lib/SessionAutoRefresh.tsx | 16 +- www/app/lib/SessionProvider.tsx | 11 - www/app/lib/auth.ts | 8 +- www/app/lib/types.ts | 4 +- www/app/lib/useSessionAccessToken.ts | 15 -- www/app/lib/useSessionStatus.ts | 8 - www/app/providers.tsx | 19 +- 14 files changed, 80 insertions(+), 470 deletions(-) delete mode 100644 www/REDIS-FREE-AUTH.md delete mode 100644 www/app/lib/SessionProvider.tsx delete mode 100644 www/app/lib/useSessionAccessToken.ts delete mode 100644 www/app/lib/useSessionStatus.ts diff --git a/www/REDIS-FREE-AUTH.md b/www/REDIS-FREE-AUTH.md deleted file mode 100644 index b8157bc2..00000000 --- a/www/REDIS-FREE-AUTH.md +++ /dev/null @@ -1,354 +0,0 @@ -# Redis-Free Authentication Solution for Reflector - -## Problem Analysis - -### The Multi-Tab Race Condition - -The current implementation uses Redis to solve a specific problem: - -- NextAuth's `useSession` hook broadcasts `getSession` events across all open tabs -- When a token expires, all tabs simultaneously try to refresh it -- Multiple refresh attempts with the same refresh_token cause 400 errors -- Redis + Redlock ensures only one refresh happens at a time - -### Root Cause - -The issue stems from **client-side broadcasting**, not from NextAuth itself. The `useSession` hook creates a BroadcastChannel that syncs sessions across tabs, triggering the race condition. - -## Solution: Middleware-Based Token Refresh - -Move token refresh from client-side to server-side middleware, eliminating broadcasting and race conditions entirely. - -### Implementation - -#### 1. Enhanced Middleware (`middleware.ts`) - -```typescript -import { withAuth } from "next-auth/middleware"; -import { getToken } from "next-auth/jwt"; -import { encode } from "next-auth/jwt"; -import { NextResponse } from "next/server"; -import { getConfig } from "./app/lib/configProvider"; - -const REFRESH_THRESHOLD = 60 * 1000; // 60 seconds before expiry - -async function refreshAccessToken(token: JWT): Promise { - try { - const response = await fetch(process.env.AUTHENTIK_REFRESH_TOKEN_URL!, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: process.env.AUTHENTIK_CLIENT_ID!, - client_secret: process.env.AUTHENTIK_CLIENT_SECRET!, - grant_type: "refresh_token", - refresh_token: token.refreshToken as string, - }), - }); - - if (!response.ok) throw new Error("Failed to refresh token"); - - const refreshedTokens = await response.json(); - return { - ...token, - accessToken: refreshedTokens.access_token, - accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, - refreshToken: refreshedTokens.refresh_token || token.refreshToken, - }; - } catch (error) { - return { ...token, error: "RefreshAccessTokenError" }; - } -} - -export default withAuth( - async function middleware(request) { - const config = await getConfig(); - const pathname = request.nextUrl.pathname; - - // Feature flag checks (existing) - if ( - (!config.features.browse && pathname.startsWith("/browse")) || - (!config.features.rooms && pathname.startsWith("/rooms")) - ) { - return NextResponse.redirect(request.nextUrl.origin); - } - - // Token refresh logic (new) - const token = await getToken({ req: request }); - - if (token && token.accessTokenExpires) { - const timeUntilExpiry = (token.accessTokenExpires as number) - Date.now(); - - // Refresh if within threshold and not already expired - if (timeUntilExpiry > 0 && timeUntilExpiry < REFRESH_THRESHOLD) { - try { - const refreshedToken = await refreshAccessToken(token); - - if (!refreshedToken.error) { - // Encode new token - const newSessionToken = await encode({ - secret: process.env.NEXTAUTH_SECRET!, - token: refreshedToken, - maxAge: 30 * 24 * 60 * 60, // 30 days - }); - - // Update cookie - const response = NextResponse.next(); - response.cookies.set({ - name: - process.env.NODE_ENV === "production" - ? "__Secure-next-auth.session-token" - : "next-auth.session-token", - value: newSessionToken, - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - }); - - return response; - } - } catch (error) { - console.error("Token refresh in middleware failed:", error); - } - } - } - - return NextResponse.next(); - }, - { - callbacks: { - async authorized({ req, token }) { - const config = await getConfig(); - - if ( - config.features.requireLogin && - PROTECTED_PAGES.test(req.nextUrl.pathname) - ) { - return !!token; - } - - return true; - }, - }, - }, -); -``` - -#### 2. Simplified auth.ts (No Redis) - -```typescript -import { AuthOptions } from "next-auth"; -import AuthentikProvider from "next-auth/providers/authentik"; -import { JWT } from "next-auth/jwt"; -import { JWTWithAccessToken, CustomSession } from "./types"; - -const PRETIMEOUT = 60; // seconds before token expires - -export const authOptions: AuthOptions = { - providers: [ - AuthentikProvider({ - clientId: process.env.AUTHENTIK_CLIENT_ID as string, - clientSecret: process.env.AUTHENTIK_CLIENT_SECRET as string, - issuer: process.env.AUTHENTIK_ISSUER, - authorization: { - params: { - scope: "openid email profile offline_access", - }, - }, - }), - ], - session: { - strategy: "jwt", - maxAge: 30 * 24 * 60 * 60, // 30 days - }, - callbacks: { - async jwt({ token, account, user }) { - // Initial sign in - if (account && user) { - return { - ...token, - accessToken: account.access_token, - accessTokenExpires: (account.expires_at as number) * 1000, - refreshToken: account.refresh_token, // Store in JWT - } as JWTWithAccessToken; - } - - // Return token as-is (refresh happens in middleware) - return token; - }, - - async session({ session, token }) { - const extendedToken = token as JWTWithAccessToken; - const customSession = session as CustomSession; - - customSession.accessToken = extendedToken.accessToken; - customSession.accessTokenExpires = extendedToken.accessTokenExpires; - customSession.error = extendedToken.error; - customSession.user = { - id: extendedToken.sub, - name: extendedToken.name, - email: extendedToken.email, - }; - - return customSession; - }, - }, -}; -``` - -#### 3. Remove Client-Side Auto-Refresh - -**Delete:** `app/lib/SessionAutoRefresh.tsx` - -**Update:** `app/lib/SessionProvider.tsx` - -```typescript -"use client"; -import { SessionProvider as SessionProviderNextAuth } from "next-auth/react"; - -export default function SessionProvider({ children }) { - return ( - - {children} - - ); -} -``` - -### Alternative: Client-Side Deduplication (If Keeping useSession) - -If you need to keep client-side session features, implement request deduplication: - -```typescript -// app/lib/deduplicatedSession.ts -let refreshPromise: Promise | null = null; - -export async function deduplicatedRefresh() { - if (!refreshPromise) { - refreshPromise = fetch("/api/auth/session", { - method: "GET", - }).finally(() => { - refreshPromise = null; - }); - } - return refreshPromise; -} - -// Modified SessionAutoRefresh.tsx -export function SessionAutoRefresh({ children }) { - const { data: session } = useSession(); - - useEffect(() => { - const interval = setInterval(async () => { - if (shouldRefresh(session)) { - await deduplicatedRefresh(); // Use deduplicated call - } - }, 20000); - - return () => clearInterval(interval); - }, [session]); - - return children; -} -``` - -## Benefits of Middleware Approach - -### Advantages - -1. **No Race Conditions**: Each request handled independently server-side -2. **No Redis Required**: Eliminates infrastructure dependency -3. **No Broadcasting**: No multi-tab synchronization issues -4. **Automatic**: Refreshes on navigation, no polling needed -5. **Simpler**: Less client-side complexity -6. **Performance**: No unnecessary API calls from multiple tabs - -### Trade-offs - -1. **Long-lived pages**: Won't refresh without navigation - - Mitigation: Keep minimal client-side refresh for critical pages -2. **Server load**: Each request checks token - - Mitigation: Only checks protected routes -3. **Cookie size**: Refresh token stored in JWT - - Acceptable: ~200-300 bytes increase - -## Migration Path - -### Phase 1: Implement Middleware Refresh - -1. Update middleware.ts with token refresh logic -2. Test with existing Redis-based auth.ts -3. Verify refresh works on navigation - -### Phase 2: Remove Redis - -1. Update auth.ts to store refresh_token in JWT -2. Remove Redis/Redlock imports -3. Test multi-tab scenarios - -### Phase 3: Optimize Client-Side - -1. Remove SessionAutoRefresh if not needed -2. Or implement deduplication for long-lived pages -3. Update documentation - -## Testing Checklist - -- [ ] Single tab: Token refreshes before expiry -- [ ] Multiple tabs: No 400 errors on refresh -- [ ] Long session: 30-day refresh token works -- [ ] Failed refresh: Graceful degradation -- [ ] Protected routes: Still require authentication -- [ ] Feature flags: Still work as expected - -## Configuration - -### Environment Variables - -```bash -# Required (same as before) -AUTHENTIK_CLIENT_ID=xxx -AUTHENTIK_CLIENT_SECRET=xxx -AUTHENTIK_ISSUER=https://auth.example.com/application/o/reflector/ -AUTHENTIK_REFRESH_TOKEN_URL=https://auth.example.com/application/o/token/ -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET=xxx - -# NOT Required anymore -# KV_URL=redis://... (removed) -``` - -### Docker Compose - -```yaml -version: "3.8" - -services: - # No Redis needed! - frontend: - build: . - ports: - - "3000:3000" - environment: - - AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID} - - AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET} - # No KV_URL needed -``` - -## Security Considerations - -1. **Refresh Token in JWT**: Encrypted with A256GCM, secure -2. **Cookie Security**: HttpOnly, Secure, SameSite flags -3. **Token Rotation**: Authentik handles rotation on refresh -4. **Expiry Handling**: Graceful degradation on refresh failure - -## Conclusion - -The middleware-based approach eliminates the multi-tab race condition without Redis by: - -1. Moving refresh logic server-side (no broadcasting) -2. Handling each request independently (no race) -3. Updating cookies transparently (no client involvement) - -This solution is simpler, more maintainable, and aligns with NextAuth's evolution toward server-side session management. diff --git a/www/app/(app)/transcripts/new/page.tsx b/www/app/(app)/transcripts/new/page.tsx index a236c763..e6957da2 100644 --- a/www/app/(app)/transcripts/new/page.tsx +++ b/www/app/(app)/transcripts/new/page.tsx @@ -9,35 +9,24 @@ import { useRouter } from "next/navigation"; import useCreateTranscript from "../createTranscript"; import SelectSearch from "react-select-search"; import { supportedLanguages } from "../../../supportedLanguages"; -import useSessionStatus from "../../../lib/useSessionStatus"; import { featureEnabled } from "../../../domainContext"; -import { signIn } from "next-auth/react"; import { Flex, Box, Spinner, Heading, Button, - Card, Center, - Link, - CardBody, - Stack, Text, - Icon, - Grid, - IconButton, Spacer, - Menu, - Tooltip, - Input, } from "@chakra-ui/react"; +import { useAuth } from "../../../lib/AuthProvider"; const TranscriptCreate = () => { const isClient = typeof window !== "undefined"; const router = useRouter(); - const status = useSessionStatus(); - const isAuthenticated = status === "authenticated"; - const isLoading = status === "loading"; + const auth = useAuth(); + const isAuthenticated = auth.status === "authenticated"; + const isLoading = auth.status === "loading"; const requireLogin = featureEnabled("requireLogin"); const [name, setName] = useState(""); @@ -145,7 +134,7 @@ const TranscriptCreate = () => { {isLoading ? ( ) : requireLogin && !isAuthenticated ? ( - + ) : ( { ); const [audioDeleted, setAudioDeleted] = useState(null); const { api_url } = useContext(DomainContext); - const { data: session } = useSession(); - const sessionExtended = - session === null ? null : assertExtendedToken(session); + const auth = useAuth(); const accessTokenInfo = - sessionExtended === null ? null : sessionExtended.accessToken; + auth.status === "authenticated" ? auth.accessToken : null; const { data: transcript, diff --git a/www/app/(auth)/userInfo.tsx b/www/app/(auth)/userInfo.tsx index 90ba7be9..24b8f15e 100644 --- a/www/app/(auth)/userInfo.tsx +++ b/www/app/(auth)/userInfo.tsx @@ -1,10 +1,11 @@ "use client"; -import { signOut, signIn } from "next-auth/react"; -import useSessionStatus from "../lib/useSessionStatus"; + import { Spinner, Link } from "@chakra-ui/react"; +import { useAuth } from "../lib/AuthProvider"; export default function UserInfo() { - const status = useSessionStatus(); + const auth = useAuth(); + const status = auth.status; const isLoading = status === "loading"; const isAuthenticated = status === "authenticated"; return isLoading ? ( @@ -13,7 +14,7 @@ export default function UserInfo() { signIn("authentik")} + onClick={() => auth.signIn("authentik")} > Log in @@ -21,7 +22,7 @@ export default function UserInfo() { signOut({ callbackUrl: "/" })} + onClick={() => auth.signOut({ callbackUrl: "/" })} > Log out diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx index c4638cbb..0130588b 100644 --- a/www/app/[roomName]/page.tsx +++ b/www/app/[roomName]/page.tsx @@ -21,13 +21,13 @@ import { toaster } from "../components/ui/toaster"; import useRoomMeeting from "./useRoomMeeting"; import { useRouter } from "next/navigation"; import { notFound } from "next/navigation"; -import useSessionStatus from "../lib/useSessionStatus"; import { useRecordingConsent } from "../recordingConsentContext"; import { useMeetingAudioConsent } from "../lib/apiHooks"; import type { components } from "../reflector-api"; type Meeting = components["schemas"]["Meeting"]; import { FaBars } from "react-icons/fa6"; +import { useAuth } from "../lib/AuthProvider"; export type RoomDetails = { params: { @@ -260,7 +260,7 @@ export default function Room(details: RoomDetails) { const roomName = details.params.roomName; const meeting = useRoomMeeting(roomName); const router = useRouter(); - const status = useSessionStatus(); + const status = useAuth().status; const isAuthenticated = status === "authenticated"; const isLoading = status === "loading" || meeting.loading; diff --git a/www/app/layout.tsx b/www/app/layout.tsx index f73b8813..62175be9 100644 --- a/www/app/layout.tsx +++ b/www/app/layout.tsx @@ -1,7 +1,6 @@ import "./styles/globals.scss"; import { Metadata, Viewport } from "next"; import { Poppins } from "next/font/google"; -import SessionProvider from "./lib/SessionProvider"; import { ErrorProvider } from "./(errors)/errorContext"; import ErrorMessage from "./(errors)/errorMessage"; import { DomainContextProvider } from "./domainContext"; @@ -74,18 +73,16 @@ export default async function RootLayout({ return ( - - - - "something went really wrong"

}> - - - {children} - -
-
-
-
+ + + "something went really wrong"

}> + + + {children} + +
+
+
); diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx index 84449c35..ea242653 100644 --- a/www/app/lib/AuthProvider.tsx +++ b/www/app/lib/AuthProvider.tsx @@ -1,15 +1,14 @@ "use client"; -import { createContext, useContext, useEffect } from "react"; +import { createContext, useContext } from "react"; import { useSession as useNextAuthSession } from "next-auth/react"; +import { signOut, signIn } from "next-auth/react"; import { configureApiAuth } from "./apiClient"; -import { - assertExtendedToken, - assertExtendedTokenAndUserId, - CustomSession, -} from "./types"; +import { assertExtendedTokenAndUserId, CustomSession } from "./types"; +import { Session } from "next-auth"; +import { SessionAutoRefresh } from "./SessionAutoRefresh"; -type AuthContextType = +type AuthContextType = ( | { status: "loading" } | { status: "unauthenticated"; error?: string } | { @@ -17,25 +16,41 @@ type AuthContextType = accessToken: string; accessTokenExpires: number; user: CustomSession["user"]; - }; + } +) & { + update: () => Promise; + signIn: typeof signIn; + signOut: typeof signOut; +}; const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: React.ReactNode }) { - const { data: session, status } = useNextAuthSession(); + const { data: session, status, update } = useNextAuthSession(); const customSession = session ? assertExtendedTokenAndUserId(session) : null; - const contextValue: AuthContextType = - status === "loading" - ? { status: "loading" as const } + const contextValue: AuthContextType = { + ...(status === "loading" + ? { status } : status === "authenticated" && customSession?.accessToken ? { - status: "authenticated" as const, + status, accessToken: customSession.accessToken, accessTokenExpires: customSession.accessTokenExpires, user: customSession.user, } - : { status: "unauthenticated" as const }; + : status === "authenticated" && !customSession?.accessToken + ? (() => { + console.warn( + "illegal state: authenticated but have no session/or access token. ignoring", + ); + return { status: "unauthenticated" as const }; + })() + : { status: "unauthenticated" as const }), + update, + signIn, + signOut, + }; // not useEffect, we need it ASAP configureApiAuth( @@ -43,7 +58,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ); return ( - {children} + + {children} + ); } diff --git a/www/app/lib/SessionAutoRefresh.tsx b/www/app/lib/SessionAutoRefresh.tsx index 127510b2..9f4e2658 100644 --- a/www/app/lib/SessionAutoRefresh.tsx +++ b/www/app/lib/SessionAutoRefresh.tsx @@ -1,5 +1,5 @@ /** - * This is a custom hook that automatically refreshes the session when the access token is about to expire. + * This is a custom provider that automatically refreshes the session when the access token is about to expire. * When communicating with the reflector API, we need to ensure that the access token is always valid. * * We could have implemented that as an interceptor on the API client, but not everything is using the @@ -7,31 +7,29 @@ */ "use client"; -import { useSession } from "next-auth/react"; import { useEffect } from "react"; -import { assertExtendedToken, CustomSession } from "./types"; +import { useAuth } from "./AuthProvider"; export function SessionAutoRefresh({ children, refreshInterval = 20 /* seconds */, }) { - const { data: session, update } = useSession(); - const accessTokenExpires = session - ? assertExtendedToken(session).accessTokenExpires - : null; + const auth = useAuth(); + const accessTokenExpires = + auth.status === "authenticated" ? auth.accessTokenExpires : null; useEffect(() => { const interval = setInterval(() => { if (accessTokenExpires) { const timeLeft = accessTokenExpires - Date.now(); if (timeLeft < refreshInterval * 1000) { - update(); + auth.update(); } } }, refreshInterval * 1000); return () => clearInterval(interval); - }, [accessTokenExpires, refreshInterval, update]); + }, [accessTokenExpires, refreshInterval, auth.update]); return children; } diff --git a/www/app/lib/SessionProvider.tsx b/www/app/lib/SessionProvider.tsx deleted file mode 100644 index 9c95fbc8..00000000 --- a/www/app/lib/SessionProvider.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; -import { SessionProvider as SessionProviderNextAuth } from "next-auth/react"; -import { SessionAutoRefresh } from "./SessionAutoRefresh"; - -export default function SessionProvider({ children }) { - return ( - - {children} - - ); -} diff --git a/www/app/lib/auth.ts b/www/app/lib/auth.ts index fea903aa..51a7e909 100644 --- a/www/app/lib/auth.ts +++ b/www/app/lib/auth.ts @@ -1,11 +1,7 @@ import { AuthOptions } from "next-auth"; import AuthentikProvider from "next-auth/providers/authentik"; -import { JWT } from "next-auth/jwt"; -import { - JWTWithAccessToken, - CustomSession, - assertExtendedToken, -} from "./types"; +import type { JWT } from "next-auth/jwt"; +import { JWTWithAccessToken, CustomSession } from "./types"; import { assertExists, assertExistsAndNonEmptyString, diff --git a/www/app/lib/types.ts b/www/app/lib/types.ts index d9b48a41..04f1d740 100644 --- a/www/app/lib/types.ts +++ b/www/app/lib/types.ts @@ -1,5 +1,5 @@ -import { Session } from "next-auth"; -import { JWT } from "next-auth/jwt"; +import type { Session } from "next-auth"; +import type { JWT } from "next-auth/jwt"; import { parseMaybeNonEmptyString } from "./utils"; export interface JWTWithAccessToken extends JWT { diff --git a/www/app/lib/useSessionAccessToken.ts b/www/app/lib/useSessionAccessToken.ts deleted file mode 100644 index a40d965a..00000000 --- a/www/app/lib/useSessionAccessToken.ts +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import { useSession as useNextAuthSession } from "next-auth/react"; -import { CustomSession } from "./types"; - -export default function useSessionAccessToken() { - const { data: session } = useNextAuthSession(); - const customSession = session as CustomSession; - - return { - accessToken: customSession?.accessToken ?? null, - accessTokenExpires: customSession?.accessTokenExpires ?? null, - error: customSession?.error, - }; -} diff --git a/www/app/lib/useSessionStatus.ts b/www/app/lib/useSessionStatus.ts deleted file mode 100644 index 62f02023..00000000 --- a/www/app/lib/useSessionStatus.ts +++ /dev/null @@ -1,8 +0,0 @@ -"use client"; - -import { useSession as useNextAuthSession } from "next-auth/react"; - -export default function useSessionStatus() { - const { status } = useNextAuthSession(); - return status; -} diff --git a/www/app/providers.tsx b/www/app/providers.tsx index 090ad161..2e3b78eb 100644 --- a/www/app/providers.tsx +++ b/www/app/providers.tsx @@ -9,19 +9,22 @@ import { NuqsAdapter } from "nuqs/adapters/next/app"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "./lib/queryClient"; import { AuthProvider } from "./lib/AuthProvider"; +import { SessionProvider as SessionProviderNextAuth } from "next-auth/react"; export function Providers({ children }: { children: React.ReactNode }) { return ( - - - - {children} - - - - + + + + + {children} + + + + + );