From 75a9b4ca1e65f12c484db0c919e95a427d0be7a9 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Mon, 23 Mar 2026 21:53:27 +1100 Subject: [PATCH] feat: implement centralized access token management for media downloads and user authentication --- .../components/image-viewer/ImageViewer.tsx | 4 +- src/app/components/message/FileHeader.tsx | 4 +- .../message/content/AudioContent.tsx | 3 +- .../message/content/FileContent.tsx | 10 +- .../message/content/ImageContent.tsx | 4 +- .../message/content/ThumbnailContent.tsx | 4 +- .../message/content/VideoContent.tsx | 3 +- src/app/features/settings/account/Profile.tsx | 7 +- src/app/hooks/useAuthenticatedMediaUrl.ts | 7 +- src/app/hooks/useUserBanner.ts | 13 ++- src/app/hooks/useUserColor.ts | 10 +- src/app/hooks/useUserProfileStyle.ts | 12 ++- src/app/utils/auth.ts | 95 +++++++++++++++++++ src/app/utils/matrix.ts | 14 ++- src/client/initMatrix.ts | 10 ++ src/index.tsx | 37 +++----- 16 files changed, 183 insertions(+), 54 deletions(-) create mode 100644 src/app/utils/auth.ts diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 3692118..b9fc514 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -8,6 +8,7 @@ import { useZoom } from '../../hooks/useZoom'; import { usePan } from '../../hooks/usePan'; import { downloadMedia } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getCurrentAccessToken } from '../../utils/auth'; export type ImageViewerProps = { alt: string; @@ -23,7 +24,8 @@ export const ImageViewer = as<'div', ImageViewerProps>( const handleDownload = async () => { try { - const fileContent = await downloadMedia(src, mx.getAccessToken()); + // Always use current session's token to avoid stale tokens during account switches + const fileContent = await downloadMedia(src, getCurrentAccessToken()); FileSaver.saveAs(fileContent, alt); } catch (error) { console.warn('[ImageViewer] Failed to download media:', error); diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index bb447b4..ba1b8e5 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -12,6 +12,7 @@ import { downloadMedia, mxcUrlToHttp, } from '../../utils/matrix'; +import { getCurrentAccessToken } from '../../utils/auth'; const badgeStyles = { maxWidth: toRem(100) }; @@ -28,7 +29,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow const [downloadState, download] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) : await downloadMedia(mediaUrl, accessToken); diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 414bd59..38912aa 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -55,7 +55,8 @@ export function AudioContent({ const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) : await downloadMedia(mediaUrl, accessToken); diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index 4ab1446..4abd3de 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -35,6 +35,7 @@ import { mxcUrlToHttp, } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { getCurrentAccessToken } from '../../../utils/auth'; import { ModalWide } from '../../../styles/Modal.css'; const renderErrorButton = (retry: () => void, text: string) => ( @@ -87,7 +88,8 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea const [textState, loadText] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) : await downloadMedia(mediaUrl, accessToken); @@ -178,7 +180,8 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read const [pdfState, loadPdf] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) : await downloadMedia(mediaUrl, accessToken); @@ -256,7 +259,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil const [downloadState, download] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) : await downloadMedia(mediaUrl, accessToken); diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index be56314..2569f73 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -31,6 +31,7 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { ModalWide } from '../../../styles/Modal.css'; import { validBlurHash } from '../../../utils/blurHash'; +import { getCurrentAccessToken } from '../../../utils/auth'; /** * Fetches media with authentication headers and returns a blob URL. @@ -129,7 +130,8 @@ export const ImageContent = as<'div', ImageContentProps>( const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); if (encInfo) { const fileContent = await downloadEncryptedMedia( mediaUrl, diff --git a/src/app/components/message/content/ThumbnailContent.tsx b/src/app/components/message/content/ThumbnailContent.tsx index efda1f8..ca6cbbe 100644 --- a/src/app/components/message/content/ThumbnailContent.tsx +++ b/src/app/components/message/content/ThumbnailContent.tsx @@ -5,6 +5,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { decryptFile, downloadEncryptedMedia, isAuthenticatedMediaUrl, mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes'; +import { getCurrentAccessToken } from '../../../utils/auth'; /** * Fetches media with authentication headers and returns a blob URL. @@ -57,7 +58,8 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { } const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl; - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); if (encInfo) { const fileContent = await downloadEncryptedMedia( mediaUrl, diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index 9eef522..d56e1b4 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -82,7 +82,8 @@ export const VideoContent = as<'div', VideoContentProps>( const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 03e8343..b253851 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -64,6 +64,7 @@ import { getExtension, ImageMetadata, } from '../../../utils/imageMetadata'; +import { getCurrentAccessToken } from '../../../utils/auth'; /** * Banner upload component for user's profile banner @@ -397,7 +398,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject { try { - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); let response = await fetch(src, { method: 'GET', headers: accessToken @@ -104,7 +106,8 @@ export const useAuthenticatedMediaFetch = () => { } try { - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); let response = await fetch(src, { method: 'GET', headers: accessToken diff --git a/src/app/hooks/useUserBanner.ts b/src/app/hooks/useUserBanner.ts index 067c4de..3df6f0c 100644 --- a/src/app/hooks/useUserBanner.ts +++ b/src/app/hooks/useUserBanner.ts @@ -3,6 +3,7 @@ import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useMediaAuthentication } from './useMediaAuthentication'; import { mxcUrlToHttp } from '../utils/matrix'; +import { getCurrentAccessToken } from '../utils/auth'; import { fetchAndExtractMetadata, extractMetadataFromImage, @@ -25,7 +26,8 @@ async function fetchAvatarData( if (!url) return null; try { - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); let response = await fetch(url, { method: 'GET', headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, @@ -85,7 +87,8 @@ export function useUserBanner(): [ return; } - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); setBanner(metadata.banner); } catch { @@ -236,7 +239,8 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined return; } - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); bannerMxc = metadata.banner; @@ -257,7 +261,8 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined return; } - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const headers: HeadersInit = {}; if (useAuthentication && accessToken) { headers.Authorization = `Bearer ${accessToken}`; diff --git a/src/app/hooks/useUserColor.ts b/src/app/hooks/useUserColor.ts index ee7c964..6ee0f5c 100644 --- a/src/app/hooks/useUserColor.ts +++ b/src/app/hooks/useUserColor.ts @@ -3,6 +3,7 @@ import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useMediaAuthentication } from './useMediaAuthentication'; import { mxcUrlToHttp } from '../utils/matrix'; +import { getCurrentAccessToken } from '../utils/auth'; import { embedColorInImage, extractColorFromImage, @@ -25,7 +26,8 @@ async function fetchAvatarData( if (!url) return null; try { - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); let response = await fetch(url, { method: 'GET', headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, @@ -86,7 +88,8 @@ export function useUserColor(): [ return; } - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null); setColor(extractedColor); } catch (e) { @@ -237,7 +240,8 @@ export function useOtherUserColor(userId: string, avatarMxc: string | undefined) return; } - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null); // Cache the result diff --git a/src/app/hooks/useUserProfileStyle.ts b/src/app/hooks/useUserProfileStyle.ts index c8a50e7..ea49679 100644 --- a/src/app/hooks/useUserProfileStyle.ts +++ b/src/app/hooks/useUserProfileStyle.ts @@ -2,8 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useMediaAuthentication } from './useMediaAuthentication'; -import { mxcUrlToHttp } from '../utils/matrix'; -import { +import { mxcUrlToHttp } from '../utils/matrix';import { getCurrentAccessToken } from '../utils/auth';import { fetchAndExtractMetadata, extractMetadataFromImage, embedMetadataInImage, @@ -26,7 +25,8 @@ async function fetchAvatarData( if (!url) return null; try { - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); let response = await fetch(url, { method: 'GET', headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, @@ -91,7 +91,8 @@ export function useUserProfileStyle(): [ return; } - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); setStyle({ avatarBorderColor: metadata.avatarBorderColor, @@ -231,7 +232,8 @@ export function useOtherUserProfileStyle(userId: string, avatarMxc: string | und return; } - const accessToken = mx.getAccessToken(); + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); const styleData: ProfileStyleData = { diff --git a/src/app/utils/auth.ts b/src/app/utils/auth.ts new file mode 100644 index 0000000..7062690 --- /dev/null +++ b/src/app/utils/auth.ts @@ -0,0 +1,95 @@ +/** + * Authentication utilities for managing access tokens across multiple accounts + */ + +import { MatrixClient } from 'matrix-js-sdk'; +import { getLocalStorageItem, setLocalStorageItem } from '../state/utils/atomWithLocalStorage'; +import { Session, Sessions, MATRIX_SESSIONS_KEY, CURRENT_SESSION_KEY } from '../state/sessions'; + +/** + * Global reference to the current MatrixClient instance + * Set by initMatrix.ts when a client is created + */ +let globalMatrixClient: MatrixClient | null = null; + +/** + * Set the global MatrixClient reference + * This should be called from initMatrix.ts after creating the client + * @param client - The MatrixClient instance or null to clear + */ +export function setGlobalMatrixClient(client: MatrixClient | null): void { + globalMatrixClient = client; +} + +/** + * Get the current MatrixClient instance + * @returns The MatrixClient instance or null if not initialized + */ +export function getGlobalMatrixClient(): MatrixClient | null { + return globalMatrixClient; +} + +/** + * Update the access token for a session in localStorage + * This should be called when a token is refreshed by the MatrixClient + * @param userId - The user ID + * @param newToken - The new access token + */ +export function updateSessionToken(userId: string, newToken: string): void { + const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); + const sessionIndex = sessions.findIndex((s: Session) => s.userId === userId); + + if (sessionIndex >= 0) { + sessions[sessionIndex] = { + ...sessions[sessionIndex], + accessToken: newToken, + }; + setLocalStorageItem(MATRIX_SESSIONS_KEY, sessions); + } +} + +/** + * Get the access token for the currently active session. + * Prioritizes the token from the active MatrixClient instance (most up-to-date), + * then falls back to localStorage. This ensures we always use the freshest token, + * even if it's been refreshed by the SDK. + * + * @returns The current access token or undefined if no session is active + */ +export function getCurrentAccessToken(): string | undefined { + // First try to get the token from the active MatrixClient if available + // This ensures we get the most up-to-date token, including any refreshed tokens + if (globalMatrixClient) { + const clientToken = globalMatrixClient.getAccessToken?.(); + if (clientToken) { + return clientToken; + } + } + + // Fall back to localStorage if MatrixClient is not available or doesn't have a token + const currentUserId = getLocalStorageItem(CURRENT_SESSION_KEY, undefined); + + if (!currentUserId) { + // Fallback to old single-account storage for migration + return localStorage.getItem('cinny_access_token') ?? undefined; + } + + const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); + const currentSession = sessions.find((s: Session) => s.userId === currentUserId); + + return currentSession?.accessToken; +} + +/** + * Get the access token for a specific user. + * Useful when you need to fetch data for a non-active account. + * + * @param userId - The Matrix user ID + * @returns The access token for the specified user or undefined + */ +export function getAccessTokenForUser(userId: string): string | undefined { + const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); + const session = sessions.find((s: Session) => s.userId === userId); + + return session?.accessToken; +} diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index b172bbd..dd5d4d4 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -308,20 +308,28 @@ export const isAuthenticatedMediaUrl = (url: string): boolean => * For authenticated media URLs, the access token is required. * Falls back to unauthenticated request if authenticated request fails with 401. * Returns an empty blob with error type if all attempts fail, to prevent cascading failures. + * + * @param src - The media URL to download + * @param accessToken - Optional access token (if not provided, will use current session's token) */ export const downloadMedia = async (src: string, accessToken?: string | null): Promise => { + // Import here to avoid circular dependencies + const { getCurrentAccessToken } = await import('./auth'); + + // Use provided token, or fall back to current session's token + const token = accessToken ?? getCurrentAccessToken(); const needsAuth = isAuthenticatedMediaUrl(src); const headers: HeadersInit = {}; - if (needsAuth && accessToken) { - headers.Authorization = `Bearer ${accessToken}`; + if (needsAuth && token) { + headers.Authorization = `Bearer ${token}`; } try { let res = await fetch(src, { method: 'GET', headers }); // If we got a 401 and we tried with auth, fallback to unauthenticated request - if (!res.ok && res.status === 401 && needsAuth && accessToken) { + if (!res.ok && res.status === 401 && needsAuth && token) { console.warn('[downloadMedia] Auth failed (401), attempting unauthenticated fallback for:', src); res = await fetch(src, { method: 'GET' }); } diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index b59d60e..5491bdc 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -279,6 +279,11 @@ export const initClient = async (session: Session): Promise => { // Setup handler to detect and auto-fix one-time key conflicts setupKeyConflictHandler(mx, session); + // Register this as the global MatrixClient so auth utils can access the latest token + const { setGlobalMatrixClient } = await import('../app/utils/auth'); + setGlobalMatrixClient(mx); + console.log('[initMatrix] Registered global MatrixClient for auth utils'); + return mx; } catch (error: any) { // Handle crypto store account mismatch error @@ -361,6 +366,11 @@ export const fixKeyConflict = async (mx: MatrixClient): Promise => { export const logoutClient = async (mx: MatrixClient) => { mx.stopClient(); + + // Clear the global MatrixClient reference + const { setGlobalMatrixClient } = await import('../app/utils/auth'); + setGlobalMatrixClient(null); + try { await mx.logout(); } catch { diff --git a/src/index.tsx b/src/index.tsx index 6f21fe4..5a8e727 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -32,33 +32,18 @@ if ('serviceWorker' in navigator) { navigator.serviceWorker.register(swUrl); navigator.serviceWorker.addEventListener('message', (event) => { if (event.data?.type === 'token' && event.data?.responseKey) { - // Get access token from the current session - let token: string | undefined; + // Use the centralized token utility to get the current access token + // This ensures we always use the most up-to-date token from the MatrixClient + const getCurrentAccessToken = async () => { + const { getCurrentAccessToken: getToken } = await import('./app/utils/auth'); + return getToken(); + }; - // First check the new multi-account session storage - const sessionsJson = localStorage.getItem('matrixSessions'); - const currentUserId = localStorage.getItem('currentSessionUserId'); - - if (sessionsJson && currentUserId) { - try { - const sessions = JSON.parse(sessionsJson); - const currentSession = sessions.find((s: { userId: string }) => s.userId === currentUserId); - if (currentSession?.accessToken) { - token = currentSession.accessToken; - } - } catch { - // JSON parse failed, fall through to fallback - } - } - - // Fallback to old single-account storage (for migration) - if (!token) { - token = localStorage.getItem('cinny_access_token') ?? undefined; - } - - event.source!.postMessage({ - responseKey: event.data.responseKey, - token, + getCurrentAccessToken().then(token => { + event.source!.postMessage({ + responseKey: event.data.responseKey, + token, + }); }); } });