mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 12:19:06 +00:00
- Replace @hey-api/openapi-ts with openapi-typescript and openapi-react-query - Generate TypeScript types from OpenAPI spec - Set up React Query infrastructure with QueryClientProvider - Migrate all API hooks to use React Query patterns - Maintain backward compatibility for existing components - Remove old API infrastructure and dependencies
9.9 KiB
9.9 KiB
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
useSessionhook broadcastsgetSessionevents 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)
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<JWT> {
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)
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
"use client";
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
export default function SessionProvider({ children }) {
return (
<SessionProviderNextAuth>
{children}
</SessionProviderNextAuth>
);
}
Alternative: Client-Side Deduplication (If Keeping useSession)
If you need to keep client-side session features, implement request deduplication:
// app/lib/deduplicatedSession.ts
let refreshPromise: Promise<any> | 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
- No Race Conditions: Each request handled independently server-side
- No Redis Required: Eliminates infrastructure dependency
- No Broadcasting: No multi-tab synchronization issues
- Automatic: Refreshes on navigation, no polling needed
- Simpler: Less client-side complexity
- Performance: No unnecessary API calls from multiple tabs
Trade-offs
- Long-lived pages: Won't refresh without navigation
- Mitigation: Keep minimal client-side refresh for critical pages
- Server load: Each request checks token
- Mitigation: Only checks protected routes
- Cookie size: Refresh token stored in JWT
- Acceptable: ~200-300 bytes increase
Migration Path
Phase 1: Implement Middleware Refresh
- Update middleware.ts with token refresh logic
- Test with existing Redis-based auth.ts
- Verify refresh works on navigation
Phase 2: Remove Redis
- Update auth.ts to store refresh_token in JWT
- Remove Redis/Redlock imports
- Test multi-tab scenarios
Phase 3: Optimize Client-Side
- Remove SessionAutoRefresh if not needed
- Or implement deduplication for long-lived pages
- 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
# 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
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
- Refresh Token in JWT: Encrypted with A256GCM, secure
- Cookie Security: HttpOnly, Secure, SameSite flags
- Token Rotation: Authentik handles rotation on refresh
- Expiry Handling: Graceful degradation on refresh failure
Conclusion
The middleware-based approach eliminates the multi-tab race condition without Redis by:
- Moving refresh logic server-side (no broadcasting)
- Handling each request independently (no race)
- Updating cookies transparently (no client involvement)
This solution is simpler, more maintainable, and aligns with NextAuth's evolution toward server-side session management.