@stevederico/skateboard-ui 1.2.19 → 1.2.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,32 @@
1
1
  # CHANGELOG
2
+
3
+ 1.2.22
4
+
5
+ Fix SignOut CSRF header
6
+ Add CSRF retry logic
7
+ Remove redundant token parsing
8
+ Update authentication docs
9
+
10
+ ## [1.2.21] - 2026-01-22
11
+
12
+ ### Fixed
13
+ - **SignOut**: Added missing CSRF token header to signout request (critical bug that caused 403 errors)
14
+ - **Error Handling**: Added automatic retry logic for 403 CSRF validation failures with session refresh
15
+ - **Code Quality**: Removed redundant CSRF token parsing in SignInView (getCSRFToken() already handles it)
16
+
17
+ ### Changed
18
+ - `apiRequest()` now automatically recovers from CSRF token failures by refreshing the session and retrying once
19
+ - Improved error messages for CSRF-related failures
20
+
21
+ ### Documentation
22
+ - Updated Authentication.md with CSRF error handling flow
23
+
24
+ 1.2.20
25
+
26
+ Fix CSRF token reading
27
+ Read csrf_token cookie directly
28
+ Add localStorage fallback
29
+
2
30
  1.2.19
3
31
 
4
32
  Skip noLogin backend validation
