@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 +28 -0
- package/SignInView.jsx +0 -8
- package/SignOutView.jsx +7 -2
- package/Utilities.js +49 -3
- package/docs/AUTHENTICATION.md +27 -5
- package/package.json +1 -1
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
|
-
*
|
|
130
|
-
*
|
|
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}`);
|
package/docs/AUTHENTICATION.md
CHANGED
|
@@ -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
|
|
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
|
-
-
|
|
475
|
-
-
|
|
476
|
-
-
|
|
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
|