mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
normalize auth provider
This commit is contained in:
@@ -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<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)
|
|
||||||
|
|
||||||
```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 (
|
|
||||||
<SessionProviderNextAuth>
|
|
||||||
{children}
|
|
||||||
</SessionProviderNextAuth>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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<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
|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -9,35 +9,24 @@ import { useRouter } from "next/navigation";
|
|||||||
import useCreateTranscript from "../createTranscript";
|
import useCreateTranscript from "../createTranscript";
|
||||||
import SelectSearch from "react-select-search";
|
import SelectSearch from "react-select-search";
|
||||||
import { supportedLanguages } from "../../../supportedLanguages";
|
import { supportedLanguages } from "../../../supportedLanguages";
|
||||||
import useSessionStatus from "../../../lib/useSessionStatus";
|
|
||||||
import { featureEnabled } from "../../../domainContext";
|
import { featureEnabled } from "../../../domainContext";
|
||||||
import { signIn } from "next-auth/react";
|
|
||||||
import {
|
import {
|
||||||
Flex,
|
Flex,
|
||||||
Box,
|
Box,
|
||||||
Spinner,
|
Spinner,
|
||||||
Heading,
|
Heading,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
Center,
|
Center,
|
||||||
Link,
|
|
||||||
CardBody,
|
|
||||||
Stack,
|
|
||||||
Text,
|
Text,
|
||||||
Icon,
|
|
||||||
Grid,
|
|
||||||
IconButton,
|
|
||||||
Spacer,
|
Spacer,
|
||||||
Menu,
|
|
||||||
Tooltip,
|
|
||||||
Input,
|
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
|
import { useAuth } from "../../../lib/AuthProvider";
|
||||||
const TranscriptCreate = () => {
|
const TranscriptCreate = () => {
|
||||||
const isClient = typeof window !== "undefined";
|
const isClient = typeof window !== "undefined";
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const status = useSessionStatus();
|
const auth = useAuth();
|
||||||
const isAuthenticated = status === "authenticated";
|
const isAuthenticated = auth.status === "authenticated";
|
||||||
const isLoading = status === "loading";
|
const isLoading = auth.status === "loading";
|
||||||
const requireLogin = featureEnabled("requireLogin");
|
const requireLogin = featureEnabled("requireLogin");
|
||||||
|
|
||||||
const [name, setName] = useState<string>("");
|
const [name, setName] = useState<string>("");
|
||||||
@@ -145,7 +134,7 @@ const TranscriptCreate = () => {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : requireLogin && !isAuthenticated ? (
|
) : requireLogin && !isAuthenticated ? (
|
||||||
<Button onClick={() => signIn("authentik")}>Log in</Button>
|
<Button onClick={() => auth.signIn("authentik")}>Log in</Button>
|
||||||
) : (
|
) : (
|
||||||
<Flex
|
<Flex
|
||||||
rounded="xl"
|
rounded="xl"
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { DomainContext } from "../../domainContext";
|
import { DomainContext } from "../../domainContext";
|
||||||
import { useTranscriptGet } from "../../lib/apiHooks";
|
import { useTranscriptGet } from "../../lib/apiHooks";
|
||||||
import { useSession } from "next-auth/react";
|
import { useAuth } from "../../lib/AuthProvider";
|
||||||
import { assertExtendedToken } from "../../lib/types";
|
|
||||||
|
|
||||||
export type Mp3Response = {
|
export type Mp3Response = {
|
||||||
media: HTMLMediaElement | null;
|
media: HTMLMediaElement | null;
|
||||||
@@ -21,11 +20,9 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
|||||||
);
|
);
|
||||||
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
|
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
|
||||||
const { api_url } = useContext(DomainContext);
|
const { api_url } = useContext(DomainContext);
|
||||||
const { data: session } = useSession();
|
const auth = useAuth();
|
||||||
const sessionExtended =
|
|
||||||
session === null ? null : assertExtendedToken(session);
|
|
||||||
const accessTokenInfo =
|
const accessTokenInfo =
|
||||||
sessionExtended === null ? null : sessionExtended.accessToken;
|
auth.status === "authenticated" ? auth.accessToken : null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: transcript,
|
data: transcript,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { signOut, signIn } from "next-auth/react";
|
|
||||||
import useSessionStatus from "../lib/useSessionStatus";
|
|
||||||
import { Spinner, Link } from "@chakra-ui/react";
|
import { Spinner, Link } from "@chakra-ui/react";
|
||||||
|
import { useAuth } from "../lib/AuthProvider";
|
||||||
|
|
||||||
export default function UserInfo() {
|
export default function UserInfo() {
|
||||||
const status = useSessionStatus();
|
const auth = useAuth();
|
||||||
|
const status = auth.status;
|
||||||
const isLoading = status === "loading";
|
const isLoading = status === "loading";
|
||||||
const isAuthenticated = status === "authenticated";
|
const isAuthenticated = status === "authenticated";
|
||||||
return isLoading ? (
|
return isLoading ? (
|
||||||
@@ -13,7 +14,7 @@ export default function UserInfo() {
|
|||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="font-light px-2"
|
className="font-light px-2"
|
||||||
onClick={() => signIn("authentik")}
|
onClick={() => auth.signIn("authentik")}
|
||||||
>
|
>
|
||||||
Log in
|
Log in
|
||||||
</Link>
|
</Link>
|
||||||
@@ -21,7 +22,7 @@ export default function UserInfo() {
|
|||||||
<Link
|
<Link
|
||||||
href="#"
|
href="#"
|
||||||
className="font-light px-2"
|
className="font-light px-2"
|
||||||
onClick={() => signOut({ callbackUrl: "/" })}
|
onClick={() => auth.signOut({ callbackUrl: "/" })}
|
||||||
>
|
>
|
||||||
Log out
|
Log out
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ import { toaster } from "../components/ui/toaster";
|
|||||||
import useRoomMeeting from "./useRoomMeeting";
|
import useRoomMeeting from "./useRoomMeeting";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import useSessionStatus from "../lib/useSessionStatus";
|
|
||||||
import { useRecordingConsent } from "../recordingConsentContext";
|
import { useRecordingConsent } from "../recordingConsentContext";
|
||||||
import { useMeetingAudioConsent } from "../lib/apiHooks";
|
import { useMeetingAudioConsent } from "../lib/apiHooks";
|
||||||
import type { components } from "../reflector-api";
|
import type { components } from "../reflector-api";
|
||||||
|
|
||||||
type Meeting = components["schemas"]["Meeting"];
|
type Meeting = components["schemas"]["Meeting"];
|
||||||
import { FaBars } from "react-icons/fa6";
|
import { FaBars } from "react-icons/fa6";
|
||||||
|
import { useAuth } from "../lib/AuthProvider";
|
||||||
|
|
||||||
export type RoomDetails = {
|
export type RoomDetails = {
|
||||||
params: {
|
params: {
|
||||||
@@ -260,7 +260,7 @@ export default function Room(details: RoomDetails) {
|
|||||||
const roomName = details.params.roomName;
|
const roomName = details.params.roomName;
|
||||||
const meeting = useRoomMeeting(roomName);
|
const meeting = useRoomMeeting(roomName);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const status = useSessionStatus();
|
const status = useAuth().status;
|
||||||
const isAuthenticated = status === "authenticated";
|
const isAuthenticated = status === "authenticated";
|
||||||
const isLoading = status === "loading" || meeting.loading;
|
const isLoading = status === "loading" || meeting.loading;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import "./styles/globals.scss";
|
import "./styles/globals.scss";
|
||||||
import { Metadata, Viewport } from "next";
|
import { Metadata, Viewport } from "next";
|
||||||
import { Poppins } from "next/font/google";
|
import { Poppins } from "next/font/google";
|
||||||
import SessionProvider from "./lib/SessionProvider";
|
|
||||||
import { ErrorProvider } from "./(errors)/errorContext";
|
import { ErrorProvider } from "./(errors)/errorContext";
|
||||||
import ErrorMessage from "./(errors)/errorMessage";
|
import ErrorMessage from "./(errors)/errorMessage";
|
||||||
import { DomainContextProvider } from "./domainContext";
|
import { DomainContextProvider } from "./domainContext";
|
||||||
@@ -74,18 +73,16 @@ export default async function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className={poppins.className} suppressHydrationWarning>
|
<html lang="en" className={poppins.className} suppressHydrationWarning>
|
||||||
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
|
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
|
||||||
<SessionProvider>
|
<DomainContextProvider config={config}>
|
||||||
<DomainContextProvider config={config}>
|
<RecordingConsentProvider>
|
||||||
<RecordingConsentProvider>
|
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
|
||||||
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
|
<ErrorProvider>
|
||||||
<ErrorProvider>
|
<ErrorMessage />
|
||||||
<ErrorMessage />
|
<Providers>{children}</Providers>
|
||||||
<Providers>{children}</Providers>
|
</ErrorProvider>
|
||||||
</ErrorProvider>
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</RecordingConsentProvider>
|
||||||
</RecordingConsentProvider>
|
</DomainContextProvider>
|
||||||
</DomainContextProvider>
|
|
||||||
</SessionProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useEffect } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import { useSession as useNextAuthSession } from "next-auth/react";
|
import { useSession as useNextAuthSession } from "next-auth/react";
|
||||||
|
import { signOut, signIn } from "next-auth/react";
|
||||||
import { configureApiAuth } from "./apiClient";
|
import { configureApiAuth } from "./apiClient";
|
||||||
import {
|
import { assertExtendedTokenAndUserId, CustomSession } from "./types";
|
||||||
assertExtendedToken,
|
import { Session } from "next-auth";
|
||||||
assertExtendedTokenAndUserId,
|
import { SessionAutoRefresh } from "./SessionAutoRefresh";
|
||||||
CustomSession,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
type AuthContextType =
|
type AuthContextType = (
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "unauthenticated"; error?: string }
|
| { status: "unauthenticated"; error?: string }
|
||||||
| {
|
| {
|
||||||
@@ -17,25 +16,41 @@ type AuthContextType =
|
|||||||
accessToken: string;
|
accessToken: string;
|
||||||
accessTokenExpires: number;
|
accessTokenExpires: number;
|
||||||
user: CustomSession["user"];
|
user: CustomSession["user"];
|
||||||
};
|
}
|
||||||
|
) & {
|
||||||
|
update: () => Promise<Session | null>;
|
||||||
|
signIn: typeof signIn;
|
||||||
|
signOut: typeof signOut;
|
||||||
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
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 customSession = session ? assertExtendedTokenAndUserId(session) : null;
|
||||||
|
|
||||||
const contextValue: AuthContextType =
|
const contextValue: AuthContextType = {
|
||||||
status === "loading"
|
...(status === "loading"
|
||||||
? { status: "loading" as const }
|
? { status }
|
||||||
: status === "authenticated" && customSession?.accessToken
|
: status === "authenticated" && customSession?.accessToken
|
||||||
? {
|
? {
|
||||||
status: "authenticated" as const,
|
status,
|
||||||
accessToken: customSession.accessToken,
|
accessToken: customSession.accessToken,
|
||||||
accessTokenExpires: customSession.accessTokenExpires,
|
accessTokenExpires: customSession.accessTokenExpires,
|
||||||
user: customSession.user,
|
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
|
// not useEffect, we need it ASAP
|
||||||
configureApiAuth(
|
configureApiAuth(
|
||||||
@@ -43,7 +58,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
|
<AuthContext.Provider value={contextValue}>
|
||||||
|
<SessionAutoRefresh>{children}</SessionAutoRefresh>
|
||||||
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* 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
|
* We could have implemented that as an interceptor on the API client, but not everything is using the
|
||||||
@@ -7,31 +7,29 @@
|
|||||||
*/
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { assertExtendedToken, CustomSession } from "./types";
|
import { useAuth } from "./AuthProvider";
|
||||||
|
|
||||||
export function SessionAutoRefresh({
|
export function SessionAutoRefresh({
|
||||||
children,
|
children,
|
||||||
refreshInterval = 20 /* seconds */,
|
refreshInterval = 20 /* seconds */,
|
||||||
}) {
|
}) {
|
||||||
const { data: session, update } = useSession();
|
const auth = useAuth();
|
||||||
const accessTokenExpires = session
|
const accessTokenExpires =
|
||||||
? assertExtendedToken(session).accessTokenExpires
|
auth.status === "authenticated" ? auth.accessTokenExpires : null;
|
||||||
: null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (accessTokenExpires) {
|
if (accessTokenExpires) {
|
||||||
const timeLeft = accessTokenExpires - Date.now();
|
const timeLeft = accessTokenExpires - Date.now();
|
||||||
if (timeLeft < refreshInterval * 1000) {
|
if (timeLeft < refreshInterval * 1000) {
|
||||||
update();
|
auth.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, refreshInterval * 1000);
|
}, refreshInterval * 1000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [accessTokenExpires, refreshInterval, update]);
|
}, [accessTokenExpires, refreshInterval, auth.update]);
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
|
|
||||||
import { SessionAutoRefresh } from "./SessionAutoRefresh";
|
|
||||||
|
|
||||||
export default function SessionProvider({ children }) {
|
|
||||||
return (
|
|
||||||
<SessionProviderNextAuth>
|
|
||||||
<SessionAutoRefresh>{children}</SessionAutoRefresh>
|
|
||||||
</SessionProviderNextAuth>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { AuthOptions } from "next-auth";
|
import { AuthOptions } from "next-auth";
|
||||||
import AuthentikProvider from "next-auth/providers/authentik";
|
import AuthentikProvider from "next-auth/providers/authentik";
|
||||||
import { JWT } from "next-auth/jwt";
|
import type { JWT } from "next-auth/jwt";
|
||||||
import {
|
import { JWTWithAccessToken, CustomSession } from "./types";
|
||||||
JWTWithAccessToken,
|
|
||||||
CustomSession,
|
|
||||||
assertExtendedToken,
|
|
||||||
} from "./types";
|
|
||||||
import {
|
import {
|
||||||
assertExists,
|
assertExists,
|
||||||
assertExistsAndNonEmptyString,
|
assertExistsAndNonEmptyString,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { JWT } from "next-auth/jwt";
|
import type { JWT } from "next-auth/jwt";
|
||||||
import { parseMaybeNonEmptyString } from "./utils";
|
import { parseMaybeNonEmptyString } from "./utils";
|
||||||
|
|
||||||
export interface JWTWithAccessToken extends JWT {
|
export interface JWTWithAccessToken extends JWT {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useSession as useNextAuthSession } from "next-auth/react";
|
|
||||||
|
|
||||||
export default function useSessionStatus() {
|
|
||||||
const { status } = useNextAuthSession();
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
@@ -9,19 +9,22 @@ import { NuqsAdapter } from "nuqs/adapters/next/app";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
import { AuthProvider } from "./lib/AuthProvider";
|
import { AuthProvider } from "./lib/AuthProvider";
|
||||||
|
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<NuqsAdapter>
|
<NuqsAdapter>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<SessionProviderNextAuth>
|
||||||
<ChakraProvider value={system}>
|
<AuthProvider>
|
||||||
<WherebyProvider>
|
<ChakraProvider value={system}>
|
||||||
{children}
|
<WherebyProvider>
|
||||||
<Toaster />
|
{children}
|
||||||
</WherebyProvider>
|
<Toaster />
|
||||||
</ChakraProvider>
|
</WherebyProvider>
|
||||||
</AuthProvider>
|
</ChakraProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</SessionProviderNextAuth>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</NuqsAdapter>
|
</NuqsAdapter>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user