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}
+
+
+
+
+
);