feat(#417): add session expiry detection to AuthProvider
Adds sessionExpiring and sessionMinutesRemaining to auth context. Checks session expiry every 60s, warns when within 5 minutes. Refs #417
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React, { act } from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { AuthProvider, useAuth } from "./auth-context";
|
||||
import type { AuthUser } from "@mosaic/shared";
|
||||
@@ -11,9 +12,23 @@ vi.mock("../api/client", () => ({
|
||||
|
||||
const { apiGet, apiPost } = await import("../api/client");
|
||||
|
||||
/** Helper: returns a date far in the future (1 hour from now) for session mocks */
|
||||
function futureExpiry(): string {
|
||||
return new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
// Test component that uses the auth context
|
||||
function TestComponent(): React.JSX.Element {
|
||||
const { user, isLoading, isAuthenticated, authError, signOut } = useAuth();
|
||||
const {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
authError,
|
||||
sessionExpiring,
|
||||
sessionMinutesRemaining,
|
||||
signOut,
|
||||
refreshSession,
|
||||
} = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
@@ -23,6 +38,8 @@ function TestComponent(): React.JSX.Element {
|
||||
<div>
|
||||
<div data-testid="auth-status">{isAuthenticated ? "Authenticated" : "Not Authenticated"}</div>
|
||||
<div data-testid="auth-error">{authError ?? "none"}</div>
|
||||
<div data-testid="session-expiring">{sessionExpiring ? "true" : "false"}</div>
|
||||
<div data-testid="session-minutes-remaining">{sessionMinutesRemaining}</div>
|
||||
{user && (
|
||||
<div>
|
||||
<div data-testid="user-email">{user.email}</div>
|
||||
@@ -30,6 +47,7 @@ function TestComponent(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
<button onClick={signOut}>Sign Out</button>
|
||||
<button onClick={refreshSession}>Refresh</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +83,7 @@ describe("AuthContext", (): void => {
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: new Date() },
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -107,7 +125,7 @@ describe("AuthContext", (): void => {
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: new Date() },
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
|
||||
vi.mocked(apiPost).mockResolvedValueOnce({ success: true });
|
||||
@@ -305,7 +323,7 @@ describe("AuthContext", (): void => {
|
||||
};
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: new Date() },
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
|
||||
// Trigger a rerender (simulating refreshSession being called)
|
||||
@@ -320,4 +338,195 @@ describe("AuthContext", (): void => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session expiry detection", (): void => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
beforeEach((): void => {
|
||||
// Reset all mocks to clear any unconsumed mockResolvedValueOnce queues
|
||||
// from previous tests (vi.clearAllMocks only clears calls/results, not implementations)
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
// Ensure no stale intervals leak between tests
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it("should set sessionExpiring to false when session has plenty of time remaining", async (): Promise<void> => {
|
||||
const farFuture = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 60 minutes
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: farFuture },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("session-expiring")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
it("should set sessionExpiring to true when session is within 5 minutes of expiry", async (): Promise<void> => {
|
||||
const nearExpiry = new Date(Date.now() + 3 * 60 * 1000).toISOString(); // 3 minutes
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: nearExpiry },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("session-expiring")).toHaveTextContent("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("should calculate sessionMinutesRemaining correctly", async (): Promise<void> => {
|
||||
const nearExpiry = new Date(Date.now() + 3 * 60 * 1000).toISOString(); // 3 minutes
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: nearExpiry },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("session-minutes-remaining")).toHaveTextContent("3");
|
||||
});
|
||||
});
|
||||
|
||||
it("should transition from not-expiring to expiring after interval fires", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Session expires 6 minutes from now - just outside the warning window
|
||||
const expiresAt = new Date(Date.now() + 6 * 60 * 1000).toISOString();
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
// Flush the resolved mock promise so checkSession completes
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Initially not expiring (6 minutes remaining)
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
expect(screen.getByTestId("session-expiring")).toHaveTextContent("false");
|
||||
|
||||
// Advance 2 minutes - should now be within the 5-minute window (4 min remaining)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2 * 60 * 1000);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("session-expiring")).toHaveTextContent("true");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should log out user when session expires via interval", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Session expires 30 seconds from now
|
||||
const almostExpired = new Date(Date.now() + 30 * 1000).toISOString();
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: almostExpired },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
|
||||
// Advance past the expiry time (triggers the 60s interval)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60 * 1000);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated");
|
||||
expect(screen.getByTestId("session-expiring")).toHaveTextContent("false");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should reset sessionExpiring after successful refreshSession", async (): Promise<void> => {
|
||||
// Session near expiry (3 minutes remaining)
|
||||
const nearExpiry = new Date(Date.now() + 3 * 60 * 1000).toISOString();
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: nearExpiry },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("session-expiring")).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
// Set up a refreshed session response with a far-future expiry
|
||||
const refreshedExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-2", token: "token456", expiresAt: refreshedExpiry },
|
||||
});
|
||||
|
||||
// Click refresh button
|
||||
const refreshButton = screen.getByRole("button", { name: "Refresh" });
|
||||
refreshButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("session-expiring")).toHaveTextContent("false");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { AuthUser, AuthSession } from "@mosaic/shared";
|
||||
import { apiGet, apiPost } from "../api/client";
|
||||
|
||||
@@ -9,11 +17,19 @@ import { apiGet, apiPost } from "../api/client";
|
||||
*/
|
||||
export type AuthErrorType = "network" | "backend" | null;
|
||||
|
||||
/** Threshold in minutes before session expiry to start warning */
|
||||
const SESSION_EXPIRY_WARNING_MINUTES = 5;
|
||||
|
||||
/** Interval in milliseconds to check session expiry */
|
||||
const SESSION_CHECK_INTERVAL_MS = 60_000;
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
authError: AuthErrorType;
|
||||
sessionExpiring: boolean;
|
||||
sessionMinutesRemaining: number;
|
||||
signOut: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
}
|
||||
@@ -72,12 +88,23 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [authError, setAuthError] = useState<AuthErrorType>(null);
|
||||
const [sessionExpiring, setSessionExpiring] = useState(false);
|
||||
const [sessionMinutesRemaining, setSessionMinutesRemaining] = useState(0);
|
||||
const expiresAtRef = useRef<Date | null>(null);
|
||||
|
||||
const checkSession = useCallback(async () => {
|
||||
try {
|
||||
const session = await apiGet<AuthSession>("/auth/session");
|
||||
setUser(session.user);
|
||||
setAuthError(null);
|
||||
|
||||
// Track session expiry timestamp
|
||||
if (session.session?.expiresAt) {
|
||||
expiresAtRef.current = new Date(session.session.expiresAt);
|
||||
}
|
||||
|
||||
// Reset expiring state on successful session check
|
||||
setSessionExpiring(false);
|
||||
} catch (error) {
|
||||
const { isBackendDown, errorType } = isBackendError(error);
|
||||
|
||||
@@ -91,6 +118,8 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
expiresAtRef.current = null;
|
||||
setSessionExpiring(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -103,6 +132,8 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
|
||||
console.error("Sign out error:", error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
expiresAtRef.current = null;
|
||||
setSessionExpiring(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -114,11 +145,49 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
|
||||
void checkSession();
|
||||
}, [checkSession]);
|
||||
|
||||
// Periodically check whether the session is approaching expiry
|
||||
useEffect((): (() => void) => {
|
||||
if (!user || !expiresAtRef.current) {
|
||||
return (): void => {
|
||||
/* no-op cleanup */
|
||||
};
|
||||
}
|
||||
|
||||
const checkExpiry = (): void => {
|
||||
if (!expiresAtRef.current) return;
|
||||
|
||||
const remainingMs = expiresAtRef.current.getTime() - Date.now();
|
||||
const minutes = Math.ceil(remainingMs / 60_000);
|
||||
|
||||
if (minutes <= 0) {
|
||||
// Session has expired
|
||||
setUser(null);
|
||||
setSessionExpiring(false);
|
||||
setSessionMinutesRemaining(0);
|
||||
expiresAtRef.current = null;
|
||||
} else if (minutes <= SESSION_EXPIRY_WARNING_MINUTES) {
|
||||
setSessionExpiring(true);
|
||||
setSessionMinutesRemaining(minutes);
|
||||
} else {
|
||||
setSessionExpiring(false);
|
||||
setSessionMinutesRemaining(minutes);
|
||||
}
|
||||
};
|
||||
|
||||
checkExpiry();
|
||||
const interval = setInterval(checkExpiry, SESSION_CHECK_INTERVAL_MS);
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: user !== null,
|
||||
authError,
|
||||
sessionExpiring,
|
||||
sessionMinutesRemaining,
|
||||
signOut,
|
||||
refreshSession,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user