package/SignInView.jsx CHANGED
@@ -52,14 +52,6 @@ export default function LoginForm({
52
52
 
53
53
  if (response.ok) {
54
54
  const data = await response.json();
55
- // Save CSRF token to localStorage for isAuthenticated() check
56
- const csrfCookie = document.cookie.split('; ').find(row => row.startsWith('csrf_token='));
57
- const csrfToken = csrfCookie ? csrfCookie.split('=')[1] : data.csrfToken;
58
- if (csrfToken) {
59
- const appName = constants.appName || 'skateboard';
60
- const csrfKey = `${appName.toLowerCase().replace(/\s+/g, '-')}_csrf`;
61
- localStorage.setItem(csrfKey, csrfToken);
62
- }
63
55
  dispatch({ type: 'SET_USER', payload: data });
64
56
  navigate('/app');
65
57
  } else {
package/SignOutView.jsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useEffect } from 'react';
2
2
  import { useNavigate } from 'react-router-dom';
3
- import { getBackendURL } from './Utilities';
3
+ import { getBackendURL, getCSRFToken } from './Utilities';
4
4
 
5
5
  function SignOutView() {
6
6
  const navigate = useNavigate();
@@ -8,10 +8,15 @@ function SignOutView() {
8
8
  useEffect(() => {
9
9
  const signOut = async () => {
10
10
  try {
11
+ const csrfToken = getCSRFToken();
11
12
  // Call backend signout endpoint
12
13
  await fetch(`${getBackendURL()}/signout`, {
13
14
  method: 'POST',
14
- credentials: 'include'
15
+ credentials: 'include',
16
+ headers: {
17
+ 'Content-Type': 'application/json',
18
+ ...(csrfToken && { 'X-CSRF-Token': csrfToken })
19
+ }
15
20
  });
16
21
  } catch (error) {
17
22
  console.error('Sign out error:', error);
package/Utilities.js CHANGED
@@ -124,10 +124,11 @@ export function getCookie(name) {
124
124
  }
125
125
 
126
126
  /**
127
- * Get CSRF token from localStorage.
127
+ * Get CSRF token from cookie or localStorage.
128
128
  *
129
- * Returns the CSRF token saved during signin/signup. This token
130
- * should be sent in the X-CSRF-Token header for state-changing requests.
129
+ * Reads CSRF token from csrf_token cookie (set by backend during signin/signup).
130
+ * Falls back to localStorage for backwards compatibility. This token should be
131
+ * sent in the X-CSRF-Token header for state-changing requests.
131
132
  *
132
133
  * @returns {string|null} CSRF token or null if not found
133
134
  *
@@ -138,6 +139,11 @@ export function getCookie(name) {
138
139
  * });
139
140
  */
140
141
  export function getCSRFToken() {
142
+ // Try cookie first (source of truth from backend)
143
+ const csrfCookie = getCookie('csrf_token');
144
+ if (csrfCookie) return csrfCookie;
145
+
146
+ // Fallback to localStorage (for backwards compatibility)
141
147
  const appName = getConstants().appName || 'skateboard';
142
148
  const csrfKey = `${appName.toLowerCase().replace(/\s+/g, '-')}_csrf`;
143
149
  return safeGetItem(csrfKey);
@@ -608,6 +614,46 @@ export async function apiRequest(endpoint, options = {}) {
608
614
  throw new Error('Unauthorized - Redirecting to Sign Out');
609
615
  }
610
616
 
617
+ // Handle 403 CSRF token failures with auto-retry
618
+ if (response.status === 403) {
619
+ const errorBody = await response.json().catch(() => ({}));
620
+
621
+ // If CSRF-related error, try to refresh session and retry once
622
+ if (errorBody.error && errorBody.error.includes('CSRF')) {
623
+ console.warn('CSRF token validation failed, attempting recovery...');
624
+
625
+ // Fetch fresh user data (triggers CSRF token regeneration on backend)
626
+ try {
627
+ await fetch(`${getBackendURL()}/me`, {
628
+ credentials: 'include'
629
+ });
630
+
631
+ // Retry original request with fresh token
632
+ const newCsrfToken = getCSRFToken();
633
+ return fetch(`${getBackendURL()}${endpoint}`, {
634
+ ...options,
635
+ credentials: 'include',
636
+ signal,
637
+ headers: {
638
+ 'Content-Type': 'application/json',
639
+ ...(needsCSRF && newCsrfToken && { 'X-CSRF-Token': newCsrfToken }),
640
+ ...options.headers
641
+ }
642
+ }).then(retryResponse => {
643
+ if (!retryResponse.ok) {
644
+ throw new Error(`Retry failed: ${retryResponse.status} ${retryResponse.statusText}`);
645
+ }
646
+ return retryResponse.json();
647
+ });
648
+ } catch (retryError) {
649
+ console.error('CSRF recovery failed:', retryError);
650
+ throw new Error('CSRF validation failed - please refresh the page');
651
+ }
652
+ }
653
+
654
+ throw new Error(`Forbidden: ${errorBody.error || response.statusText}`);
655
+ }
656
+
611
657
  // Handle other errors
612
658
  if (!response.ok) {
613
659
  throw new Error(`Request failed: ${response.status} ${response.statusText}`);
@@ -54,6 +54,27 @@ skateboard-ui uses a **hybrid cookie + localStorage authentication system** that
54
54
  - Backend validates header matches stored session CSRF token
55
55
  - Separate from session cookie to prevent cookie-based CSRF
56
56
 
57
+ ### CSRF Error Handling
58
+
59
+ The `apiRequest` utility automatically handles CSRF token failures:
60
+
61
+ 1. **Auto-Regeneration**: Backend auto-regenerates tokens after server restart
62
+ 2. **Retry Logic**: Frontend automatically retries failed requests once after refreshing the session
63
+ 3. **User Experience**: Transparent recovery without forcing sign-out or page refresh
64
+
65
+ **Error Flow**:
66
+ ```
67
+ POST /api/keys → 403 CSRF error
68
+
69
+ Fetch /me (triggers backend auto-regeneration)
70
+
71
+ Retry POST /api/keys with fresh token
72
+
73
+ Success
74
+ ```
75
+
76
+ **Note**: The retry is automatic and transparent to users. Only if the retry fails will an error be shown.
77
+
57
78
  ## Backend Requirements
58
79
 
59
80
  ### Required Endpoints
@@ -236,7 +257,7 @@ const response = await fetch(`${getBackendURL()}/protected-endpoint`, {
236
257
  credentials: 'include', // Automatically includes cookies
237
258
  headers: {
238
259
  'Content-Type': 'application/json',
239
- 'X-CSRF-Token': getCSRFToken() // From localStorage
260
+ 'X-CSRF-Token': getCSRFToken() // From cookie (with localStorage fallback)
240
261
  },
241
262
  body: JSON.stringify(data)
242
263
  });
@@ -466,14 +487,15 @@ app.listen(8000, () => {
466
487
  **Symptoms:** Protected endpoints return 403 Forbidden.
467
488
 
468
489
  **Causes:**
469
- 1. CSRF token not in localStorage
490
+ 1. CSRF token cookie missing or expired
470
491
  2. Header not being sent
471
492
  3. Token mismatch
472
493
 
473
494
  **Solutions:**
474
- - Check `localStorage.getItem('{appName}_csrf')` exists
475
- - Verify `X-CSRF-Token` header is set
476
- - Ensure CSRF cookie and localStorage token match
495
+ - Verify signin/signup set `csrf_token` cookie (check browser DevTools → Application → Cookies)
496
+ - Check `getCSRFToken()` returns a value (reads from cookie first, localStorage fallback)
497
+ - Verify `X-CSRF-Token` header is set in request
498
+ - Ensure CSRF cookie matches backend session token
477
499
  - Check CSRF token not expired (matches session lifetime)
478
500
 
479
501
  ### Cookies not persisting across requests
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stevederico/skateboard-ui",
3
3
  "private": false,
4
- "version": "1.2.19",
4
+ "version": "1.2.22",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  "./AppSidebar": {