@stevederico/skateboard-ui 1.2.17 → 1.2.18
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 +7 -0
- package/Context.jsx +52 -0
- package/README.md +90 -0
- package/Utilities.js +83 -0
- package/docs/AUTHENTICATION.md +533 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/Context.jsx
CHANGED
|
@@ -5,6 +5,22 @@ const context = createContext();
|
|
|
5
5
|
// Store dispatch reference for programmatic access outside components
|
|
6
6
|
let _dispatch = null;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Get dispatch function for programmatic state updates outside components.
|
|
10
|
+
*
|
|
11
|
+
* Useful for updating state from non-React code (event handlers, utilities).
|
|
12
|
+
* Returns null if ContextProvider hasn't mounted yet.
|
|
13
|
+
*
|
|
14
|
+
* @returns {Function|null} Dispatch function or null
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* import { getDispatch } from '@stevederico/skateboard-ui/Context';
|
|
18
|
+
*
|
|
19
|
+
* const dispatch = getDispatch();
|
|
20
|
+
* if (dispatch) {
|
|
21
|
+
* dispatch({ type: 'CLEAR_USER' });
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
8
24
|
export function getDispatch() {
|
|
9
25
|
return _dispatch;
|
|
10
26
|
}
|
|
@@ -58,6 +74,24 @@ function safeLSRemoveItem(key) {
|
|
|
58
74
|
}
|
|
59
75
|
}
|
|
60
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Global state provider for skateboard-ui.
|
|
79
|
+
*
|
|
80
|
+
* Manages user authentication state and UI visibility (sidebar, tabbar).
|
|
81
|
+
* Persists user data to localStorage using app-specific keys.
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} props
|
|
84
|
+
* @param {Object} props.constants - App configuration
|
|
85
|
+
* @param {string} props.constants.appName - Used for localStorage key namespacing
|
|
86
|
+
* @param {React.ReactNode} props.children - Child components
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* import { ContextProvider } from '@stevederico/skateboard-ui/Context';
|
|
90
|
+
*
|
|
91
|
+
* <ContextProvider constants={constants}>
|
|
92
|
+
* <App />
|
|
93
|
+
* </ContextProvider>
|
|
94
|
+
*/
|
|
61
95
|
export function ContextProvider({ children, constants }) {
|
|
62
96
|
const getStorageKey = () => {
|
|
63
97
|
const appName = constants.appName || 'skateboard';
|
|
@@ -133,6 +167,24 @@ export function ContextProvider({ children, constants }) {
|
|
|
133
167
|
);
|
|
134
168
|
}
|
|
135
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Hook to access skateboard-ui state.
|
|
172
|
+
*
|
|
173
|
+
* Returns { state, dispatch } where state contains:
|
|
174
|
+
* - user: Current user object or null
|
|
175
|
+
* - ui: { sidebarVisible, tabBarVisible }
|
|
176
|
+
*
|
|
177
|
+
* @returns {{ state: Object, dispatch: Function }}
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* import { getState } from '@stevederico/skateboard-ui/Context';
|
|
181
|
+
*
|
|
182
|
+
* function MyComponent() {
|
|
183
|
+
* const { state, dispatch } = getState();
|
|
184
|
+
* console.log(state.user);
|
|
185
|
+
* dispatch({ type: 'SET_USER', payload: userData });
|
|
186
|
+
* }
|
|
187
|
+
*/
|
|
136
188
|
export function getState() {
|
|
137
189
|
return useContext(context);
|
|
138
190
|
}
|
package/README.md
CHANGED
|
@@ -25,6 +25,92 @@ createSkateboardApp({ constants, appRoutes });
|
|
|
25
25
|
|
|
26
26
|
That's it! You get routing, auth, layout, landing page, settings, and payments.
|
|
27
27
|
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
skateboard-ui requires a `constants` object that configures your application:
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
// constants.json or constants.js
|
|
34
|
+
const constants = {
|
|
35
|
+
// Required: Backend URLs (include /api prefix)
|
|
36
|
+
devBackendURL: "http://localhost:8000/api",
|
|
37
|
+
backendURL: "https://api.myapp.com/api",
|
|
38
|
+
|
|
39
|
+
// Required: App identity
|
|
40
|
+
appName: "MyApp",
|
|
41
|
+
appIcon: "🛹",
|
|
42
|
+
|
|
43
|
+
// Required: Landing page content
|
|
44
|
+
tagline: "Build apps faster with skateboard-ui",
|
|
45
|
+
cta: "Get Started",
|
|
46
|
+
|
|
47
|
+
// Required: Features section
|
|
48
|
+
features: {
|
|
49
|
+
title: "Everything you need",
|
|
50
|
+
items: [
|
|
51
|
+
{ icon: "Zap", title: "Fast", description: "Built for speed" },
|
|
52
|
+
{ icon: "Shield", title: "Secure", description: "Authentication included" }
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// Required: Company information
|
|
57
|
+
companyName: "Your Company",
|
|
58
|
+
companyWebsite: "https://yourcompany.com",
|
|
59
|
+
companyEmail: "hello@yourcompany.com",
|
|
60
|
+
|
|
61
|
+
// Optional: Authentication
|
|
62
|
+
noLogin: false, // Set true to disable authentication
|
|
63
|
+
|
|
64
|
+
// Optional: Payments (Stripe)
|
|
65
|
+
stripeProducts: [
|
|
66
|
+
{
|
|
67
|
+
name: "Pro Plan",
|
|
68
|
+
priceId: "price_123",
|
|
69
|
+
price: "$10/month",
|
|
70
|
+
lookup_key: "pro_plan"
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
|
|
74
|
+
// Optional: Legal documents
|
|
75
|
+
termsOfService: "Your terms of service...",
|
|
76
|
+
privacyPolicy: "Your privacy policy...",
|
|
77
|
+
|
|
78
|
+
// Optional: UI visibility
|
|
79
|
+
hideSidebar: false,
|
|
80
|
+
hideTabBar: false
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Backend URL Pattern
|
|
85
|
+
|
|
86
|
+
The `devBackendURL` and `backendURL` should include your full API base path (including the `/api` prefix):
|
|
87
|
+
|
|
88
|
+
```javascript
|
|
89
|
+
const constants = {
|
|
90
|
+
devBackendURL: "http://localhost:8000/api", // Include /api prefix
|
|
91
|
+
backendURL: "https://api.myapp.com/api",
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Endpoints are relative to this base URL:
|
|
96
|
+
- `${getBackendURL()}/signup` → `http://localhost:8000/api/signup`
|
|
97
|
+
- `${getBackendURL()}/me` → `http://localhost:8000/api/me`
|
|
98
|
+
- `${getBackendURL()}/deals` → `http://localhost:8000/api/deals`
|
|
99
|
+
|
|
100
|
+
**Tip:** Include API versioning in the base URL (e.g., `/api/v2`) rather than in each endpoint path.
|
|
101
|
+
|
|
102
|
+
### Authentication Setup
|
|
103
|
+
|
|
104
|
+
skateboard-ui uses a hybrid cookie + localStorage authentication system. Your backend must implement specific endpoints and cookie handling.
|
|
105
|
+
|
|
106
|
+
**See [AUTHENTICATION.md](./docs/AUTHENTICATION.md) for complete backend setup requirements.**
|
|
107
|
+
|
|
108
|
+
Quick overview:
|
|
109
|
+
- Session token stored in HttpOnly cookie for security
|
|
110
|
+
- CSRF token in localStorage for request validation
|
|
111
|
+
- Backend validates cookies on protected endpoints
|
|
112
|
+
- Client-side `isAuthenticated()` checks localStorage for fast validation
|
|
113
|
+
|
|
28
114
|
## Components
|
|
29
115
|
|
|
30
116
|
### Core Components
|
|
@@ -253,6 +339,10 @@ import ProtectedRoute from '@stevederico/skateboard-ui/ProtectedRoute';
|
|
|
253
339
|
|
|
254
340
|
Used internally by createSkateboardApp. Redirects to /signin if not authenticated.
|
|
255
341
|
|
|
342
|
+
## Documentation
|
|
343
|
+
|
|
344
|
+
- **[Authentication Guide](./docs/AUTHENTICATION.md)** - Complete guide to the hybrid cookie + localStorage authentication system, including backend requirements, security considerations, and Express.js implementation examples
|
|
345
|
+
|
|
256
346
|
## Dependencies
|
|
257
347
|
|
|
258
348
|
- React 19.1+
|
package/Utilities.js
CHANGED
|
@@ -65,6 +65,27 @@ function safeRemoveItem(key) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Initialize skateboard-ui utilities with app constants.
|
|
70
|
+
*
|
|
71
|
+
* MUST be called before any utility functions are used, including
|
|
72
|
+
* before React components render. Stores constants in both module-level
|
|
73
|
+
* variable and window.__SKATEBOARD_CONSTANTS__ to handle Vite module
|
|
74
|
+
* duplication issues.
|
|
75
|
+
*
|
|
76
|
+
* @param {Object} constants - App configuration object
|
|
77
|
+
* @param {string} constants.appName - Application name
|
|
78
|
+
* @param {string} constants.devBackendURL - Development API base URL (include /api prefix)
|
|
79
|
+
* @param {string} constants.backendURL - Production API base URL (include /api prefix)
|
|
80
|
+
* @throws {Error} If constants is null/undefined
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* initializeUtilities({
|
|
84
|
+
* appName: "MyApp",
|
|
85
|
+
* devBackendURL: "http://localhost:8000/api",
|
|
86
|
+
* backendURL: "https://api.myapp.com/api"
|
|
87
|
+
* });
|
|
88
|
+
*/
|
|
68
89
|
export function initializeUtilities(constants) {
|
|
69
90
|
if (!constants) {
|
|
70
91
|
throw new Error('initializeUtilities called with null/undefined constants');
|
|
@@ -102,17 +123,66 @@ export function getCookie(name) {
|
|
|
102
123
|
return null;
|
|
103
124
|
}
|
|
104
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Get CSRF token from localStorage.
|
|
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.
|
|
131
|
+
*
|
|
132
|
+
* @returns {string|null} CSRF token or null if not found
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* const csrfToken = getCSRFToken();
|
|
136
|
+
* fetch('/api/endpoint', {
|
|
137
|
+
* headers: { 'X-CSRF-Token': csrfToken }
|
|
138
|
+
* });
|
|
139
|
+
*/
|
|
105
140
|
export function getCSRFToken() {
|
|
106
141
|
const appName = getConstants().appName || 'skateboard';
|
|
107
142
|
const csrfKey = `${appName.toLowerCase().replace(/\s+/g, '-')}_csrf`;
|
|
108
143
|
return safeGetItem(csrfKey);
|
|
109
144
|
}
|
|
110
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Generate app-specific localStorage key.
|
|
148
|
+
*
|
|
149
|
+
* Creates namespaced keys using app name: `{appName}_{suffix}`
|
|
150
|
+
* App name is normalized (lowercase, hyphens replace spaces)
|
|
151
|
+
*
|
|
152
|
+
* @param {string} suffix - Key suffix (e.g., 'csrf', 'user', 'theme')
|
|
153
|
+
* @returns {string} Namespaced key
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* getAppKey('csrf') // "myapp_csrf"
|
|
157
|
+
* getAppKey('user') // "myapp_user"
|
|
158
|
+
* getAppKey('theme') // "myapp_theme"
|
|
159
|
+
*/
|
|
111
160
|
export function getAppKey(suffix) {
|
|
112
161
|
const appName = getConstants().appName || 'skateboard';
|
|
113
162
|
return `${appName.toLowerCase().replace(/\s+/g, '-')}_${suffix}`;
|
|
114
163
|
}
|
|
115
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Check if user is authenticated.
|
|
167
|
+
*
|
|
168
|
+
* Uses localStorage (NOT cookies) for fast client-side validation.
|
|
169
|
+
* Checks for both CSRF token and user data in localStorage.
|
|
170
|
+
* If constants.noLogin is true, always returns true.
|
|
171
|
+
*
|
|
172
|
+
* Note: This is a client-side check only. ProtectedRoute performs
|
|
173
|
+
* additional server-side validation via /me endpoint.
|
|
174
|
+
*
|
|
175
|
+
* @returns {boolean} True if authenticated or noLogin mode
|
|
176
|
+
*
|
|
177
|
+
* @see {@link ProtectedRoute} for server-side validation
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* if (isAuthenticated()) {
|
|
181
|
+
* // Show authenticated UI
|
|
182
|
+
* } else {
|
|
183
|
+
* // Redirect to signin
|
|
184
|
+
* }
|
|
185
|
+
*/
|
|
116
186
|
export function isAuthenticated() {
|
|
117
187
|
if (getConstants().noLogin === true) {
|
|
118
188
|
return true;
|
|
@@ -122,6 +192,19 @@ export function isAuthenticated() {
|
|
|
122
192
|
return Boolean(safeGetItem(csrfKey)) && Boolean(safeGetItem(userKey));
|
|
123
193
|
}
|
|
124
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Get the backend API base URL based on environment.
|
|
197
|
+
*
|
|
198
|
+
* Returns devBackendURL in development mode, backendURL in production.
|
|
199
|
+
* Endpoints should be concatenated: `${getBackendURL()}/endpoint`
|
|
200
|
+
*
|
|
201
|
+
* @returns {string} Base URL including /api prefix
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* const url = `${getBackendURL()}/signup`;
|
|
205
|
+
* // Dev: "http://localhost:8000/api/signup"
|
|
206
|
+
* // Prod: "https://api.myapp.com/api/signup"
|
|
207
|
+
*/
|
|
125
208
|
export function getBackendURL() {
|
|
126
209
|
let result = import.meta.env.DEV ? getConstants().devBackendURL : getConstants().backendURL;
|
|
127
210
|
return result
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# Authentication Guide
|
|
2
|
+
|
|
3
|
+
## Architecture Overview
|
|
4
|
+
|
|
5
|
+
skateboard-ui uses a **hybrid cookie + localStorage authentication system** that combines security with performance:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ Authentication Flow │
|
|
10
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
11
|
+
|
|
12
|
+
Frontend Backend Storage
|
|
13
|
+
──────── ─────── ───────
|
|
14
|
+
|
|
15
|
+
1. POST /signin → Validate credentials
|
|
16
|
+
credentials
|
|
17
|
+
← Set-Cookie: {appName}_token (HttpOnly)
|
|
18
|
+
← Set-Cookie: csrf_token
|
|
19
|
+
← Response: { csrfToken, ...user }
|
|
20
|
+
|
|
21
|
+
2. Extract tokens → localStorage:
|
|
22
|
+
- CSRF from cookie {appName}_csrf
|
|
23
|
+
- User from response {appName}_user
|
|
24
|
+
|
|
25
|
+
3. isAuthenticated() → Check localStorage
|
|
26
|
+
(client-side) (fast, no network)
|
|
27
|
+
|
|
28
|
+
4. ProtectedRoute → GET /me
|
|
29
|
+
(server validation) Validate cookies
|
|
30
|
+
← 200 OK or 401 Unauthorized
|
|
31
|
+
|
|
32
|
+
5. API requests → Protected endpoints
|
|
33
|
+
+ cookies (automatic) Validate {appName}_token
|
|
34
|
+
+ X-CSRF-Token header Validate CSRF header
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## How It Works
|
|
38
|
+
|
|
39
|
+
### Cookie-Based Session Management
|
|
40
|
+
- **Session token** stored in `{appName}_token` cookie (HttpOnly, Secure, SameSite=Strict)
|
|
41
|
+
- Automatically sent with every request via browser
|
|
42
|
+
- Cannot be accessed by JavaScript (XSS protection)
|
|
43
|
+
- Backend validates cookie on each protected endpoint
|
|
44
|
+
|
|
45
|
+
### localStorage for Client-Side Validation
|
|
46
|
+
- **CSRF token** and **user data** stored in localStorage
|
|
47
|
+
- Enables instant `isAuthenticated()` checks without network calls
|
|
48
|
+
- Used by client-side routing logic (ProtectedRoute initial check)
|
|
49
|
+
- Not used for actual authentication (cookies handle that)
|
|
50
|
+
|
|
51
|
+
### CSRF Protection
|
|
52
|
+
- Dual-token system prevents CSRF attacks
|
|
53
|
+
- **CSRF token** sent in `X-CSRF-Token` header with state-changing requests
|
|
54
|
+
- Backend validates header matches stored session CSRF token
|
|
55
|
+
- Separate from session cookie to prevent cookie-based CSRF
|
|
56
|
+
|
|
57
|
+
## Backend Requirements
|
|
58
|
+
|
|
59
|
+
### Required Endpoints
|
|
60
|
+
|
|
61
|
+
#### POST /signup
|
|
62
|
+
Create new user account.
|
|
63
|
+
|
|
64
|
+
**Request:**
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"email": "user@example.com",
|
|
68
|
+
"password": "securePassword123"
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Response:**
|
|
73
|
+
- Status: 201 Created
|
|
74
|
+
- Headers:
|
|
75
|
+
- `Set-Cookie: {appName}_token={sessionToken}; HttpOnly; Secure; SameSite=Strict; Path=/`
|
|
76
|
+
- `Set-Cookie: csrf_token={csrfToken}; Secure; SameSite=Lax; Path=/`
|
|
77
|
+
- Body:
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"csrfToken": "csrf_abc123...",
|
|
81
|
+
"user": {
|
|
82
|
+
"id": "user123",
|
|
83
|
+
"email": "user@example.com",
|
|
84
|
+
"name": "John Doe"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### POST /signin
|
|
90
|
+
Authenticate existing user.
|
|
91
|
+
|
|
92
|
+
**Request:**
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"email": "user@example.com",
|
|
96
|
+
"password": "securePassword123"
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Response:**
|
|
101
|
+
- Status: 200 OK
|
|
102
|
+
- Headers: Same as /signup
|
|
103
|
+
- Body: Same as /signup
|
|
104
|
+
|
|
105
|
+
#### GET /me
|
|
106
|
+
Validate current session and return user data.
|
|
107
|
+
|
|
108
|
+
**Request:**
|
|
109
|
+
- Headers: Cookies automatically sent by browser
|
|
110
|
+
|
|
111
|
+
**Response (authenticated):**
|
|
112
|
+
- Status: 200 OK
|
|
113
|
+
- Body:
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"user": {
|
|
117
|
+
"id": "user123",
|
|
118
|
+
"email": "user@example.com",
|
|
119
|
+
"name": "John Doe"
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Response (not authenticated):**
|
|
125
|
+
- Status: 401 Unauthorized
|
|
126
|
+
|
|
127
|
+
#### POST /signout
|
|
128
|
+
End current session.
|
|
129
|
+
|
|
130
|
+
**Request:**
|
|
131
|
+
- Headers:
|
|
132
|
+
- Cookies automatically sent
|
|
133
|
+
- `X-CSRF-Token: {csrfToken}`
|
|
134
|
+
|
|
135
|
+
**Response:**
|
|
136
|
+
- Status: 200 OK
|
|
137
|
+
- Headers:
|
|
138
|
+
- `Set-Cookie: {appName}_token=; Max-Age=0; Path=/` (clear cookie)
|
|
139
|
+
- `Set-Cookie: csrf_token=; Max-Age=0; Path=/` (clear cookie)
|
|
140
|
+
|
|
141
|
+
### Cookie Configuration
|
|
142
|
+
|
|
143
|
+
**Session Token Cookie:**
|
|
144
|
+
```javascript
|
|
145
|
+
{
|
|
146
|
+
name: '{appName}_token',
|
|
147
|
+
httpOnly: true, // Prevents JavaScript access (XSS protection)
|
|
148
|
+
secure: true, // HTTPS only (production)
|
|
149
|
+
sameSite: 'Strict', // Strongest CSRF protection
|
|
150
|
+
path: '/',
|
|
151
|
+
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days (configurable)
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**CSRF Token Cookie:**
|
|
156
|
+
```javascript
|
|
157
|
+
{
|
|
158
|
+
name: 'csrf_token',
|
|
159
|
+
httpOnly: false, // Must be readable by JavaScript
|
|
160
|
+
secure: true, // HTTPS only (production)
|
|
161
|
+
sameSite: 'Lax', // Allow top-level navigation
|
|
162
|
+
path: '/',
|
|
163
|
+
maxAge: 7 * 24 * 60 * 60 * 1000 // Match session token
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Protected Endpoints
|
|
168
|
+
All authenticated endpoints must:
|
|
169
|
+
1. Validate `{appName}_token` cookie exists and is valid
|
|
170
|
+
2. For state-changing operations (POST, PUT, DELETE), validate `X-CSRF-Token` header
|
|
171
|
+
3. Return 401 if authentication fails
|
|
172
|
+
4. Return 403 if CSRF validation fails
|
|
173
|
+
|
|
174
|
+
## Frontend Flow
|
|
175
|
+
|
|
176
|
+
### 1. User Signs In
|
|
177
|
+
```javascript
|
|
178
|
+
// SignInView.jsx
|
|
179
|
+
const response = await fetch(`${getBackendURL()}/signin`, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
credentials: 'include', // Send and receive cookies
|
|
182
|
+
headers: { 'Content-Type': 'application/json' },
|
|
183
|
+
body: JSON.stringify({ email, password })
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const data = await response.json();
|
|
187
|
+
// Backend has set cookies automatically
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 2. Store CSRF Token and User Data
|
|
191
|
+
```javascript
|
|
192
|
+
// Extract CSRF token from cookie or response body
|
|
193
|
+
const csrfToken = getCookie('csrf_token') || data.csrfToken;
|
|
194
|
+
|
|
195
|
+
// Save to localStorage with app-specific keys
|
|
196
|
+
localStorage.setItem(`${appName}_csrf`, csrfToken);
|
|
197
|
+
localStorage.setItem(`${appName}_user`, JSON.stringify(data.user));
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### 3. Client-Side Authentication Check
|
|
201
|
+
```javascript
|
|
202
|
+
// Utilities.js:isAuthenticated()
|
|
203
|
+
export function isAuthenticated() {
|
|
204
|
+
const csrf = localStorage.getItem(getAppKey('csrf'));
|
|
205
|
+
const user = localStorage.getItem(getAppKey('user'));
|
|
206
|
+
return !!(csrf && user);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Usage in components
|
|
210
|
+
if (isAuthenticated()) {
|
|
211
|
+
// Show authenticated UI
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### 4. Server-Side Validation
|
|
216
|
+
```javascript
|
|
217
|
+
// ProtectedRoute.jsx
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
fetch(`${getBackendURL()}/me`, {
|
|
220
|
+
credentials: 'include' // Sends session cookie
|
|
221
|
+
})
|
|
222
|
+
.then(response => {
|
|
223
|
+
if (response.status === 401) {
|
|
224
|
+
// Not authenticated - redirect to signin
|
|
225
|
+
navigate('/signin');
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}, []);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### 5. Making Authenticated Requests
|
|
232
|
+
```javascript
|
|
233
|
+
// All API requests include cookies and CSRF token
|
|
234
|
+
const response = await fetch(`${getBackendURL()}/protected-endpoint`, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
credentials: 'include', // Automatically includes cookies
|
|
237
|
+
headers: {
|
|
238
|
+
'Content-Type': 'application/json',
|
|
239
|
+
'X-CSRF-Token': getCSRFToken() // From localStorage
|
|
240
|
+
},
|
|
241
|
+
body: JSON.stringify(data)
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Security Considerations
|
|
246
|
+
|
|
247
|
+
### XSS Protection
|
|
248
|
+
- **Session token is HttpOnly** - JavaScript cannot access it
|
|
249
|
+
- Even if attacker injects malicious script, they cannot steal session token
|
|
250
|
+
- CSRF token in localStorage is less sensitive (validated server-side)
|
|
251
|
+
|
|
252
|
+
### CSRF Protection
|
|
253
|
+
- **Dual-token pattern** prevents cookie-based CSRF attacks
|
|
254
|
+
- Attacker cannot forge `X-CSRF-Token` header from external site
|
|
255
|
+
- Cookie's SameSite attribute provides additional protection
|
|
256
|
+
|
|
257
|
+
### SameSite Cookie Policy
|
|
258
|
+
- **Session token (Strict)** - Never sent on cross-site requests
|
|
259
|
+
- **CSRF token (Lax)** - Sent on top-level navigation (allows direct links)
|
|
260
|
+
- Provides strong CSRF protection at browser level
|
|
261
|
+
|
|
262
|
+
### LocalStorage Trade-offs
|
|
263
|
+
- **Vulnerable to XSS** - If site has XSS vulnerability, localStorage can be read
|
|
264
|
+
- **Acceptable for CSRF token** - CSRF token alone cannot authenticate requests
|
|
265
|
+
- **Never store session token in localStorage** - Always use HttpOnly cookies
|
|
266
|
+
|
|
267
|
+
### HTTPS Requirement
|
|
268
|
+
- All cookies marked `Secure` - only sent over HTTPS in production
|
|
269
|
+
- Development mode (HTTP) may need `Secure: false` based on environment
|
|
270
|
+
|
|
271
|
+
## Example Backend Implementation (Express.js)
|
|
272
|
+
|
|
273
|
+
```javascript
|
|
274
|
+
import express from 'express';
|
|
275
|
+
import cookieParser from 'cookie-parser';
|
|
276
|
+
import crypto from 'crypto';
|
|
277
|
+
|
|
278
|
+
const app = express();
|
|
279
|
+
app.use(express.json());
|
|
280
|
+
app.use(cookieParser());
|
|
281
|
+
|
|
282
|
+
// In-memory session store (use Redis in production)
|
|
283
|
+
const sessions = new Map();
|
|
284
|
+
|
|
285
|
+
// Generate secure random token
|
|
286
|
+
function generateToken() {
|
|
287
|
+
return crypto.randomBytes(32).toString('hex');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Middleware: Validate session token
|
|
291
|
+
function requireAuth(req, res, next) {
|
|
292
|
+
const sessionToken = req.cookies.myapp_token;
|
|
293
|
+
const session = sessions.get(sessionToken);
|
|
294
|
+
|
|
295
|
+
if (!session) {
|
|
296
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
req.session = session;
|
|
300
|
+
next();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Middleware: Validate CSRF token
|
|
304
|
+
function requireCSRF(req, res, next) {
|
|
305
|
+
const csrfToken = req.headers['x-csrf-token'];
|
|
306
|
+
|
|
307
|
+
if (!req.session || req.session.csrfToken !== csrfToken) {
|
|
308
|
+
return res.status(403).json({ error: 'Invalid CSRF token' });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
next();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// POST /api/signup
|
|
315
|
+
app.post('/api/signup', async (req, res) => {
|
|
316
|
+
const { email, password } = req.body;
|
|
317
|
+
|
|
318
|
+
// Validate input
|
|
319
|
+
if (!email || !password) {
|
|
320
|
+
return res.status(400).json({ error: 'Email and password required' });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Create user (pseudo-code)
|
|
324
|
+
const user = await createUser(email, password);
|
|
325
|
+
|
|
326
|
+
// Create session
|
|
327
|
+
const sessionToken = generateToken();
|
|
328
|
+
const csrfToken = generateToken();
|
|
329
|
+
|
|
330
|
+
sessions.set(sessionToken, {
|
|
331
|
+
userId: user.id,
|
|
332
|
+
csrfToken,
|
|
333
|
+
createdAt: Date.now()
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Set cookies
|
|
337
|
+
res.cookie('myapp_token', sessionToken, {
|
|
338
|
+
httpOnly: true,
|
|
339
|
+
secure: process.env.NODE_ENV === 'production',
|
|
340
|
+
sameSite: 'strict',
|
|
341
|
+
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
res.cookie('csrf_token', csrfToken, {
|
|
345
|
+
httpOnly: false, // Readable by JavaScript
|
|
346
|
+
secure: process.env.NODE_ENV === 'production',
|
|
347
|
+
sameSite: 'lax',
|
|
348
|
+
maxAge: 7 * 24 * 60 * 60 * 1000
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Return user data and CSRF token
|
|
352
|
+
res.status(201).json({
|
|
353
|
+
csrfToken,
|
|
354
|
+
user: {
|
|
355
|
+
id: user.id,
|
|
356
|
+
email: user.email,
|
|
357
|
+
name: user.name
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// POST /api/signin
|
|
363
|
+
app.post('/api/signin', async (req, res) => {
|
|
364
|
+
const { email, password } = req.body;
|
|
365
|
+
|
|
366
|
+
// Validate credentials (pseudo-code)
|
|
367
|
+
const user = await validateCredentials(email, password);
|
|
368
|
+
|
|
369
|
+
if (!user) {
|
|
370
|
+
return res.status(401).json({ error: 'Invalid credentials' });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Create session (same as signup)
|
|
374
|
+
const sessionToken = generateToken();
|
|
375
|
+
const csrfToken = generateToken();
|
|
376
|
+
|
|
377
|
+
sessions.set(sessionToken, {
|
|
378
|
+
userId: user.id,
|
|
379
|
+
csrfToken,
|
|
380
|
+
createdAt: Date.now()
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Set cookies (same as signup)
|
|
384
|
+
res.cookie('myapp_token', sessionToken, {
|
|
385
|
+
httpOnly: true,
|
|
386
|
+
secure: process.env.NODE_ENV === 'production',
|
|
387
|
+
sameSite: 'strict',
|
|
388
|
+
maxAge: 7 * 24 * 60 * 60 * 1000
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
res.cookie('csrf_token', csrfToken, {
|
|
392
|
+
httpOnly: false,
|
|
393
|
+
secure: process.env.NODE_ENV === 'production',
|
|
394
|
+
sameSite: 'lax',
|
|
395
|
+
maxAge: 7 * 24 * 60 * 60 * 1000
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
res.json({
|
|
399
|
+
csrfToken,
|
|
400
|
+
user: {
|
|
401
|
+
id: user.id,
|
|
402
|
+
email: user.email,
|
|
403
|
+
name: user.name
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// GET /api/me
|
|
409
|
+
app.get('/api/me', requireAuth, async (req, res) => {
|
|
410
|
+
// Get user from session
|
|
411
|
+
const user = await getUserById(req.session.userId);
|
|
412
|
+
|
|
413
|
+
res.json({
|
|
414
|
+
user: {
|
|
415
|
+
id: user.id,
|
|
416
|
+
email: user.email,
|
|
417
|
+
name: user.name
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// POST /api/signout
|
|
423
|
+
app.post('/api/signout', requireAuth, requireCSRF, (req, res) => {
|
|
424
|
+
const sessionToken = req.cookies.myapp_token;
|
|
425
|
+
|
|
426
|
+
// Delete session
|
|
427
|
+
sessions.delete(sessionToken);
|
|
428
|
+
|
|
429
|
+
// Clear cookies
|
|
430
|
+
res.clearCookie('myapp_token');
|
|
431
|
+
res.clearCookie('csrf_token');
|
|
432
|
+
|
|
433
|
+
res.json({ message: 'Signed out successfully' });
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Protected endpoint example
|
|
437
|
+
app.post('/api/protected-action', requireAuth, requireCSRF, (req, res) => {
|
|
438
|
+
// Handle protected action
|
|
439
|
+
res.json({ message: 'Action completed' });
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
app.listen(8000, () => {
|
|
443
|
+
console.log('Server running on http://localhost:8000');
|
|
444
|
+
});
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
## Troubleshooting
|
|
448
|
+
|
|
449
|
+
### "Not authenticated" after signing in
|
|
450
|
+
|
|
451
|
+
**Symptoms:** User signs in successfully but immediately redirected to signin page.
|
|
452
|
+
|
|
453
|
+
**Causes:**
|
|
454
|
+
1. Cookies not being sent with requests
|
|
455
|
+
2. Cookie domain/path mismatch
|
|
456
|
+
3. CORS blocking cookies
|
|
457
|
+
|
|
458
|
+
**Solutions:**
|
|
459
|
+
- Verify `credentials: 'include'` in all fetch calls
|
|
460
|
+
- Check cookie `domain` and `path` settings
|
|
461
|
+
- Ensure CORS allows credentials: `Access-Control-Allow-Credentials: true`
|
|
462
|
+
- Verify `Access-Control-Allow-Origin` is specific origin, not `*`
|
|
463
|
+
|
|
464
|
+
### CSRF validation failing
|
|
465
|
+
|
|
466
|
+
**Symptoms:** Protected endpoints return 403 Forbidden.
|
|
467
|
+
|
|
468
|
+
**Causes:**
|
|
469
|
+
1. CSRF token not in localStorage
|
|
470
|
+
2. Header not being sent
|
|
471
|
+
3. Token mismatch
|
|
472
|
+
|
|
473
|
+
**Solutions:**
|
|
474
|
+
- Check `localStorage.getItem('{appName}_csrf')` exists
|
|
475
|
+
- Verify `X-CSRF-Token` header is set
|
|
476
|
+
- Ensure CSRF cookie and localStorage token match
|
|
477
|
+
- Check CSRF token not expired (matches session lifetime)
|
|
478
|
+
|
|
479
|
+
### Cookies not persisting across requests
|
|
480
|
+
|
|
481
|
+
**Symptoms:** User authenticated on signin but /me returns 401.
|
|
482
|
+
|
|
483
|
+
**Causes:**
|
|
484
|
+
1. SameSite policy blocking cookies
|
|
485
|
+
2. Secure flag on HTTP (development)
|
|
486
|
+
3. Domain mismatch
|
|
487
|
+
|
|
488
|
+
**Solutions:**
|
|
489
|
+
- Development: Set `secure: false` when `NODE_ENV !== 'production'`
|
|
490
|
+
- Check frontend and backend on same domain (or use proxy)
|
|
491
|
+
- Verify SameSite policy allows your use case
|
|
492
|
+
|
|
493
|
+
### LocalStorage cleared unexpectedly
|
|
494
|
+
|
|
495
|
+
**Symptoms:** `isAuthenticated()` returns false but session cookie exists.
|
|
496
|
+
|
|
497
|
+
**Causes:**
|
|
498
|
+
1. User cleared browser data
|
|
499
|
+
2. localStorage quota exceeded
|
|
500
|
+
3. Private browsing mode
|
|
501
|
+
|
|
502
|
+
**Solutions:**
|
|
503
|
+
- Re-fetch user data from `/me` endpoint
|
|
504
|
+
- Implement session restoration from cookie
|
|
505
|
+
- Handle `isAuthenticated()` false as trigger to validate with backend
|
|
506
|
+
|
|
507
|
+
### Session token stolen (security incident)
|
|
508
|
+
|
|
509
|
+
**Symptoms:** User receives "already logged in" errors or unauthorized actions.
|
|
510
|
+
|
|
511
|
+
**Response:**
|
|
512
|
+
1. Revoke all sessions for affected user
|
|
513
|
+
2. Force password reset
|
|
514
|
+
3. Audit recent account activity
|
|
515
|
+
4. Investigate XSS vulnerabilities if token was HttpOnly
|
|
516
|
+
5. Check for MITM attack if token was Secure
|
|
517
|
+
|
|
518
|
+
## No-Login Mode
|
|
519
|
+
|
|
520
|
+
For development or public-only apps, disable authentication:
|
|
521
|
+
|
|
522
|
+
```javascript
|
|
523
|
+
const constants = {
|
|
524
|
+
noLogin: true,
|
|
525
|
+
// ... other constants
|
|
526
|
+
};
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Effects:
|
|
530
|
+
- `isAuthenticated()` always returns `true`
|
|
531
|
+
- ProtectedRoute allows all access
|
|
532
|
+
- Signin/signup views still render but aren't enforced
|
|
533
|
+
- No authentication state management
|