AuthContext.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
  2. import { ApiError, api, getAuthToken, setAuthToken } from '../api/client';
  3. import type { LoginResponse, Permission, TokenPersistence, UserResponse } from '../api/client';
  4. interface AuthContextType {
  5. user: UserResponse | null;
  6. authEnabled: boolean;
  7. loading: boolean;
  8. /** A token is stored but the server could not say whether it is valid
  9. * (backend or database down). Retried in the background; the route guards
  10. * wait instead of sending a kiosk to /login, where it can't sign in (#3360). */
  11. authUnconfirmed: boolean;
  12. isAdmin: boolean;
  13. /** Login with username/password. Returns LoginResponse (may include requires_2fa). */
  14. login: (username: string, password: string, persistence?: TokenPersistence) => Promise<LoginResponse>;
  15. /** Finalise login after 2FA or OIDC — store token and set user directly. */
  16. loginWithToken: (token: string, user: UserResponse, persistence?: TokenPersistence) => void;
  17. logout: () => void;
  18. refreshAuth: () => Promise<void>;
  19. hasPermission: (permission: Permission) => boolean;
  20. hasAnyPermission: (...permissions: Permission[]) => boolean;
  21. canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
  22. }
  23. const AuthContext = createContext<AuthContextType | undefined>(undefined);
  24. export function AuthProvider({ children }: { children: React.ReactNode }) {
  25. const [user, setUser] = useState<UserResponse | null>(null);
  26. const [authEnabled, setAuthEnabled] = useState(false);
  27. const [requiresSetup, setRequiresSetup] = useState(false);
  28. const [loading, setLoading] = useState(true);
  29. const [authUnconfirmed, setAuthUnconfirmed] = useState(false);
  30. // A ?token= kiosk key not yet confirmed by the server, kept for a re-check
  31. // that confirms it after a failed first load, which persists it then.
  32. const pendingUrlTokenRef = useRef<string | null>(null);
  33. const hasRedirectedRef = useRef(false);
  34. const mountedRef = useRef(true);
  35. const checkAuthStatus = async () => {
  36. let hasStoredToken = false;
  37. try {
  38. // Bootstrap: if URL has ?token= param, store it session-only first and
  39. // strip it from the URL. Allows SpoolBuddy kiosk to pass an API key via
  40. // URL on first load. Persistence to localStorage is deferred until the
  41. // token has been verified by the server (L-4: prevents session fixation
  42. // where an attacker-crafted URL immediately persists a forged/stolen token).
  43. const urlParams = new URLSearchParams(window.location.search);
  44. // Streaming pages own their scoped URL tokens. Consuming one as a login
  45. // token removes authentication on reload and can overwrite a user session.
  46. const isStreamingPage = /^\/(?:overlay\/[^/]+|camwall)\/?$/i.test(window.location.pathname);
  47. const urlToken = isStreamingPage ? null : urlParams.get('token');
  48. if (urlToken) {
  49. setAuthToken(urlToken, 'session'); // session-only until server confirms it's valid
  50. pendingUrlTokenRef.current = urlToken;
  51. urlParams.delete('token');
  52. const cleanSearch = urlParams.toString();
  53. const cleanUrl = window.location.pathname
  54. + (cleanSearch ? `?${cleanSearch}` : '')
  55. + window.location.hash;
  56. window.history.replaceState({}, '', cleanUrl);
  57. }
  58. hasStoredToken = Boolean(getAuthToken());
  59. const status = await api.getAuthStatus();
  60. if (!mountedRef.current) return;
  61. setAuthEnabled(status.auth_enabled);
  62. setRequiresSetup(status.requires_setup);
  63. if (status.auth_enabled) {
  64. const token = getAuthToken();
  65. if (token) {
  66. // Validate the stored token. A transient failure here (backend not
  67. // yet ready after a container/proxy restart, a brief network blip)
  68. // must NOT discard a valid persisted token — doing so logs the user
  69. // out and, because the token is deleted, a reload can't recover it
  70. // (#1889). Only a definitive 401 invalid-token response clears the
  71. // token, and `request()` already does that clearing + dispatches
  72. // `auth:expired`; here we just retry the transient cases and keep the
  73. // token so the session survives a slow load.
  74. let currentUser: UserResponse | null = null;
  75. let definitiveAuthFailure = false;
  76. const maxAttempts = 3;
  77. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  78. try {
  79. currentUser = await api.getCurrentUser();
  80. break;
  81. } catch (err) {
  82. if (!mountedRef.current) return;
  83. // 401 invalid-token → genuinely logged out. `request()` has
  84. // already cleared the token; stop retrying.
  85. if (err instanceof ApiError && err.status === 401) {
  86. definitiveAuthFailure = true;
  87. break;
  88. }
  89. // Transient (network / 5xx / other) → back off and retry. Leave
  90. // the token in place so a subsequent load can recover.
  91. if (attempt < maxAttempts) {
  92. await new Promise((r) => setTimeout(r, 400 * attempt));
  93. }
  94. }
  95. }
  96. if (!mountedRef.current) return;
  97. if (currentUser) {
  98. setUser(currentUser);
  99. setAuthUnconfirmed(false);
  100. // Persist kiosk token only after the server confirms it is valid.
  101. if (urlToken && token === urlToken) {
  102. setAuthToken(urlToken, 'persistent');
  103. pendingUrlTokenRef.current = null;
  104. }
  105. } else {
  106. // No user: either a definitive 401 (token already cleared by
  107. // request()) or transient failures exhausted their retries. In the
  108. // transient case we deliberately keep the token and keep asking
  109. // in the background, so the page comes back by itself (#3360).
  110. setUser(null);
  111. if (definitiveAuthFailure) {
  112. setAuthToken(null);
  113. setAuthUnconfirmed(false);
  114. } else {
  115. setAuthUnconfirmed(true);
  116. runRecheckRef.current();
  117. }
  118. }
  119. } else {
  120. setUser(null);
  121. setAuthUnconfirmed(false);
  122. }
  123. } else {
  124. // Auth not enabled, allow access
  125. setUser(null);
  126. setAuthUnconfirmed(false);
  127. }
  128. } catch {
  129. if (!mountedRef.current) return;
  130. setUser(null);
  131. if (hasStoredToken) {
  132. // No answer at all, but a token is stored: wait for one rather than
  133. // showing the open UI or the login page (#3360).
  134. setAuthUnconfirmed(true);
  135. runRecheckRef.current();
  136. } else {
  137. setAuthEnabled(false);
  138. }
  139. } finally {
  140. if (mountedRef.current) {
  141. setLoading(false);
  142. }
  143. }
  144. };
  145. // Like checkAuthStatus, mid-session: only a definite answer changes the
  146. // state. The 4401 that asks for it can come from a database failure, which
  147. // the re-check may meet too; at first load an error shows the login or the
  148. // open UI, but here it would sign a valid tab out or switch it to "auth
  149. // off" (every admin control shown) until a reload.
  150. // Returns whether it got a definite answer.
  151. const recheckAuthStatus = async (): Promise<boolean> => {
  152. let status;
  153. try {
  154. status = await api.getAuthStatus();
  155. } catch {
  156. return false;
  157. }
  158. if (!mountedRef.current) return true;
  159. setAuthEnabled(status.auth_enabled);
  160. setRequiresSetup(status.requires_setup);
  161. if (!status.auth_enabled || !getAuthToken()) {
  162. setUser(null);
  163. return true;
  164. }
  165. try {
  166. const currentUser = await api.getCurrentUser();
  167. if (mountedRef.current) {
  168. setUser(currentUser);
  169. const pending = pendingUrlTokenRef.current;
  170. if (pending && getAuthToken() === pending) {
  171. setAuthToken(pending, 'persistent');
  172. pendingUrlTokenRef.current = null;
  173. }
  174. }
  175. return true;
  176. } catch (err) {
  177. // 401: signed out. request() clears the token only for some messages,
  178. // not "Token no longer valid" (a password change or reset) or "Token has
  179. // been revoked", so it is cleared here, as the first load does. 403: an
  180. // API key (a SpoolBuddy kiosk) whose owner is deactivated or gone; a
  181. // user's JWT gets a 401 there. Signed out too, but the key is kept, as
  182. // at first load: reactivating the owner makes it work again, and the
  183. // kiosk would otherwise need setting up again. Anything else: no
  184. // answer, keep.
  185. if (!(err instanceof ApiError) || (err.status !== 401 && err.status !== 403)) return false;
  186. if (mountedRef.current) {
  187. if (err.status === 401) setAuthToken(null);
  188. setUser(null);
  189. }
  190. return true;
  191. }
  192. };
  193. // A re-check without an answer is repeated (2 s, doubling to 60 s) until it
  194. // gets one: after a refused token mint there is no socket left to ask again.
  195. const recheckTimerRef = useRef<number | null>(null);
  196. const recheckDelayRef = useRef(0);
  197. const runRecheck = async () => {
  198. if (recheckTimerRef.current !== null) {
  199. clearTimeout(recheckTimerRef.current);
  200. recheckTimerRef.current = null;
  201. }
  202. const answered = await recheckAuthStatus();
  203. if (!mountedRef.current) return;
  204. if (answered) {
  205. recheckDelayRef.current = 0;
  206. setAuthUnconfirmed(false);
  207. return;
  208. }
  209. const delay = recheckDelayRef.current ? Math.min(recheckDelayRef.current * 2, 60000) : 2000;
  210. recheckDelayRef.current = delay;
  211. recheckTimerRef.current = window.setTimeout(() => {
  212. recheckTimerRef.current = null;
  213. runRecheck();
  214. }, delay);
  215. };
  216. // The listener below is added once; it calls the current function.
  217. const runRecheckRef = useRef(runRecheck);
  218. runRecheckRef.current = runRecheck;
  219. useEffect(() => {
  220. mountedRef.current = true;
  221. // Check auth status on mount
  222. checkAuthStatus();
  223. // Listen for token-expiry events from the API client. setAuthToken(null)
  224. // in client.ts only clears storage; without this listener, `user` stays
  225. // populated and ProtectedRoute keeps rendering the protected tree until a
  226. // manual refresh — every request silently fails in the meantime (#1698).
  227. const handleAuthExpired = () => {
  228. if (!mountedRef.current) return;
  229. setUser(null);
  230. };
  231. window.addEventListener('auth:expired', handleAuthExpired);
  232. // A WebSocket closed with 4401: authentication may have been turned on, or
  233. // this session ended, while the tab still holds the old state.
  234. const handleAuthRecheck = () => {
  235. if (!mountedRef.current) return;
  236. runRecheckRef.current();
  237. };
  238. window.addEventListener('auth:recheck', handleAuthRecheck);
  239. return () => {
  240. mountedRef.current = false;
  241. window.removeEventListener('auth:expired', handleAuthExpired);
  242. window.removeEventListener('auth:recheck', handleAuthRecheck);
  243. if (recheckTimerRef.current !== null) clearTimeout(recheckTimerRef.current);
  244. };
  245. }, []);
  246. // Separate effect to handle redirect only when setup is required
  247. useEffect(() => {
  248. // Only redirect if setup is truly required (first time setup)
  249. // Don't redirect if user manually navigated to /setup or is on camera page
  250. if (!loading && requiresSetup && !authEnabled) {
  251. const currentPath = window.location.pathname;
  252. // Only redirect if not already on setup page or camera page, and haven't redirected yet
  253. if (currentPath !== '/setup' && !currentPath.startsWith('/camera/') && !hasRedirectedRef.current) {
  254. hasRedirectedRef.current = true;
  255. window.location.href = '/setup';
  256. }
  257. } else if (!requiresSetup) {
  258. // Reset redirect flag when setup is no longer required
  259. hasRedirectedRef.current = false;
  260. }
  261. }, [loading, requiresSetup, authEnabled]);
  262. const login = async (username: string, password: string, persistence: TokenPersistence = 'session'): Promise<LoginResponse> => {
  263. const response = await api.login({ username, password });
  264. if (!response.requires_2fa && response.access_token) {
  265. setAuthToken(response.access_token, persistence);
  266. await checkAuthStatus();
  267. }
  268. return response;
  269. };
  270. const loginWithToken = (token: string, userObj: UserResponse, persistence: TokenPersistence = 'session') => {
  271. setAuthToken(token, persistence);
  272. setUser(userObj);
  273. setAuthEnabled(true);
  274. setAuthUnconfirmed(false);
  275. };
  276. const logout = () => {
  277. setAuthToken(null);
  278. setUser(null);
  279. api.logout().catch(() => {
  280. // Ignore logout errors
  281. });
  282. window.location.href = '/login';
  283. };
  284. const refreshAuth = async () => {
  285. await checkAuthStatus();
  286. };
  287. // Memoize permission set for efficient lookups
  288. const permissionSet = useMemo(() => {
  289. return new Set(user?.permissions ?? []);
  290. }, [user?.permissions]);
  291. // Computed admin status
  292. const isAdmin = useMemo(() => {
  293. if (!authEnabled) return true; // Auth disabled = admin access
  294. return user?.is_admin ?? false;
  295. }, [authEnabled, user?.is_admin]);
  296. // Permission check functions
  297. const hasPermission = useCallback((permission: Permission): boolean => {
  298. if (!authEnabled) return true; // Auth disabled = allow all
  299. if (isAdmin) return true; // Admins have all permissions
  300. return permissionSet.has(permission);
  301. }, [authEnabled, isAdmin, permissionSet]);
  302. const hasAnyPermission = useCallback((...permissions: Permission[]): boolean => {
  303. if (!authEnabled) return true;
  304. if (isAdmin) return true;
  305. return permissions.some(p => permissionSet.has(p));
  306. }, [authEnabled, isAdmin, permissionSet]);
  307. // Ownership-based permission check
  308. const canModify = useCallback((
  309. resource: 'queue' | 'archives' | 'library',
  310. action: 'update' | 'delete' | 'reprint',
  311. createdById: number | null | undefined,
  312. ): boolean => {
  313. if (!authEnabled) return true; // Auth disabled, allow all
  314. if (isAdmin) return true; // Admins can modify anything
  315. const allPerm = `${resource}:${action}_all` as Permission;
  316. const ownPerm = `${resource}:${action}_own` as Permission;
  317. // User has *_all permission - can modify any item
  318. if (permissionSet.has(allPerm)) return true;
  319. // User has *_own permission - can only modify their own items
  320. if (permissionSet.has(ownPerm)) {
  321. // Ownerless items (null created_by_id) require *_all permission
  322. if (createdById == null) return false;
  323. return createdById === user?.id;
  324. }
  325. return false;
  326. }, [authEnabled, isAdmin, permissionSet, user?.id]);
  327. return (
  328. <AuthContext.Provider
  329. value={{
  330. user,
  331. authEnabled,
  332. loading,
  333. authUnconfirmed,
  334. isAdmin,
  335. login,
  336. loginWithToken,
  337. logout,
  338. refreshAuth,
  339. hasPermission,
  340. hasAnyPermission,
  341. canModify,
  342. }}
  343. >
  344. {children}
  345. </AuthContext.Provider>
  346. );
  347. }
  348. export function useAuth() {
  349. const context = useContext(AuthContext);
  350. if (context === undefined) {
  351. throw new Error('useAuth must be used within an AuthProvider');
  352. }
  353. return context;
  354. }