mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
feat: frontend openapi react query (#606)
* refactor: migrate from @hey-api/openapi-ts to openapi-react-query
- 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
* fix: resolve import errors and add missing api hooks
- Create constants.ts for RECORD_A_MEETING_URL
- Add api-types.ts for backward compatible type exports
- Update all imports from deleted api folder to new locations
- Add missing React Query hooks for rooms and zulip operations
- Create useApi compatibility layer for unmigrated components
* feat: migrate components to React Query hooks
- Add comprehensive API hooks for all operations
- Migrate rooms page to use React Query mutations
- Update transcript title component to use mutation hook
- Refactor share/privacy component with proper error handling
- Remove direct API client usage in favor of hooks
* feat: complete migration from @hey-api/openapi-ts to openapi-react-query
- Migrated all components from useApi compatibility layer to direct React Query hooks
- Added new hooks for participant operations, room meetings, and speaker operations
- Updated all imports from old api module to api-types
- Fixed TypeScript types and API endpoint signatures
- Removed deprecated useApi.ts compatibility layer
- Fixed SourceKind enum values to match OpenAPI spec
- Added @ts-ignore for Zulip endpoints not in OpenAPI spec yet
- Fixed all compilation errors and type issues
* fix: authentication flow with React Query migration
- Fix middleware management in apiClient to properly handle auth tokens
- Update ApiAuthProvider to correctly configure base URL and auth
- Add missing NextAuth API route handler at app/api/auth/[...nextauth]/route.ts
- Remove middleware ejection attempts (not supported by openapi-fetch)
- Use global variables to store current auth token and API URL
- Setup middleware once on initialization instead of repeatedly adding
This fixes the login/logout flow that was broken after migrating from
the useApi compatibility layer to native React Query hooks.
* 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.
* refactor: remove redundant client-side AuthGuard
The authentication is already properly handled by Next.js middleware
in middleware.ts with LOGIN_REQUIRED_PAGES. The middleware approach is
superior as it:
- Provides server-side protection before page loads
- Prevents flash of unauthorized content
- Centralizes auth logic in one place
- Better performance (no client-side JS needed)
Keep the API hooks conditional to prevent 401 errors before token is ready.
* fix: use direct status check for API query authentication
Changed all query hooks to use direct `status === "authenticated"` check
instead of derived `isAuthenticated && !isLoading` to avoid race conditions
where queries might fire before the authentication token is properly set.
This prevents the brief 401 errors that occur on page refresh when the
session is being restored.
* fix: correct content-type header for FormData uploads
Previously, the API client was setting a default Content-Type of application/json
for all requests, which broke file uploads that need multipart/form-data.
Now the client only sets application/json when the body is not FormData,
allowing FormData to automatically set the correct multipart boundary.
* fix: resolve authentication race condition with React Query
Previously, API calls were being made before the auth token was configured,
causing initial 401 errors that would retry with 200 after token setup.
Changes:
- Add global auth readiness tracking in apiClient
- Create useAuthReady hook that checks both session and token state
- Update all API hooks to use isAuthReady instead of just session status
- Add AuthWrapper component at layout level for consistent loading UX
- Show spinner while authentication initializes across all pages
This ensures API calls only fire after authentication is fully configured,
eliminating the 401/retry pattern and improving user experience.
* refactor: clean up api-hooks.ts comments and improve search invalidation
- Remove redundant function category comments (exports are self-explanatory)
- Remove obvious inline comments for query invalidation
- Fix search endpoint invalidation to clear all queries regardless of parameters
* refactor: remove api-types.ts compatibility layer
- Migrated all 29 files from api-types.ts to use reflector-api.d.ts directly
- Removed $SourceKind manual enum in favor of OpenAPI-generated types
- Fixed unrelated Spinner component TypeScript error in AuthWrapper.tsx
- All imports now use: import type { components } from "path/to/reflector-api"
- Deleted api-types.ts file completely
* refactor: rename api-hooks.ts to apiHooks.ts for consistency
- Renamed api-hooks.ts to apiHooks.ts to follow camelCase convention
- Updated all 21 import statements across the codebase
- Maintains consistency with other non-component files (apiClient.tsx, useAuthReady.ts, etc.)
- Follows established naming pattern: PascalCase for components, camelCase for utilities/hooks
* chore: add .playwright-mcp to .gitignore
* refactor: remove SK helper object and use inline type casting in FilterSidebar
Replace the SK (SourceKind) helper object with direct inline type casting
to simplify the code and reduce unnecessary abstraction.
* chore: clean up migration comments from React Query refactoring
- Remove temporary "// Use new React Query hooks" comments
- Remove "// React Query hooks" comments from browse and rooms pages
- Update package.json script name from codegen to openapi for consistency
* refactor: remove Redis dependencies from frontend authentication
- Replace Redis/Redlock with in-memory cache for token management
- Remove @vercel/kv, ioredis, and redlock dependencies from package.json
- Implement simple lock mechanism for concurrent token refresh prevention
- Use Map-based cache with TTL for token storage
- Maintain same authentication flow without external dependencies
This simplifies the infrastructure requirements and removes the need for
Redis while maintaining the same functionality through in-memory caching.
* fix: add staleTime to prevent cross-tab staled data
* fix: remove infinite re-render loop in useSessionAccessToken
The hook was maintaining redundant local state that caused re-renders
on every update, which triggered NextAuth to continuously refetch the
session, resulting in hundreds of POST requests to /api/auth/session.
Simplified the hook to directly return session values without
unnecessary state duplication.
* fix: handle undefined access tokens in auth.ts
Added fallback to empty string for potentially undefined access_token
and refresh_token from NextAuth account object to satisfy
JWTWithAccessToken type requirements.
* Igor/mathieu/frontend openapi react query (#597)
* small typing
* typing fixes
---------
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
* self-review-fix
* authReady callback simplify
* fix auth
* fix compose
* room detail page fix
* compile fix
* room edit fix
* normalize auth provider
* room edition state granular management
* cover TODOs + cross-tab cache
* session auto refresh blink
* schema generator error type doc
* protect from zombie auth
* clarify access token refresh logic a bit
* remove react-query tab sharing cache
* remove react-query tab sharing cache
* websocket dupe react devmode protection
* invalidate room on room update
* redis cache
* test ts server
* ci randomness
* less edgy config (ci)
* less edgy config (ci)
* less edgy config (ci)
* ci randomness
* ci randomness
* ci randomness
* ci randomness
* less edgy config (ci)
* added vs edited room state cleanup
* file upload real-time state management fix
* prettier auth state ternary
* prettier auth state ternary
* proper api address from env
* INTERVAL_REFRESH_MS
* node version 20 for tests
* github debug
* github debug
* github debug
* github debug
* github debug
* github debug
* github debug
* github debug
* github debug
* github debug
* github debug
* CI debug
* CI debug
* nextjs magic
* nextjs magic
* doc
* client-side stale auth soft safety net
---------
Co-authored-by: Mathieu Virbel <mat@meltingrocks.com>
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
This commit is contained in:
104
www/app/lib/AuthProvider.tsx
Normal file
104
www/app/lib/AuthProvider.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
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 { assertCustomSession, CustomSession } from "./types";
|
||||
import { Session } from "next-auth";
|
||||
import { SessionAutoRefresh } from "./SessionAutoRefresh";
|
||||
import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth";
|
||||
|
||||
type AuthContextType = (
|
||||
| { status: "loading" }
|
||||
| { status: "refreshing" }
|
||||
| { status: "unauthenticated"; error?: string }
|
||||
| {
|
||||
status: "authenticated";
|
||||
accessToken: string;
|
||||
accessTokenExpires: number;
|
||||
user: CustomSession["user"];
|
||||
}
|
||||
) & {
|
||||
update: () => Promise<Session | null>;
|
||||
signIn: typeof signIn;
|
||||
signOut: typeof signOut;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status, update } = useNextAuthSession();
|
||||
const customSession = session ? assertCustomSession(session) : null;
|
||||
|
||||
const contextValue: AuthContextType = {
|
||||
...(() => {
|
||||
switch (status) {
|
||||
case "loading": {
|
||||
const sessionIsHere = !!customSession;
|
||||
switch (sessionIsHere) {
|
||||
case false: {
|
||||
return { status };
|
||||
}
|
||||
case true: {
|
||||
return { status: "refreshing" as const };
|
||||
}
|
||||
default: {
|
||||
const _: never = sessionIsHere;
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
case "authenticated": {
|
||||
if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) {
|
||||
// token had expired but next auth still returns "authenticated" so show user unauthenticated state
|
||||
return {
|
||||
status: "unauthenticated" as const,
|
||||
};
|
||||
} else if (customSession?.accessToken) {
|
||||
return {
|
||||
status,
|
||||
accessToken: customSession.accessToken,
|
||||
accessTokenExpires: customSession.accessTokenExpires,
|
||||
user: customSession.user,
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
"illegal state: authenticated but have no session/or access token. ignoring",
|
||||
);
|
||||
return { status: "unauthenticated" as const };
|
||||
}
|
||||
}
|
||||
case "unauthenticated": {
|
||||
return { status: "unauthenticated" as const };
|
||||
}
|
||||
default: {
|
||||
const _: never = status;
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
})(),
|
||||
update,
|
||||
signIn,
|
||||
signOut,
|
||||
};
|
||||
|
||||
// not useEffect, we need it ASAP
|
||||
configureApiAuth(
|
||||
contextValue.status === "authenticated" ? contextValue.accessToken : null,
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
<SessionAutoRefresh>{children}</SessionAutoRefresh>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -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,30 +7,38 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect } from "react";
|
||||
import { CustomSession } from "./types";
|
||||
import { useAuth } from "./AuthProvider";
|
||||
import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth";
|
||||
|
||||
export function SessionAutoRefresh({
|
||||
children,
|
||||
refreshInterval = 20 /* seconds */,
|
||||
}) {
|
||||
const { data: session, update } = useSession();
|
||||
const customSession = session as CustomSession;
|
||||
const accessTokenExpires = customSession?.accessTokenExpires;
|
||||
const REFRESH_BEFORE = REFRESH_ACCESS_TOKEN_BEFORE;
|
||||
|
||||
export function SessionAutoRefresh({ children }) {
|
||||
const auth = useAuth();
|
||||
const accessTokenExpires =
|
||||
auth.status === "authenticated" ? auth.accessTokenExpires : null;
|
||||
|
||||
useEffect(() => {
|
||||
// technical value for how often the setInterval will be polling news - not too fast (no spam in case of errors)
|
||||
// and not too slow (debuggable)
|
||||
const INTERVAL_REFRESH_MS = 5000;
|
||||
const interval = setInterval(() => {
|
||||
if (accessTokenExpires) {
|
||||
if (accessTokenExpires !== null) {
|
||||
const timeLeft = accessTokenExpires - Date.now();
|
||||
if (timeLeft < refreshInterval * 1000) {
|
||||
update();
|
||||
if (timeLeft < REFRESH_BEFORE) {
|
||||
auth
|
||||
.update()
|
||||
.then(() => {})
|
||||
.catch((e) => {
|
||||
// note: 401 won't be considered error here
|
||||
console.error("error refreshing auth token", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, refreshInterval * 1000);
|
||||
}, INTERVAL_REFRESH_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [accessTokenExpires, refreshInterval, update]);
|
||||
}, [accessTokenExpires, auth.update]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
85
www/app/lib/__tests__/redisTokenCache.test.ts
Normal file
85
www/app/lib/__tests__/redisTokenCache.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
getTokenCache,
|
||||
setTokenCache,
|
||||
deleteTokenCache,
|
||||
TokenCacheEntry,
|
||||
KV,
|
||||
} from "../redisTokenCache";
|
||||
|
||||
const mockKV: KV & {
|
||||
clear: () => void;
|
||||
} = (() => {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
async get(key: string): Promise<string | null> {
|
||||
return data.get(key) || null;
|
||||
},
|
||||
|
||||
async setex(key: string, seconds_: number, value: string): Promise<"OK"> {
|
||||
data.set(key, value);
|
||||
return "OK";
|
||||
},
|
||||
|
||||
async del(key: string): Promise<number> {
|
||||
const existed = data.has(key);
|
||||
data.delete(key);
|
||||
return existed ? 1 : 0;
|
||||
},
|
||||
|
||||
clear() {
|
||||
data.clear();
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
describe("Redis Token Cache", () => {
|
||||
beforeEach(() => {
|
||||
mockKV.clear();
|
||||
});
|
||||
|
||||
test("basic write/read - value written equals value read", async () => {
|
||||
const testKey = "token:test-user-123";
|
||||
const testValue: TokenCacheEntry = {
|
||||
token: {
|
||||
sub: "test-user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
accessToken: "access-token-123",
|
||||
accessTokenExpires: Date.now() + 3600000, // 1 hour from now
|
||||
refreshToken: "refresh-token-456",
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await setTokenCache(mockKV, testKey, testValue);
|
||||
const retrievedValue = await getTokenCache(mockKV, testKey);
|
||||
|
||||
expect(retrievedValue).not.toBeNull();
|
||||
expect(retrievedValue).toEqual(testValue);
|
||||
expect(retrievedValue?.token.accessToken).toBe(testValue.token.accessToken);
|
||||
expect(retrievedValue?.token.sub).toBe(testValue.token.sub);
|
||||
expect(retrievedValue?.timestamp).toBe(testValue.timestamp);
|
||||
});
|
||||
|
||||
test("get returns null for non-existent key", async () => {
|
||||
const result = await getTokenCache(mockKV, "non-existent-key");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("delete removes token from cache", async () => {
|
||||
const testKey = "token:delete-test";
|
||||
const testValue: TokenCacheEntry = {
|
||||
token: {
|
||||
accessToken: "test-token",
|
||||
accessTokenExpires: Date.now() + 3600000,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await setTokenCache(mockKV, testKey, testValue);
|
||||
await deleteTokenCache(mockKV, testKey);
|
||||
|
||||
const result = await getTokenCache(mockKV, testKey);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
50
www/app/lib/apiClient.tsx
Normal file
50
www/app/lib/apiClient.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "../reflector-api";
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useSuspenseQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import createFetchClient from "openapi-react-query";
|
||||
import { assertExistsAndNonEmptyString } from "./utils";
|
||||
import { isBuildPhase } from "./next";
|
||||
|
||||
const API_URL = !isBuildPhase
|
||||
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
|
||||
: "http://localhost";
|
||||
|
||||
// Create the base openapi-fetch client with a default URL
|
||||
// The actual URL will be set via middleware in AuthProvider
|
||||
export const client = createClient<paths>({
|
||||
baseUrl: API_URL,
|
||||
});
|
||||
|
||||
export const $api = createFetchClient<paths>(client);
|
||||
|
||||
let currentAuthToken: string | null | undefined = null;
|
||||
|
||||
client.use({
|
||||
onRequest({ request }) {
|
||||
if (currentAuthToken) {
|
||||
request.headers.set("Authorization", `Bearer ${currentAuthToken}`);
|
||||
}
|
||||
// XXX Only set Content-Type if not already set (FormData will set its own boundary)
|
||||
// This is a work around for uploading file, we're passing a formdata
|
||||
// but the content type was still application/json
|
||||
if (
|
||||
!request.headers.has("Content-Type") &&
|
||||
!(request.body instanceof FormData)
|
||||
) {
|
||||
request.headers.set("Content-Type", "application/json");
|
||||
}
|
||||
return request;
|
||||
},
|
||||
});
|
||||
|
||||
// the function contract: lightweight, idempotent
|
||||
export const configureApiAuth = (token: string | null | undefined) => {
|
||||
currentAuthToken = token;
|
||||
};
|
||||
618
www/app/lib/apiHooks.ts
Normal file
618
www/app/lib/apiHooks.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
"use client";
|
||||
|
||||
import { $api } from "./apiClient";
|
||||
import { useError } from "../(errors)/errorContext";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { components } from "../reflector-api";
|
||||
import { useAuth } from "./AuthProvider";
|
||||
|
||||
/*
|
||||
* XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other
|
||||
* this is either a limitation or incorrect usage of Python json schema generator
|
||||
* or, limitation or incorrect usage of .d type generator from json schema
|
||||
* */
|
||||
|
||||
const useAuthReady = () => {
|
||||
const auth = useAuth();
|
||||
|
||||
return {
|
||||
isAuthenticated: auth.status === "authenticated",
|
||||
isLoading: auth.status === "loading",
|
||||
};
|
||||
};
|
||||
|
||||
export function useRoomsList(page: number = 1) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/rooms",
|
||||
{
|
||||
params: {
|
||||
query: { page },
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: isAuthenticated,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type SourceKind = components["schemas"]["SourceKind"];
|
||||
|
||||
export function useTranscriptsSearch(
|
||||
q: string = "",
|
||||
options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
room_id?: string;
|
||||
source_kind?: SourceKind;
|
||||
} = {},
|
||||
) {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptDelete() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation("delete", "/v1/transcripts/{transcript_id}", {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["get", "/v1/transcripts/search"],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error deleting the transcript");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTranscriptProcess() {
|
||||
const { setError } = useError();
|
||||
|
||||
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/process", {
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error processing the transcript");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTranscriptGet(transcriptId: string | null) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}",
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
transcript_id: transcriptId || "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!transcriptId && isAuthenticated,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useRoomGet(roomId: string | null) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/rooms/{room_id}",
|
||||
{
|
||||
params: {
|
||||
path: { room_id: roomId || "" },
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!roomId && isAuthenticated,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useRoomTestWebhook() {
|
||||
const { setError } = useError();
|
||||
|
||||
return $api.useMutation("post", "/v1/rooms/{room_id}/webhook/test", {
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error testing the webhook");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRoomCreate() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation("post", "/v1/rooms", {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error creating the room");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRoomUpdate() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation("patch", "/v1/rooms/{room_id}", {
|
||||
onSuccess: async (room) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions("get", "/v1/rooms/{room_id}", {
|
||||
params: {
|
||||
path: {
|
||||
room_id: room.id,
|
||||
},
|
||||
},
|
||||
}).queryKey,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error updating the room");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRoomDelete() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation("delete", "/v1/rooms/{room_id}", {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error deleting the room");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useZulipStreams() {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/zulip/streams",
|
||||
{},
|
||||
{
|
||||
enabled: isAuthenticated,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useZulipTopics(streamId: number | null) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
const enabled = !!streamId && isAuthenticated;
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/zulip/streams/{stream_id}/topics",
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
stream_id: enabled ? streamId : 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptUpdate() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation("patch", "/v1/transcripts/{transcript_id}", {
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions("get", "/v1/transcripts/{transcript_id}", {
|
||||
params: {
|
||||
path: { transcript_id: variables.params.path.transcript_id },
|
||||
},
|
||||
}).queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error updating the transcript");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTranscriptPostToZulip() {
|
||||
const { setError } = useError();
|
||||
|
||||
// @ts-ignore - Zulip endpoint not in OpenAPI spec
|
||||
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/zulip", {
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error posting to Zulip");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTranscriptUploadAudio() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation(
|
||||
"post",
|
||||
"/v1/transcripts/{transcript_id}/record/upload",
|
||||
{
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: variables.params.path.transcript_id },
|
||||
},
|
||||
},
|
||||
).queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error uploading the audio file");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptWaveform(transcriptId: string | null) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/audio/waveform",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: transcriptId || "" },
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!transcriptId && isAuthenticated,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptMP3(transcriptId: string | null) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/audio/mp3",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: transcriptId || "" },
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!transcriptId && isAuthenticated,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptTopics(transcriptId: string | null) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/topics",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: transcriptId || "" },
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!transcriptId && isAuthenticated,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptTopicsWithWords(transcriptId: string | null) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/topics/with-words",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: transcriptId || "" },
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!transcriptId && isAuthenticated,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptTopicsWithWordsPerSpeaker(
|
||||
transcriptId: string | null,
|
||||
topicId: string | null,
|
||||
) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker",
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
transcript_id: transcriptId || "",
|
||||
topic_id: topicId || "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!transcriptId && !!topicId && isAuthenticated,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptParticipants(transcriptId: string | null) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/participants",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: transcriptId || "" },
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!transcriptId && isAuthenticated,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptParticipantUpdate() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation(
|
||||
"patch",
|
||||
"/v1/transcripts/{transcript_id}/participants/{participant_id}",
|
||||
{
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/participants",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: variables.params.path.transcript_id },
|
||||
},
|
||||
},
|
||||
).queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error updating the participant");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptParticipantCreate() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation(
|
||||
"post",
|
||||
"/v1/transcripts/{transcript_id}/participants",
|
||||
{
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/participants",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: variables.params.path.transcript_id },
|
||||
},
|
||||
},
|
||||
).queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error creating the participant");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptParticipantDelete() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation(
|
||||
"delete",
|
||||
"/v1/transcripts/{transcript_id}/participants/{participant_id}",
|
||||
{
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/participants",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: variables.params.path.transcript_id },
|
||||
},
|
||||
},
|
||||
).queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error deleting the participant");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptSpeakerAssign() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation(
|
||||
"patch",
|
||||
"/v1/transcripts/{transcript_id}/speaker/assign",
|
||||
{
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: variables.params.path.transcript_id },
|
||||
},
|
||||
},
|
||||
).queryKey,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/participants",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: variables.params.path.transcript_id },
|
||||
},
|
||||
},
|
||||
).queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error assigning the speaker");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptSpeakerMerge() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation(
|
||||
"patch",
|
||||
"/v1/transcripts/{transcript_id}/speaker/merge",
|
||||
{
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: variables.params.path.transcript_id },
|
||||
},
|
||||
},
|
||||
).queryKey,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/participants",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: variables.params.path.transcript_id },
|
||||
},
|
||||
},
|
||||
).queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error merging speakers");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useMeetingAudioConsent() {
|
||||
const { setError } = useError();
|
||||
|
||||
return $api.useMutation("post", "/v1/meetings/{meeting_id}/consent", {
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error recording consent");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTranscriptWebRTC() {
|
||||
const { setError } = useError();
|
||||
|
||||
return $api.useMutation(
|
||||
"post",
|
||||
"/v1/transcripts/{transcript_id}/record/webrtc",
|
||||
{
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error with WebRTC connection");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranscriptCreate() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation("post", "/v1/transcripts", {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["get", "/v1/transcripts/search"],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error creating the transcript");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRoomsCreateMeeting() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation("post", "/v1/rooms/{room_name}/meeting", {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error creating the meeting");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,157 +1,13 @@
|
||||
// import { kv } from "@vercel/kv";
|
||||
import Redlock, { ResourceLockedError } from "redlock";
|
||||
import { AuthOptions } from "next-auth";
|
||||
import AuthentikProvider from "next-auth/providers/authentik";
|
||||
import { JWT } from "next-auth/jwt";
|
||||
import { JWTWithAccessToken, CustomSession } from "./types";
|
||||
import Redis from "ioredis";
|
||||
export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
|
||||
// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min
|
||||
export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;
|
||||
|
||||
const PRETIMEOUT = 60; // seconds before token expires to refresh it
|
||||
const DEFAULT_REDIS_KEY_TIMEOUT = 60 * 60 * 24 * 30; // 30 days (refresh token expires in 30 days)
|
||||
const kv = new Redis(process.env.KV_URL || "", {
|
||||
tls: {},
|
||||
});
|
||||
const redlock = new Redlock([kv], {});
|
||||
export const LOGIN_REQUIRED_PAGES = [
|
||||
"/transcripts/[!new]",
|
||||
"/browse(.*)",
|
||||
"/rooms(.*)",
|
||||
];
|
||||
|
||||
redlock.on("error", (error) => {
|
||||
if (error instanceof ResourceLockedError) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log all other errors.
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, account, user }) {
|
||||
const extendedToken = token as JWTWithAccessToken;
|
||||
if (account && user) {
|
||||
// called only on first login
|
||||
// XXX account.expires_in used in example is not defined for authentik backend, but expires_at is
|
||||
const expiresAt = (account.expires_at as number) - PRETIMEOUT;
|
||||
const jwtToken = {
|
||||
...extendedToken,
|
||||
accessToken: account.access_token,
|
||||
accessTokenExpires: expiresAt * 1000,
|
||||
refreshToken: account.refresh_token,
|
||||
};
|
||||
kv.set(
|
||||
`token:${jwtToken.sub}`,
|
||||
JSON.stringify(jwtToken),
|
||||
"EX",
|
||||
DEFAULT_REDIS_KEY_TIMEOUT,
|
||||
);
|
||||
return jwtToken;
|
||||
}
|
||||
|
||||
if (Date.now() < extendedToken.accessTokenExpires) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// access token has expired, try to update it
|
||||
return await redisLockedrefreshAccessToken(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;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function redisLockedrefreshAccessToken(token: JWT) {
|
||||
return await redlock.using(
|
||||
[token.sub as string, "jwt-refresh"],
|
||||
5000,
|
||||
async () => {
|
||||
const redisToken = await kv.get(`token:${token.sub}`);
|
||||
const currentToken = JSON.parse(
|
||||
redisToken as string,
|
||||
) as JWTWithAccessToken;
|
||||
|
||||
// if there is multiple requests for the same token, it may already have been refreshed
|
||||
if (Date.now() < currentToken.accessTokenExpires) {
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
// now really do the request
|
||||
const newToken = await refreshAccessToken(currentToken);
|
||||
await kv.set(
|
||||
`token:${currentToken.sub}`,
|
||||
JSON.stringify(newToken),
|
||||
"EX",
|
||||
DEFAULT_REDIS_KEY_TIMEOUT,
|
||||
);
|
||||
return newToken;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> {
|
||||
try {
|
||||
const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
|
||||
|
||||
const options = {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.AUTHENTIK_CLIENT_ID as string,
|
||||
client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: token.refreshToken as string,
|
||||
}).toString(),
|
||||
method: "POST",
|
||||
};
|
||||
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
new Date().toISOString(),
|
||||
"Failed to refresh access token. Response status:",
|
||||
response.status,
|
||||
);
|
||||
const responseBody = await response.text();
|
||||
console.error(new Date().toISOString(), "Response body:", responseBody);
|
||||
throw new Error(`Failed to refresh access token: ${response.statusText}`);
|
||||
}
|
||||
const refreshedTokens = await response.json();
|
||||
return {
|
||||
...token,
|
||||
accessToken: refreshedTokens.access_token,
|
||||
accessTokenExpires:
|
||||
Date.now() + (refreshedTokens.expires_in - PRETIMEOUT) * 1000,
|
||||
refreshToken: refreshedTokens.refresh_token,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error refreshing access token", error);
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError",
|
||||
} as JWTWithAccessToken;
|
||||
}
|
||||
}
|
||||
export const PROTECTED_PAGES = new RegExp(
|
||||
LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"),
|
||||
);
|
||||
|
||||
178
www/app/lib/authBackend.ts
Normal file
178
www/app/lib/authBackend.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { AuthOptions } from "next-auth";
|
||||
import AuthentikProvider from "next-auth/providers/authentik";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
import { JWTWithAccessToken, CustomSession } from "./types";
|
||||
import { assertExists, assertExistsAndNonEmptyString } from "./utils";
|
||||
import {
|
||||
REFRESH_ACCESS_TOKEN_BEFORE,
|
||||
REFRESH_ACCESS_TOKEN_ERROR,
|
||||
} from "./auth";
|
||||
import {
|
||||
getTokenCache,
|
||||
setTokenCache,
|
||||
deleteTokenCache,
|
||||
} from "./redisTokenCache";
|
||||
import { tokenCacheRedis } from "./redisClient";
|
||||
import { isBuildPhase } from "./next";
|
||||
|
||||
// REFRESH_ACCESS_TOKEN_BEFORE because refresh is based on access token expiration (imagine we cache it 30 days)
|
||||
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
|
||||
|
||||
const refreshLocks = new Map<string, Promise<JWTWithAccessToken>>();
|
||||
|
||||
const CLIENT_ID = !isBuildPhase
|
||||
? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_ID)
|
||||
: "noop";
|
||||
const CLIENT_SECRET = !isBuildPhase
|
||||
? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_SECRET)
|
||||
: "noop";
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
providers: [
|
||||
AuthentikProvider({
|
||||
clientId: CLIENT_ID,
|
||||
clientSecret: CLIENT_SECRET,
|
||||
issuer: process.env.AUTHENTIK_ISSUER,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid email profile offline_access",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, account, user }) {
|
||||
const KEY = `token:${token.sub}`;
|
||||
|
||||
if (account && user) {
|
||||
// called only on first login
|
||||
// XXX account.expires_in used in example is not defined for authentik backend, but expires_at is
|
||||
const expiresAtS = assertExists(account.expires_at);
|
||||
const expiresAtMs = expiresAtS * 1000;
|
||||
if (!account.access_token) {
|
||||
await deleteTokenCache(tokenCacheRedis, KEY);
|
||||
} else {
|
||||
const jwtToken: JWTWithAccessToken = {
|
||||
...token,
|
||||
accessToken: account.access_token,
|
||||
accessTokenExpires: expiresAtMs,
|
||||
refreshToken: account.refresh_token,
|
||||
};
|
||||
await setTokenCache(tokenCacheRedis, KEY, {
|
||||
token: jwtToken,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return jwtToken;
|
||||
}
|
||||
}
|
||||
|
||||
const currentToken = await getTokenCache(tokenCacheRedis, KEY);
|
||||
if (currentToken && Date.now() < currentToken.token.accessTokenExpires) {
|
||||
return currentToken.token;
|
||||
}
|
||||
|
||||
// access token has expired, try to update it
|
||||
return await lockedRefreshAccessToken(token);
|
||||
},
|
||||
async session({ session, token }) {
|
||||
const extendedToken = token as JWTWithAccessToken;
|
||||
return {
|
||||
...session,
|
||||
accessToken: extendedToken.accessToken,
|
||||
accessTokenExpires: extendedToken.accessTokenExpires,
|
||||
error: extendedToken.error,
|
||||
user: {
|
||||
id: assertExists(extendedToken.sub),
|
||||
name: extendedToken.name,
|
||||
email: extendedToken.email,
|
||||
},
|
||||
} satisfies CustomSession;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function lockedRefreshAccessToken(
|
||||
token: JWT,
|
||||
): Promise<JWTWithAccessToken> {
|
||||
const lockKey = `${token.sub}-refresh`;
|
||||
|
||||
const existingRefresh = refreshLocks.get(lockKey);
|
||||
if (existingRefresh) {
|
||||
return await existingRefresh;
|
||||
}
|
||||
|
||||
const refreshPromise = (async () => {
|
||||
try {
|
||||
const cached = await getTokenCache(tokenCacheRedis, `token:${token.sub}`);
|
||||
if (cached) {
|
||||
if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
|
||||
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
|
||||
} else if (Date.now() < cached.token.accessTokenExpires) {
|
||||
return cached.token;
|
||||
}
|
||||
}
|
||||
|
||||
const currentToken = cached?.token || (token as JWTWithAccessToken);
|
||||
const newToken = await refreshAccessToken(currentToken);
|
||||
|
||||
await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
|
||||
token: newToken,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return newToken;
|
||||
} finally {
|
||||
setTimeout(() => refreshLocks.delete(lockKey), 100);
|
||||
}
|
||||
})();
|
||||
|
||||
refreshLocks.set(lockKey, refreshPromise);
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> {
|
||||
try {
|
||||
const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
|
||||
|
||||
const options = {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.AUTHENTIK_CLIENT_ID as string,
|
||||
client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: token.refreshToken as string,
|
||||
}).toString(),
|
||||
method: "POST",
|
||||
};
|
||||
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
new Date().toISOString(),
|
||||
"Failed to refresh access token. Response status:",
|
||||
response.status,
|
||||
);
|
||||
const responseBody = await response.text();
|
||||
console.error(new Date().toISOString(), "Response body:", responseBody);
|
||||
throw new Error(`Failed to refresh access token: ${response.statusText}`);
|
||||
}
|
||||
const refreshedTokens = await response.json();
|
||||
return {
|
||||
...token,
|
||||
accessToken: refreshedTokens.access_token,
|
||||
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
|
||||
refreshToken: refreshedTokens.refresh_token,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error refreshing access token", error);
|
||||
return {
|
||||
...token,
|
||||
error: REFRESH_ACCESS_TOKEN_ERROR,
|
||||
} as JWTWithAccessToken;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { get } from "@vercel/edge-config";
|
||||
import { isDevelopment } from "./utils";
|
||||
import { isBuildPhase } from "./next";
|
||||
|
||||
type EdgeConfig = {
|
||||
[domainWithDash: string]: {
|
||||
@@ -29,12 +29,18 @@ export function edgeDomainToKey(domain: string) {
|
||||
|
||||
// get edge config server-side (prefer DomainContext when available), domain is the hostname
|
||||
export async function getConfig() {
|
||||
const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
|
||||
|
||||
if (process.env.NEXT_PUBLIC_ENV === "development") {
|
||||
return require("../../config").localConfig;
|
||||
try {
|
||||
return require("../../config").localConfig;
|
||||
} catch (e) {
|
||||
// next build() WILL try to execute the require above even if conditionally protected
|
||||
// but thank god it at least runs catch{} block properly
|
||||
if (!isBuildPhase) throw new Error(e);
|
||||
return require("../../config-template").localConfig;
|
||||
}
|
||||
}
|
||||
|
||||
const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
|
||||
let config = await get(edgeDomainToKey(domain));
|
||||
|
||||
if (typeof config !== "object") {
|
||||
|
||||
2
www/app/lib/next.ts
Normal file
2
www/app/lib/next.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// next.js tries to run all the lib code during build phase; we don't always want it when e.g. we have connections initialized we don't want to have
|
||||
export const isBuildPhase = process.env.NEXT_PHASE?.includes("build");
|
||||
17
www/app/lib/queryClient.tsx
Normal file
17
www/app/lib/queryClient.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
46
www/app/lib/redisClient.ts
Normal file
46
www/app/lib/redisClient.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import Redis from "ioredis";
|
||||
import { isBuildPhase } from "./next";
|
||||
|
||||
export type RedisClient = Pick<Redis, "get" | "setex" | "del">;
|
||||
|
||||
const getRedisClient = (): RedisClient => {
|
||||
const redisUrl = process.env.KV_URL;
|
||||
if (!redisUrl) {
|
||||
throw new Error("KV_URL environment variable is required");
|
||||
}
|
||||
const redis = new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
redis.on("error", (error) => {
|
||||
console.error("Redis error:", error);
|
||||
});
|
||||
|
||||
// not necessary but will indicate redis config errors by failfast at startup
|
||||
// happens only once; after that connection is allowed to die and the lib is assumed to be able to restore it eventually
|
||||
redis.connect().catch((e) => {
|
||||
console.error("Failed to connect to Redis:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
return redis;
|
||||
};
|
||||
|
||||
// next.js buildtime usage - we want to isolate next.js "build" time concepts here
|
||||
const noopClient: RedisClient = (() => {
|
||||
const noopSetex: Redis["setex"] = async () => {
|
||||
return "OK" as const;
|
||||
};
|
||||
const noopDel: Redis["del"] = async () => {
|
||||
return 0;
|
||||
};
|
||||
return {
|
||||
get: async () => {
|
||||
return null;
|
||||
},
|
||||
setex: noopSetex,
|
||||
del: noopDel,
|
||||
};
|
||||
})();
|
||||
export const tokenCacheRedis = isBuildPhase ? noopClient : getRedisClient();
|
||||
61
www/app/lib/redisTokenCache.ts
Normal file
61
www/app/lib/redisTokenCache.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod";
|
||||
import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth";
|
||||
|
||||
const TokenCacheEntrySchema = z.object({
|
||||
token: z.object({
|
||||
sub: z.string().optional(),
|
||||
name: z.string().nullish(),
|
||||
email: z.string().nullish(),
|
||||
accessToken: z.string(),
|
||||
accessTokenExpires: z.number(),
|
||||
refreshToken: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
const TokenCacheEntryCodec = z.codec(z.string(), TokenCacheEntrySchema, {
|
||||
decode: (jsonString) => {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
return TokenCacheEntrySchema.parse(parsed);
|
||||
},
|
||||
encode: (value) => JSON.stringify(value),
|
||||
});
|
||||
|
||||
export type TokenCacheEntry = z.infer<typeof TokenCacheEntrySchema>;
|
||||
|
||||
export type KV = {
|
||||
get(key: string): Promise<string | null>;
|
||||
setex(key: string, seconds: number, value: string): Promise<"OK">;
|
||||
del(key: string): Promise<number>;
|
||||
};
|
||||
|
||||
export async function getTokenCache(
|
||||
redis: KV,
|
||||
key: string,
|
||||
): Promise<TokenCacheEntry | null> {
|
||||
const data = await redis.get(key);
|
||||
if (!data) return null;
|
||||
|
||||
try {
|
||||
return TokenCacheEntryCodec.decode(data);
|
||||
} catch (error) {
|
||||
console.error("Invalid token cache data:", error);
|
||||
await redis.del(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setTokenCache(
|
||||
redis: KV,
|
||||
key: string,
|
||||
value: TokenCacheEntry,
|
||||
): Promise<void> {
|
||||
const encodedValue = TokenCacheEntryCodec.encode(value);
|
||||
const ttlSeconds = Math.floor(REFRESH_ACCESS_TOKEN_BEFORE / 1000);
|
||||
await redis.setex(key, ttlSeconds, encodedValue);
|
||||
}
|
||||
|
||||
export async function deleteTokenCache(redis: KV, key: string): Promise<void> {
|
||||
await redis.del(key);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
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 {
|
||||
accessToken: string;
|
||||
accessTokenExpires: number;
|
||||
refreshToken: string;
|
||||
refreshToken?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -12,9 +13,62 @@ export interface CustomSession extends Session {
|
||||
accessToken: string;
|
||||
accessTokenExpires: number;
|
||||
error?: string;
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
user: Session["user"] & {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
// assumption that JWT is JWTWithAccessToken - we set it in jwt callback of auth; typing isn't strong around there
|
||||
// but the assumption is crucial to auth working
|
||||
export const assertExtendedToken = <T>(
|
||||
t: T,
|
||||
): T & {
|
||||
accessTokenExpires: number;
|
||||
accessToken: string;
|
||||
} => {
|
||||
if (
|
||||
typeof (t as { accessTokenExpires: any }).accessTokenExpires === "number" &&
|
||||
!isNaN((t as { accessTokenExpires: any }).accessTokenExpires) &&
|
||||
typeof (
|
||||
t as {
|
||||
accessToken: any;
|
||||
}
|
||||
).accessToken === "string" &&
|
||||
parseMaybeNonEmptyString((t as { accessToken: any }).accessToken) !== null
|
||||
) {
|
||||
return t as T & {
|
||||
accessTokenExpires: number;
|
||||
accessToken: string;
|
||||
};
|
||||
}
|
||||
throw new Error("Token is not extended with access token");
|
||||
};
|
||||
|
||||
export const assertExtendedTokenAndUserId = <U, T extends { user?: U }>(
|
||||
t: T,
|
||||
): T & {
|
||||
accessTokenExpires: number;
|
||||
accessToken: string;
|
||||
user: U & {
|
||||
id: string;
|
||||
};
|
||||
} => {
|
||||
const extendedToken = assertExtendedToken(t);
|
||||
if (typeof (extendedToken.user as any)?.id === "string") {
|
||||
return t as T & {
|
||||
accessTokenExpires: number;
|
||||
accessToken: string;
|
||||
user: U & {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
throw new Error("Token is not extended with user id");
|
||||
};
|
||||
|
||||
// best attempt to check the session is valid
|
||||
export const assertCustomSession = <S extends Session>(s: S): CustomSession => {
|
||||
const r = assertExtendedTokenAndUserId(s);
|
||||
// no other checks for now
|
||||
return r as CustomSession;
|
||||
};
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { DomainContext, featureEnabled } from "../domainContext";
|
||||
import { OpenApi, DefaultService } from "../api";
|
||||
import { CustomSession } from "./types";
|
||||
import useSessionStatus from "./useSessionStatus";
|
||||
import useSessionAccessToken from "./useSessionAccessToken";
|
||||
|
||||
export default function useApi(): DefaultService | null {
|
||||
const api_url = useContext(DomainContext).api_url;
|
||||
const [api, setApi] = useState<OpenApi | null>(null);
|
||||
const { isLoading, isAuthenticated } = useSessionStatus();
|
||||
const { accessToken, error } = useSessionAccessToken();
|
||||
|
||||
if (!api_url) throw new Error("no API URL");
|
||||
|
||||
useEffect(() => {
|
||||
if (error === "RefreshAccessTokenError") {
|
||||
signOut();
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || (isAuthenticated && !accessToken)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openApi = new OpenApi({
|
||||
BASE: api_url,
|
||||
TOKEN: accessToken || undefined,
|
||||
});
|
||||
|
||||
setApi(openApi);
|
||||
}, [isLoading, isAuthenticated, accessToken]);
|
||||
|
||||
return api?.default ?? null;
|
||||
}
|
||||
26
www/app/lib/useLoginRequiredPages.ts
Normal file
26
www/app/lib/useLoginRequiredPages.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// for paths that are not supposed to be public
|
||||
import { PROTECTED_PAGES } from "./auth";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "./AuthProvider";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const HOME = "/" as const;
|
||||
|
||||
export const useLoginRequiredPages = () => {
|
||||
const pathname = usePathname();
|
||||
const isProtected = PROTECTED_PAGES.test(pathname);
|
||||
const auth = useAuth();
|
||||
const isNotLoggedIn = auth.status === "unauthenticated";
|
||||
// safety
|
||||
const isLastDestination = pathname === HOME;
|
||||
const shouldRedirect = isNotLoggedIn && isProtected && !isLastDestination;
|
||||
useEffect(() => {
|
||||
if (!shouldRedirect) return;
|
||||
// on the backend, the redirect goes straight to the auth provider, but we don't have it because it's hidden inside next-auth middleware
|
||||
// so we just "softly" lead the user to the main page
|
||||
// warning: if HOME redirects somewhere else, we won't be protected by isLastDestination
|
||||
window.location.href = HOME;
|
||||
}, [shouldRedirect]);
|
||||
// optionally save from blink, since window.location.href takes a bit of time
|
||||
return shouldRedirect ? HOME : null;
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
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;
|
||||
const naAccessToken = customSession?.accessToken;
|
||||
const naAccessTokenExpires = customSession?.accessTokenExpires;
|
||||
const naError = customSession?.error;
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [accessTokenExpires, setAccessTokenExpires] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (naAccessToken !== accessToken) {
|
||||
setAccessToken(naAccessToken);
|
||||
}
|
||||
}, [naAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (naAccessTokenExpires !== accessTokenExpires) {
|
||||
setAccessTokenExpires(naAccessTokenExpires);
|
||||
}
|
||||
}, [naAccessTokenExpires]);
|
||||
|
||||
useEffect(() => {
|
||||
if (naError !== error) {
|
||||
setError(naError);
|
||||
}
|
||||
}, [naError]);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
accessTokenExpires,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession as useNextAuthSession } from "next-auth/react";
|
||||
import { Session } from "next-auth";
|
||||
|
||||
export default function useSessionStatus() {
|
||||
const { status: naStatus } = useNextAuthSession();
|
||||
const [status, setStatus] = useState("loading");
|
||||
|
||||
useEffect(() => {
|
||||
if (naStatus !== "loading" && naStatus !== status) {
|
||||
setStatus(naStatus);
|
||||
}
|
||||
}, [naStatus]);
|
||||
|
||||
return {
|
||||
status,
|
||||
isLoading: status === "loading",
|
||||
isAuthenticated: status === "authenticated",
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession as useNextAuthSession } from "next-auth/react";
|
||||
import { Session } from "next-auth";
|
||||
|
||||
// user type with id, name, email
|
||||
export interface User {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export default function useSessionUser() {
|
||||
const { data: session } = useNextAuthSession();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user) {
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
if (JSON.stringify(session.user) !== JSON.stringify(user)) {
|
||||
setUser(session.user);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
return {
|
||||
id: user?.id,
|
||||
name: user?.name,
|
||||
email: user?.email,
|
||||
};
|
||||
}
|
||||
7
www/app/lib/useUserName.ts
Normal file
7
www/app/lib/useUserName.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useAuth } from "./AuthProvider";
|
||||
|
||||
export const useUserName = (): string | null | undefined => {
|
||||
const auth = useAuth();
|
||||
if (auth.status !== "authenticated") return undefined;
|
||||
return auth.user?.name || null;
|
||||
};
|
||||
@@ -137,9 +137,28 @@ export function extractDomain(url) {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertExists<T>(value: T | null | undefined, err?: string): T {
|
||||
export type NonEmptyString = string & { __brand: "NonEmptyString" };
|
||||
export const parseMaybeNonEmptyString = (
|
||||
s: string,
|
||||
trim = true,
|
||||
): NonEmptyString | null => {
|
||||
s = trim ? s.trim() : s;
|
||||
return s.length > 0 ? (s as NonEmptyString) : null;
|
||||
};
|
||||
export const parseNonEmptyString = (s: string, trim = true): NonEmptyString =>
|
||||
assertExists(parseMaybeNonEmptyString(s, trim), "Expected non-empty string");
|
||||
|
||||
export const assertExists = <T>(
|
||||
value: T | null | undefined,
|
||||
err?: string,
|
||||
): T => {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(`Assertion failed: ${err ?? "value is null or undefined"}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export const assertExistsAndNonEmptyString = (
|
||||
value: string | null | undefined,
|
||||
): NonEmptyString =>
|
||||
parseNonEmptyString(assertExists(value, "Expected non-empty string"));
|
||||
|
||||
Reference in New Issue
Block a user