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

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' });
}