feat: implement centralized access token management for media downloads and user authentication

This commit is contained in:
2026-03-23 21:53:27 +11:00
parent f76fee23bf
commit 75a9b4ca1e
16 changed files with 183 additions and 54 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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<HTML
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}`;
@@ -493,7 +495,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
const httpUrl = mxcUrlToHttp(mx, upload.mxc, useAuthentication);
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
const accessToken = mx.getAccessToken();
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
let response = await fetch(httpUrl, {
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
});

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { getCurrentAccessToken } from '../utils/auth';
/**
* Fetches media with authentication and returns a blob URL.
@@ -40,7 +41,8 @@ export const useAuthenticatedMediaUrl = (
const fetchMedia = async () => {
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

View File

@@ -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}`;

View File

@@ -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

View File

@@ -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 = {

95
src/app/utils/auth.ts Normal file
View File

@@ -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<Sessions>(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<string | undefined>(CURRENT_SESSION_KEY, undefined);
if (!currentUserId) {
// Fallback to old single-account storage for migration
return localStorage.getItem('cinny_access_token') ?? undefined;
}
const sessions = getLocalStorageItem<Sessions>(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<Sessions>(MATRIX_SESSIONS_KEY, []);
const session = sessions.find((s: Session) => s.userId === userId);
return session?.accessToken;
}

View File

@@ -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<Blob> => {
// 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' });
}

View File

@@ -279,6 +279,11 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
// 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<void> => {
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 {

View File

@@ -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,
});
});
}
});