From 31c44ac0bbbe5b65c924e7e0fbbf0d4e08501047 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Tue, 2 Sep 2025 14:44:10 -0400 Subject: [PATCH] fix auth --- www/app/(app)/browse/page.tsx | 5 +-- www/app/(app)/transcripts/shareAndPrivacy.tsx | 5 ++- www/app/lib/ApiAuthProvider.tsx | 16 --------- www/app/lib/AuthProvider.tsx | 12 +++---- www/app/lib/SessionAutoRefresh.tsx | 7 ++-- www/app/lib/apiClient.tsx | 4 +-- www/app/lib/auth.ts | 17 ++++++---- www/app/lib/types.ts | 5 --- www/app/lib/useSessionUser.ts | 33 ------------------- www/app/lib/useUserId.ts | 19 +++++++++++ www/app/lib/useUserName.ts | 7 ++++ 11 files changed, 52 insertions(+), 78 deletions(-) delete mode 100644 www/app/lib/ApiAuthProvider.tsx delete mode 100644 www/app/lib/useSessionUser.ts create mode 100644 www/app/lib/useUserId.ts create mode 100644 www/app/lib/useUserName.ts diff --git a/www/app/(app)/browse/page.tsx b/www/app/(app)/browse/page.tsx index 659588cc..2a22ae9c 100644 --- a/www/app/(app)/browse/page.tsx +++ b/www/app/(app)/browse/page.tsx @@ -19,7 +19,7 @@ import { parseAsStringLiteral, } from "nuqs"; import { LuX } from "react-icons/lu"; -import useSessionUser from "../../lib/useSessionUser"; +import useSessionUser from "../../lib/useUserId"; import type { components } from "../../reflector-api"; type Room = components["schemas"]["Room"]; @@ -43,6 +43,7 @@ import TranscriptCards from "./_components/TranscriptCards"; import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog"; import { formatLocalDate } from "../../lib/time"; import { RECORD_A_MEETING_URL } from "../../api/urls"; +import { useUserName } from "../../lib/useUserName"; const SEARCH_FORM_QUERY_INPUT_NAME = "query" as const; @@ -255,7 +256,7 @@ export default function TranscriptBrowser() { const totalPages = getTotalPages(totalResults, pageSize); - const userName = useSessionUser().name; + const userName = useUserName(); const [deletionLoading, setDeletionLoading] = useState(false); const cancelRef = React.useRef(null); const [transcriptToDeleteId, setTranscriptToDeleteId] = diff --git a/www/app/(app)/transcripts/shareAndPrivacy.tsx b/www/app/(app)/transcripts/shareAndPrivacy.tsx index 43a61753..608ebb1a 100644 --- a/www/app/(app)/transcripts/shareAndPrivacy.tsx +++ b/www/app/(app)/transcripts/shareAndPrivacy.tsx @@ -19,11 +19,10 @@ import { } from "@chakra-ui/react"; import { LuShare2 } from "react-icons/lu"; import { useTranscriptUpdate } from "../../lib/apiHooks"; -import useSessionUser from "../../lib/useSessionUser"; -import { CustomSession } from "../../lib/types"; import ShareLink from "./shareLink"; import ShareCopy from "./shareCopy"; import ShareZulip from "./shareZulip"; +import useUserId from "../../lib/useUserId"; type ShareAndPrivacyProps = { finalSummaryRef: any; @@ -86,7 +85,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) { } }; - const userId = useSessionUser().id; + const userId = useUserId(); useEffect(() => { setIsOwner(!!(requireLogin && userId === props.transcriptResponse.user_id)); diff --git a/www/app/lib/ApiAuthProvider.tsx b/www/app/lib/ApiAuthProvider.tsx deleted file mode 100644 index 6215408c..00000000 --- a/www/app/lib/ApiAuthProvider.tsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { configureApiAuth } from "./apiClient"; -import useSessionAccessToken from "./useSessionAccessToken"; - -// TODO should be context -export function ApiAuthProvider({ children }: { children: React.ReactNode }) { - const { accessToken } = useSessionAccessToken(); - - useEffect(() => { - configureApiAuth(accessToken); - }, [accessToken]); - - return <>{children}; -} diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx index c6a77486..c9b47914 100644 --- a/www/app/lib/AuthProvider.tsx +++ b/www/app/lib/AuthProvider.tsx @@ -3,7 +3,7 @@ import { createContext, useContext, useEffect } from "react"; import { useSession as useNextAuthSession } from "next-auth/react"; import { configureApiAuth } from "./apiClient"; -import { CustomSession } from "./types"; +import { assertExtendedToken, CustomSession } from "./types"; type AuthContextType = | { status: "loading" } @@ -12,26 +12,24 @@ type AuthContextType = status: "authenticated"; accessToken: string; accessTokenExpires: number; + user: CustomSession["user"]; }; const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: React.ReactNode }) { const { data: session, status } = useNextAuthSession(); - const customSession = session as CustomSession; - - if (session) { - debugger; - } + const customSession = session ? assertExtendedToken(session) : null; const contextValue: AuthContextType = status === "loading" ? { status: "loading" as const } - : customSession?.accessToken + : status === "authenticated" && customSession?.accessToken ? { status: "authenticated" as const, accessToken: customSession.accessToken, accessTokenExpires: customSession.accessTokenExpires, + user: customSession.user, } : { status: "unauthenticated" as const }; diff --git a/www/app/lib/SessionAutoRefresh.tsx b/www/app/lib/SessionAutoRefresh.tsx index 1e230d6c..127510b2 100644 --- a/www/app/lib/SessionAutoRefresh.tsx +++ b/www/app/lib/SessionAutoRefresh.tsx @@ -9,15 +9,16 @@ import { useSession } from "next-auth/react"; import { useEffect } from "react"; -import { CustomSession } from "./types"; +import { assertExtendedToken, CustomSession } from "./types"; export function SessionAutoRefresh({ children, refreshInterval = 20 /* seconds */, }) { const { data: session, update } = useSession(); - const customSession = session as CustomSession; - const accessTokenExpires = customSession?.accessTokenExpires; + const accessTokenExpires = session + ? assertExtendedToken(session).accessTokenExpires + : null; useEffect(() => { const interval = setInterval(() => { diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx index fe2a271b..6c798ced 100644 --- a/www/app/lib/apiClient.tsx +++ b/www/app/lib/apiClient.tsx @@ -11,9 +11,9 @@ import { import createFetchClient from "openapi-react-query"; // Create the base openapi-fetch client with a default URL -// The actual URL will be set via middleware in ApiAuthProvider +// The actual URL will be set via middleware in AuthProvider export const client = createClient({ - baseUrl: "http://192.0.2.1:1250", + baseUrl: "http://127.0.0.1:1250", }); export const $api = createFetchClient(client); diff --git a/www/app/lib/auth.ts b/www/app/lib/auth.ts index 7a93b105..859459d3 100644 --- a/www/app/lib/auth.ts +++ b/www/app/lib/auth.ts @@ -7,6 +7,7 @@ import { assertExtendedToken, } from "./types"; import { + assertExists, assertExistsAndNonEmptyString, parseMaybeNonEmptyString, } from "./utils"; @@ -48,23 +49,24 @@ export const authOptions: AuthOptions = { }, callbacks: { async jwt({ token, account, user }) { - const extendedToken = assertExtendedToken(token); 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 expiresAt = (account.expires_at as number) - PRETIMEOUT; + const expiresAtS = assertExists(account.expires_at) - PRETIMEOUT; + const expiresAtMs = expiresAtS * 1000; if (!account.access_token) { tokenCache.delete(KEY); } else { const jwtToken: JWTWithAccessToken = { - ...extendedToken, + ...token, accessToken: account.access_token, - accessTokenExpires: expiresAt * 1000, + accessTokenExpires: expiresAtMs, refreshToken: account.refresh_token || "", }; // Store in memory cache - tokenCache.set(`token:${jwtToken.sub}`, { + tokenCache.set(KEY, { token: jwtToken, timestamp: Date.now(), }); @@ -72,8 +74,9 @@ export const authOptions: AuthOptions = { } } - if (Date.now() < extendedToken.accessTokenExpires) { - return token; + const currentToken = tokenCache.get(KEY); + if (currentToken && Date.now() < currentToken.token.accessTokenExpires) { + return currentToken.token; } // access token has expired, try to update it diff --git a/www/app/lib/types.ts b/www/app/lib/types.ts index 00c50820..70f58ec5 100644 --- a/www/app/lib/types.ts +++ b/www/app/lib/types.ts @@ -13,11 +13,6 @@ export interface CustomSession extends Session { accessToken: string; accessTokenExpires: number; error?: string; - user: { - id?: string; - name?: string | null; - email?: string | null; - }; } // assumption that JWT is JWTWithAccessToken - not ideal, TODO find a reason we have to do that diff --git a/www/app/lib/useSessionUser.ts b/www/app/lib/useSessionUser.ts deleted file mode 100644 index 2da299f5..00000000 --- a/www/app/lib/useSessionUser.ts +++ /dev/null @@ -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(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, - }; -} diff --git a/www/app/lib/useUserId.ts b/www/app/lib/useUserId.ts new file mode 100644 index 00000000..415195b3 --- /dev/null +++ b/www/app/lib/useUserId.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSession as useNextAuthSession } from "next-auth/react"; +import { Session } from "next-auth"; +import { useAuth } from "./AuthProvider"; + +const assertUserId = (u: T): T & { id: string } => { + if (typeof (u as { id: string }).id !== "string") + throw new Error("Expected user.id to be a string"); + return u as T & { id: string }; +}; + +// the current assumption in useSessionUser is that "useNextAuthSession" also returns user.id, although useNextAuthSession documentation states it doesn't +// the hook is to isolate the potential impact and to document this behaviour +export default function useUserId() { + const auth = useAuth(); + return auth.status === "authenticated" ? assertUserId(auth.user) : null; +} diff --git a/www/app/lib/useUserName.ts b/www/app/lib/useUserName.ts new file mode 100644 index 00000000..80814281 --- /dev/null +++ b/www/app/lib/useUserName.ts @@ -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; +};