Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ export async function apiClient<T>(
try {
const refreshResponse = await refreshTokens();
tokenStorage.setAccessToken(refreshResponse.accessToken);
notifyTokenRefreshed(refreshResponse.accessToken);
// Forward the fresh user too so AuthContext updates both the user
// object and isAuthenticated, not just the stored token (#560).
notifyTokenRefreshed(refreshResponse.accessToken, refreshResponse.user);
// Retry the original request with hasTriedRefresh = true
return attemptRequest(attempt, true);
} catch (refreshError) {
Expand Down
18 changes: 13 additions & 5 deletions frontend/src/api/tokens.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import type { User } from './types';

const ACCESS_TOKEN_KEY = 'accessToken';

// Called by apiClient after a silent token refresh so AuthContext can stay in sync.
let _onTokenRefreshed: ((accessToken: string) => void) | null = null;
export const setTokenRefreshCallback = (cb: (accessToken: string) => void) => {
/**
* Callback invoked after `apiClient` silently refreshes the access token, so
* AuthContext can keep its reactive `isAuthenticated` / `user` state in sync.
* The fresh `user` from the refresh response is forwarded when available.
*/
type TokenRefreshCallback = (accessToken: string, user?: User | null) => void;

let _onTokenRefreshed: TokenRefreshCallback | null = null;
export const setTokenRefreshCallback = (cb: TokenRefreshCallback) => {
_onTokenRefreshed = cb;
};
export const notifyTokenRefreshed = (accessToken: string) => {
_onTokenRefreshed?.(accessToken);
export const notifyTokenRefreshed = (accessToken: string, user?: User | null) => {
_onTokenRefreshed?.(accessToken, user);
};

export const tokenStorage = {
Expand Down
104 changes: 104 additions & 0 deletions frontend/src/context/AuthContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React from 'react';
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { AuthProvider, useAuth } from './AuthContext';
import { tokenStorage, notifyTokenRefreshed } from '../api/tokens';
import { User, UserRole } from '../api/types';

/** Build a JWT-shaped token whose `exp` is `offsetSec` from now. */
const makeToken = (offsetSec: number): string => {
const payload = btoa(
JSON.stringify({ exp: Math.floor(Date.now() / 1000) + offsetSec }),
);
return `header.${payload}.signature`;
};

const sampleUser: User = {
id: '1',
email: 'alice@example.com',
firstName: 'Alice',
lastName: 'Doe',
role: UserRole.USER,
};

const Consumer: React.FC = () => {
const { isAuthenticated, user } = useAuth();
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="user">{user?.email ?? 'none'}</span>
</div>
);
};

const renderAuth = () =>
render(
<AuthProvider>
<Consumer />
</AuthProvider>,
);

beforeEach(() => {
localStorage.clear();
});

describe('AuthContext silent token refresh (#560)', () => {
it('flips isAuthenticated to true and sets the user after a silent refresh', () => {
// Start unauthenticated (no token, no user).
renderAuth();
expect(screen.getByTestId('auth').textContent).toBe('false');
expect(screen.getByTestId('user').textContent).toBe('none');

// Simulate apiClient's background refresh writing a fresh token + user.
act(() => {
tokenStorage.setAccessToken(makeToken(3600));
notifyTokenRefreshed(makeToken(3600), sampleUser);
});

// Context updates immediately — no waiting for the 5-minute expiry check.
expect(screen.getByTestId('auth').textContent).toBe('true');
expect(screen.getByTestId('user').textContent).toBe('alice@example.com');
});

it('updates the user object from the refresh response', () => {
localStorage.setItem('user', JSON.stringify(sampleUser));
tokenStorage.setAccessToken(makeToken(3600));

renderAuth();
expect(screen.getByTestId('user').textContent).toBe('alice@example.com');

const updated: User = { ...sampleUser, email: 'alice.new@example.com' };
act(() => {
tokenStorage.setAccessToken(makeToken(3600));
notifyTokenRefreshed(makeToken(3600), updated);
});

expect(screen.getByTestId('user').textContent).toBe('alice.new@example.com');
expect(screen.getByTestId('auth').textContent).toBe('true');
});

it('keeps isAuthenticated true when a refresh carries no user (token only)', () => {
localStorage.setItem('user', JSON.stringify(sampleUser));
tokenStorage.setAccessToken(makeToken(-100)); // expired going in

renderAuth();
// Mount cleared the expired token, so we are unauthenticated.
expect(screen.getByTestId('auth').textContent).toBe('false');

// A refresh without a user should still re-authenticate if a user exists...
// here there is no user (it was cleared), so it stays false but does not throw.
act(() => {
notifyTokenRefreshed(makeToken(3600));
});
expect(screen.getByTestId('auth').textContent).toBe('false');
});

it('ignores a refreshed token that is already expired', () => {
renderAuth();
act(() => {
notifyTokenRefreshed(makeToken(-100), sampleUser);
});
// Expired token must not authenticate.
expect(screen.getByTestId('auth').textContent).toBe('false');
});
});
42 changes: 42 additions & 0 deletions frontend/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
});
const [isLoading, setIsLoading] = useState<boolean>(true);
// Track the access token in React state so `isAuthenticated` is reactive.
// Reading `tokenStorage` directly during render would not re-render when a
// silent refresh writes a new token, leaving consumers with stale auth state.
const [accessToken, setAccessTokenState] = useState<string | null>(() =>
tokenStorage.getAccessToken(),
);

// #559 — derive isAuthenticated once per token/user change, not on every render
const isAuthenticated = useMemo(() => {
Expand All @@ -50,23 +56,57 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
useEffect(() => {
// Check token expiration on app load
const checkTokenExpiration = () => {
const currentToken = tokenStorage.getAccessToken();

if (currentToken && isTokenExpired(currentToken)) {
// Token is expired, clear auth state
const accessToken = tokenStorage.getAccessToken();

if (accessToken && isTokenExpired(accessToken)) {
console.warn('Access token expired, clearing authentication state');
tokenStorage.clearTokens();
setUserState(null);
setAccessTokenState(null);
localStorage.removeItem('user');
} else if (!currentToken) {
// No token, clear user state
} else if (!accessToken) {
setUserState(null);
setAccessTokenState(null);
localStorage.removeItem('user');
} else {
// Valid token — keep reactive state in sync with storage.
setAccessTokenState(currentToken);
}

setIsLoading(false);
};

checkTokenExpiration();

// Keep AuthContext in sync when apiClient silently refreshes the access
// token. Updating the reactive token (and user, when the refresh response
// carries one) re-renders consumers so `isAuthenticated` reflects the new
// valid token immediately, instead of waiting for the 5-minute check (#560).
setTokenRefreshCallback((newAccessToken, refreshedUser) => {
if (isTokenExpired(newAccessToken)) {
return;
}
setAccessTokenState(newAccessToken);
if (refreshedUser) {
setUserState(refreshedUser);
}
setIsLoading(false);
});

// Set up periodic token expiration check (every 5 minutes)
const interval = setInterval(checkTokenExpiration, 5 * 60 * 1000);

return () => {
clearInterval(interval);
// Drop the callback so a torn-down provider can't update stale state.
setTokenRefreshCallback(() => {});
};
const interval = setInterval(checkTokenExpiration, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
Expand All @@ -87,13 +127,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>

localStorage.removeItem('user');
tokenStorage.clearTokens();
setAccessTokenState(null);
}, [user]);

const setUser = (nextUser: User | null) => setUserState(nextUser);

const clearAuth = () => {
setUserState(null);
tokenStorage.clearTokens();
setAccessTokenState(null);
localStorage.removeItem('user');
};

Expand Down