bro-auth 0.1.4 → 0.2.0
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/README.md +595 -221
- package/dist/browser.cjs +650 -16
- package/dist/browser.js +650 -16
- package/dist/index.cjs +24 -12
- package/dist/index.js +24 -11
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,169 +1,231 @@
|
|
|
1
1
|
# bro-auth
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
5
|
-
│ █▄▄ █▀█ █▀█ █▀█ █ █ ▀█▀ █ █ bro-auth │
|
|
6
|
-
│ █▄█ █▀▄ █▄█ █▀█ █▄█ █ █▀█ │
|
|
7
|
-
├──────────────────────────────────────────────────────────────┤
|
|
8
|
-
│ Stateless JWT · Device Fingerprinting · Zero Replay │
|
|
9
|
-
└──────────────────────────────────────────────────────────────┘
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
A lightweight, **stateless**, and **high-security** authentication layer that combines JWT tokens with device fingerprinting to prevent token theft and replay attacks—no database required.
|
|
3
|
+
**Stateless JWT authentication with device fingerprint binding and derived signing keys.**
|
|
13
4
|
|
|
14
5
|
[](https://www.npmjs.com/package/bro-auth)
|
|
15
6
|
[](https://opensource.org/licenses/MIT)
|
|
16
7
|
|
|
17
8
|
---
|
|
18
9
|
|
|
19
|
-
##
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
`bro-auth` is a Node.js library that binds JWT tokens to browser devices using fingerprinting and derives signing keys from application-provided secrets. This prevents token replay attacks and limits the impact of token theft.
|
|
13
|
+
|
|
14
|
+
**What bro-auth does:**
|
|
15
|
+
- Generates and verifies JWT tokens bound to device fingerprints
|
|
16
|
+
- Derives unique signing secrets per user-device combination using HMAC
|
|
17
|
+
- Provides browser fingerprinting utilities
|
|
18
|
+
|
|
19
|
+
**What bro-auth does NOT do:**
|
|
20
|
+
- Manage secrets or environment variables (your application's responsibility)
|
|
21
|
+
- Prevent XSS attacks (your application's responsibility)
|
|
22
|
+
- Handle login UI or session management (your application's responsibility)
|
|
23
|
+
|
|
24
|
+
---
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
## Why Use bro-auth?
|
|
22
27
|
|
|
23
|
-
|
|
28
|
+
Traditional JWT tokens work from any device if stolen. `bro-auth` binds tokens to specific devices, making stolen tokens unusable on different browsers.
|
|
24
29
|
|
|
25
|
-
###
|
|
30
|
+
### Attack Mitigation
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
For an attacker to successfully use a stolen token, they would need:
|
|
33
|
+
|
|
34
|
+
1. The JWT token itself (stolen via XSS, network interception, etc.)
|
|
35
|
+
2. The user ID (embedded in token)
|
|
36
|
+
3. The application's access/refresh secrets
|
|
37
|
+
4. The exact device fingerprint hash (SHA-256)
|
|
38
|
+
5. The application's server-only pepper (`BRO_AUTH_SECRET_PEPPER`)
|
|
39
|
+
|
|
40
|
+
Even with items 1-4, the attacker cannot:
|
|
41
|
+
- Use the token from a different device (fingerprint mismatch fails verification)
|
|
42
|
+
- Forge new tokens (requires the server-only pepper)
|
|
43
|
+
- Replay the token (derived secret verification fails)
|
|
44
|
+
|
|
45
|
+
### Security Scope & Limitations
|
|
46
|
+
|
|
47
|
+
**What bro-auth protects against:**
|
|
48
|
+
- Token replay from different devices
|
|
49
|
+
- Token forgery without server secrets
|
|
50
|
+
- Credential stuffing across devices
|
|
51
|
+
|
|
52
|
+
**What bro-auth does NOT protect against:**
|
|
53
|
+
- XSS attacks during active execution (attacker can make requests while JS runs)
|
|
54
|
+
- Full server compromise (if secrets are leaked, all bets are off)
|
|
55
|
+
- Social engineering or phishing attacks
|
|
56
|
+
- Browser fingerprint spoofing (though difficult, it's theoretically possible)
|
|
57
|
+
|
|
58
|
+
**Your application must:**
|
|
59
|
+
- Implement XSS prevention (CSP, input sanitization, etc.)
|
|
60
|
+
- Store tokens securely (HTTP-only cookies for refresh tokens)
|
|
61
|
+
- Protect environment variables and secrets
|
|
62
|
+
- Use HTTPS in production
|
|
34
63
|
|
|
35
64
|
---
|
|
36
65
|
|
|
37
|
-
##
|
|
66
|
+
## Installation
|
|
38
67
|
|
|
39
68
|
```bash
|
|
40
69
|
npm install bro-auth
|
|
41
70
|
```
|
|
42
71
|
|
|
43
|
-
|
|
44
|
-
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## How It Works
|
|
75
|
+
|
|
76
|
+
### 1. Authentication Flow
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
Browser Server
|
|
80
|
+
│
|
|
81
|
+
├─ Generate fingerprint hash
|
|
82
|
+
│ (Canvas, GPU, User-Agent, etc.)
|
|
83
|
+
│
|
|
84
|
+
├─ POST /login ────────────────────────▶ Verify credentials
|
|
85
|
+
│ { username, password, fpHash } │
|
|
86
|
+
│ ├─ Derive signing secret:
|
|
87
|
+
│ │ HMAC(pepper, secret|userId|fpHash)
|
|
88
|
+
│ │
|
|
89
|
+
│ ├─ Sign JWT with derived secret
|
|
90
|
+
│ │ Payload: { sub: userId, fp: fpHash }
|
|
91
|
+
│ │
|
|
92
|
+
│ ◀──────────────────────────────────── Return tokens
|
|
93
|
+
│ { accessToken, refreshToken }
|
|
94
|
+
│
|
|
45
95
|
```
|
|
46
96
|
|
|
47
|
-
|
|
48
|
-
|
|
97
|
+
### 2. Verification Flow
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
Browser Server
|
|
101
|
+
│
|
|
102
|
+
├─ GET /api/protected ─────────────────▶ Extract token & fpHash
|
|
103
|
+
│ Headers: │
|
|
104
|
+
│ Authorization: Bearer <token> ├─ Decode token (unsafe)
|
|
105
|
+
│ X-Fingerprint: <fpHash> │ Extract userId from payload
|
|
106
|
+
│ │
|
|
107
|
+
│ ├─ Re-derive signing secret:
|
|
108
|
+
│ │ HMAC(pepper, secret|userId|fpHash)
|
|
109
|
+
│ │
|
|
110
|
+
│ ├─ Verify JWT signature
|
|
111
|
+
│ │ jwt.verify(token, derivedSecret)
|
|
112
|
+
│ │
|
|
113
|
+
│ ├─ Compare fingerprints
|
|
114
|
+
│ │ payload.fp === request.fpHash
|
|
115
|
+
│ │
|
|
116
|
+
│ ◀──────────────────────────────────── Grant access ✓
|
|
117
|
+
│
|
|
49
118
|
```
|
|
50
119
|
|
|
120
|
+
### 3. Why Stolen Tokens Fail
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Attacker (Different Device) Server
|
|
124
|
+
│
|
|
125
|
+
├─ GET /api/protected ─────────────────▶ Extract token & fpHash
|
|
126
|
+
│ Token: <stolen_token> │
|
|
127
|
+
│ Fingerprint: <attacker_fp_hash> ├─ Decode token
|
|
128
|
+
│ │ payload.fp = <victim_fp_hash>
|
|
129
|
+
│ │
|
|
130
|
+
│ ├─ Derive secret using attacker's FP:
|
|
131
|
+
│ │ HMAC(pepper, secret|userId|attacker_fp)
|
|
132
|
+
│ │
|
|
133
|
+
│ ├─ Verify signature
|
|
134
|
+
│ │ ✗ FAILS - Different derived secret
|
|
135
|
+
│ │
|
|
136
|
+
│ ◀──────────────────────────────────── Reject: "invalid signature"
|
|
137
|
+
│
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Key insight:** The signing secret is derived from the fingerprint. A different fingerprint produces a different secret, causing signature verification to fail.
|
|
141
|
+
|
|
51
142
|
---
|
|
52
143
|
|
|
53
|
-
##
|
|
144
|
+
## Quick Start
|
|
54
145
|
|
|
55
|
-
|
|
56
|
-
sequenceDiagram
|
|
57
|
-
participant Browser
|
|
58
|
-
participant Server
|
|
59
|
-
|
|
60
|
-
Browser->>Browser: Generate device fingerprint
|
|
61
|
-
Browser->>Server: Login with credentials + fingerprint
|
|
62
|
-
Server->>Server: Hash fingerprint (SHA-256)
|
|
63
|
-
Server->>Server: Create JWT bound to fingerprint hash
|
|
64
|
-
Server->>Browser: Return access + refresh tokens
|
|
65
|
-
|
|
66
|
-
Browser->>Server: API request with token + fingerprint
|
|
67
|
-
Server->>Server: Verify token signature
|
|
68
|
-
Server->>Server: Verify fingerprint match
|
|
69
|
-
Server->>Browser: Grant access ✅
|
|
70
|
-
|
|
71
|
-
Note over Browser,Server: If attacker steals token...
|
|
72
|
-
|
|
73
|
-
Browser->>Server: Request from different device
|
|
74
|
-
Server->>Server: Fingerprint mismatch detected
|
|
75
|
-
Server->>Browser: Reject request ❌
|
|
76
|
-
```
|
|
146
|
+
### Step 1: Configure Environment Variables
|
|
77
147
|
|
|
78
|
-
|
|
148
|
+
Create a `.env` file in your application (NOT in bro-auth):
|
|
79
149
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
5. **Token theft is mitigated** - stolen tokens fail fingerprint verification on different devices
|
|
150
|
+
```bash
|
|
151
|
+
# Required: Application-owned server-only pepper
|
|
152
|
+
# bro-auth requires this but does NOT provide it
|
|
153
|
+
BRO_AUTH_SECRET_PEPPER=your-random-string-min-32-chars-never-expose
|
|
85
154
|
|
|
86
|
-
|
|
155
|
+
# Your application's JWT signing secrets
|
|
156
|
+
ACCESS_SECRET=your-access-secret-min-32-chars
|
|
157
|
+
REFRESH_SECRET=your-refresh-secret-min-32-chars
|
|
158
|
+
```
|
|
87
159
|
|
|
88
|
-
|
|
160
|
+
**Important:** These are YOUR application's secrets. `bro-auth` reads them but does not manage or provide them.
|
|
89
161
|
|
|
90
|
-
###
|
|
162
|
+
### Step 2: Browser - Generate Fingerprint
|
|
91
163
|
|
|
92
164
|
```javascript
|
|
93
165
|
import { getFingerprint } from "bro-auth/browser";
|
|
94
166
|
|
|
95
167
|
async function handleLogin() {
|
|
96
|
-
|
|
168
|
+
// Generate device fingerprint hash
|
|
169
|
+
const fpHash = await getFingerprint();
|
|
170
|
+
|
|
171
|
+
// fpHash is a string: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
97
172
|
|
|
98
|
-
// Send to your login endpoint
|
|
99
173
|
const response = await fetch("/api/login", {
|
|
100
174
|
method: "POST",
|
|
101
175
|
headers: { "Content-Type": "application/json" },
|
|
102
176
|
body: JSON.stringify({
|
|
103
177
|
username: "user@example.com",
|
|
104
|
-
password: "
|
|
105
|
-
fingerprint:
|
|
178
|
+
password: "password123",
|
|
179
|
+
fingerprint: fpHash
|
|
106
180
|
})
|
|
107
181
|
});
|
|
108
182
|
|
|
109
183
|
const { accessToken, refreshToken } = await response.json();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
**Fingerprint output example:**
|
|
115
|
-
|
|
116
|
-
```json
|
|
117
|
-
{
|
|
118
|
-
"hash": "53ff76d8a4c21b9c8e3f1a7d9e2c4b5a8f1e3d6c9b2a5e8f1d4c7b0a3e6f9c2696",
|
|
119
|
-
"raw": "Mozilla/5.0|1920x1080|ANGLE (Intel, Mesa Intel UHD)|canvas_data...",
|
|
120
|
-
"components": {
|
|
121
|
-
"userAgent": "Mozilla/5.0 (X11; Linux x86_64)...",
|
|
122
|
-
"screenResolution": "1920x1080",
|
|
123
|
-
"gpu": "ANGLE (Intel, Mesa Intel UHD Graphics 620)",
|
|
124
|
-
"canvas": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...",
|
|
125
|
-
"timezone": "America/New_York"
|
|
126
|
-
}
|
|
184
|
+
|
|
185
|
+
// Store tokens (see security best practices)
|
|
186
|
+
sessionStorage.setItem("accessToken", accessToken);
|
|
127
187
|
}
|
|
128
188
|
```
|
|
129
189
|
|
|
130
|
-
###
|
|
190
|
+
### Step 3: Server - Generate Tokens
|
|
131
191
|
|
|
132
192
|
```javascript
|
|
133
|
-
import { generateTokens } from "bro-auth";
|
|
193
|
+
import { generateTokens } from "bro-auth/core";
|
|
134
194
|
|
|
135
195
|
app.post("/api/login", async (req, res) => {
|
|
136
196
|
const { username, password, fingerprint } = req.body;
|
|
137
197
|
|
|
138
|
-
// Verify credentials (your logic
|
|
139
|
-
const user = await
|
|
198
|
+
// 1. Verify credentials (your logic)
|
|
199
|
+
const user = await authenticateUser(username, password);
|
|
140
200
|
if (!user) {
|
|
141
201
|
return res.status(401).json({ error: "Invalid credentials" });
|
|
142
202
|
}
|
|
143
203
|
|
|
144
|
-
// Generate
|
|
145
|
-
const { accessToken, refreshToken } = generateTokens(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
refreshExpiresIn: "7d" // Optional: default 7d
|
|
152
|
-
});
|
|
204
|
+
// 2. Generate device-bound tokens
|
|
205
|
+
const { accessToken, refreshToken } = generateTokens(
|
|
206
|
+
user.id, // userId
|
|
207
|
+
fingerprint, // fpHash from browser
|
|
208
|
+
process.env.ACCESS_SECRET, // your secret
|
|
209
|
+
process.env.REFRESH_SECRET // your secret
|
|
210
|
+
);
|
|
153
211
|
|
|
154
212
|
res.json({ accessToken, refreshToken });
|
|
155
213
|
});
|
|
156
214
|
```
|
|
157
215
|
|
|
158
|
-
###
|
|
216
|
+
### Step 4: Server - Verify Requests
|
|
159
217
|
|
|
160
218
|
```javascript
|
|
161
|
-
import { verifyAccessToken } from "bro-auth";
|
|
219
|
+
import { verifyAccessToken } from "bro-auth/core";
|
|
162
220
|
|
|
163
221
|
app.get("/api/protected", (req, res) => {
|
|
164
222
|
const token = req.headers.authorization?.replace("Bearer ", "");
|
|
165
223
|
const fingerprint = req.headers["x-fingerprint"];
|
|
166
224
|
|
|
225
|
+
if (!token || !fingerprint) {
|
|
226
|
+
return res.status(401).json({ error: "Missing credentials" });
|
|
227
|
+
}
|
|
228
|
+
|
|
167
229
|
const result = verifyAccessToken(
|
|
168
230
|
token,
|
|
169
231
|
fingerprint,
|
|
@@ -175,15 +237,15 @@ app.get("/api/protected", (req, res) => {
|
|
|
175
237
|
}
|
|
176
238
|
|
|
177
239
|
// Access granted
|
|
178
|
-
const userId = result.payload.
|
|
179
|
-
res.json({ message: "
|
|
240
|
+
const userId = result.payload.sub;
|
|
241
|
+
res.json({ message: "Success", userId });
|
|
180
242
|
});
|
|
181
243
|
```
|
|
182
244
|
|
|
183
|
-
###
|
|
245
|
+
### Step 5: Server - Refresh Tokens
|
|
184
246
|
|
|
185
247
|
```javascript
|
|
186
|
-
import { verifyRefreshToken, generateTokens
|
|
248
|
+
import { verifyRefreshToken, generateTokens } from "bro-auth/core";
|
|
187
249
|
|
|
188
250
|
app.post("/api/refresh", (req, res) => {
|
|
189
251
|
const { refreshToken, fingerprint } = req.body;
|
|
@@ -195,20 +257,16 @@ app.post("/api/refresh", (req, res) => {
|
|
|
195
257
|
);
|
|
196
258
|
|
|
197
259
|
if (!result.valid) {
|
|
198
|
-
return res.status(401).json({ error:
|
|
260
|
+
return res.status(401).json({ error: "Invalid refresh token" });
|
|
199
261
|
}
|
|
200
262
|
|
|
201
263
|
// Issue new token pair
|
|
202
|
-
const tokens = generateTokens(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// Optional: Set refresh token as HTTP-only cookie
|
|
210
|
-
const cookie = buildRefreshCookie(tokens.refreshToken);
|
|
211
|
-
res.setHeader("Set-Cookie", cookie);
|
|
264
|
+
const tokens = generateTokens(
|
|
265
|
+
result.payload.sub,
|
|
266
|
+
fingerprint,
|
|
267
|
+
process.env.ACCESS_SECRET,
|
|
268
|
+
process.env.REFRESH_SECRET
|
|
269
|
+
);
|
|
212
270
|
|
|
213
271
|
res.json({ accessToken: tokens.accessToken });
|
|
214
272
|
});
|
|
@@ -216,162 +274,310 @@ app.post("/api/refresh", (req, res) => {
|
|
|
216
274
|
|
|
217
275
|
---
|
|
218
276
|
|
|
219
|
-
##
|
|
277
|
+
## API Reference
|
|
220
278
|
|
|
221
|
-
### Browser Module
|
|
279
|
+
### Browser Module (`bro-auth/browser`)
|
|
222
280
|
|
|
223
|
-
#### `getFingerprint()
|
|
281
|
+
#### `getFingerprint()`
|
|
224
282
|
|
|
225
|
-
Generates a
|
|
283
|
+
Generates a SHA-256 hash of browser device characteristics.
|
|
226
284
|
|
|
227
|
-
**Returns:**
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
userAgent: string;
|
|
234
|
-
screenResolution: string;
|
|
235
|
-
gpu: string;
|
|
236
|
-
canvas: string;
|
|
237
|
-
timezone: string;
|
|
238
|
-
// ... more components
|
|
239
|
-
}
|
|
240
|
-
}
|
|
285
|
+
**Returns:** `Promise<string>`
|
|
286
|
+
|
|
287
|
+
**Example:**
|
|
288
|
+
```javascript
|
|
289
|
+
const fpHash = await getFingerprint();
|
|
290
|
+
// "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
241
291
|
```
|
|
242
292
|
|
|
243
|
-
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
### Server Module (`bro-auth/core`)
|
|
244
296
|
|
|
245
|
-
#### `generateTokens(
|
|
297
|
+
#### `generateTokens(userId, fpHash, accessSecret, refreshSecret)`
|
|
246
298
|
|
|
247
|
-
|
|
299
|
+
Generates access and refresh tokens bound to a device fingerprint.
|
|
248
300
|
|
|
249
301
|
**Parameters:**
|
|
250
|
-
- `userId` (string)
|
|
251
|
-
- `
|
|
252
|
-
- `accessSecret` (string)
|
|
253
|
-
- `refreshSecret` (string)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
**
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
302
|
+
- `userId` (string) - Unique user identifier
|
|
303
|
+
- `fpHash` (string) - Device fingerprint hash from browser
|
|
304
|
+
- `accessSecret` (string) - Your application's access token secret
|
|
305
|
+
- `refreshSecret` (string) - Your application's refresh token secret
|
|
306
|
+
|
|
307
|
+
**Returns:** `{ accessToken: string, refreshToken: string }`
|
|
308
|
+
|
|
309
|
+
**Example:**
|
|
310
|
+
```javascript
|
|
311
|
+
const tokens = generateTokens(
|
|
312
|
+
"user_123",
|
|
313
|
+
fpHash,
|
|
314
|
+
process.env.ACCESS_SECRET,
|
|
315
|
+
process.env.REFRESH_SECRET
|
|
316
|
+
);
|
|
263
317
|
```
|
|
264
318
|
|
|
265
|
-
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
#### `generateAccessToken(userId, fpHash, secret, expiresIn?)`
|
|
322
|
+
|
|
323
|
+
Generates only an access token.
|
|
324
|
+
|
|
325
|
+
**Parameters:**
|
|
326
|
+
- `userId` (string) - User identifier
|
|
327
|
+
- `fpHash` (string) - Fingerprint hash
|
|
328
|
+
- `secret` (string) - Signing secret
|
|
329
|
+
- `expiresIn` (string, optional) - Expiration time (default: "15m")
|
|
330
|
+
|
|
331
|
+
**Returns:** `string`
|
|
332
|
+
|
|
333
|
+
**Example:**
|
|
334
|
+
```javascript
|
|
335
|
+
const accessToken = generateAccessToken(
|
|
336
|
+
"user_123",
|
|
337
|
+
fpHash,
|
|
338
|
+
process.env.ACCESS_SECRET,
|
|
339
|
+
"30m"
|
|
340
|
+
);
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
#### `generateRefreshToken(userId, fpHash, secret, expiresIn?)`
|
|
346
|
+
|
|
347
|
+
Generates only a refresh token.
|
|
348
|
+
|
|
349
|
+
**Parameters:**
|
|
350
|
+
- `userId` (string) - User identifier
|
|
351
|
+
- `fpHash` (string) - Fingerprint hash
|
|
352
|
+
- `secret` (string) - Signing secret
|
|
353
|
+
- `expiresIn` (string, optional) - Expiration time (default: "7d")
|
|
354
|
+
|
|
355
|
+
**Returns:** `string`
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
#### `verifyAccessToken(token, fpHash, secret)`
|
|
360
|
+
|
|
361
|
+
Verifies an access token and fingerprint binding.
|
|
266
362
|
|
|
267
|
-
|
|
363
|
+
**Parameters:**
|
|
364
|
+
- `token` (string) - JWT access token
|
|
365
|
+
- `fpHash` (string) - Current device fingerprint hash
|
|
366
|
+
- `secret` (string) - Signing secret
|
|
367
|
+
|
|
368
|
+
**Returns:** `VerificationResult`
|
|
268
369
|
|
|
269
|
-
**Returns:**
|
|
270
370
|
```typescript
|
|
271
371
|
{
|
|
272
372
|
valid: boolean;
|
|
273
373
|
payload?: {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
374
|
+
sub: string; // userId
|
|
375
|
+
fp: string; // fingerprint hash
|
|
376
|
+
type: string; // "access"
|
|
377
|
+
iat: number; // issued at timestamp
|
|
378
|
+
exp: number; // expiration timestamp
|
|
278
379
|
};
|
|
279
380
|
error?: string;
|
|
280
381
|
}
|
|
281
382
|
```
|
|
282
383
|
|
|
283
|
-
|
|
384
|
+
**Possible errors:**
|
|
385
|
+
- `"Invalid token structure"` - Malformed JWT
|
|
386
|
+
- `"invalid signature"` - Token tampered or wrong fingerprint
|
|
387
|
+
- `"jwt expired"` - Token expired
|
|
388
|
+
- `"Invalid token type"` - Not an access token
|
|
389
|
+
- `"Fingerprint mismatch"` - Device fingerprint doesn't match
|
|
390
|
+
|
|
391
|
+
**Example:**
|
|
392
|
+
```javascript
|
|
393
|
+
const result = verifyAccessToken(token, fpHash, process.env.ACCESS_SECRET);
|
|
394
|
+
|
|
395
|
+
if (result.valid) {
|
|
396
|
+
console.log("User ID:", result.payload.sub);
|
|
397
|
+
} else {
|
|
398
|
+
console.error("Error:", result.error);
|
|
399
|
+
}
|
|
400
|
+
```
|
|
284
401
|
|
|
285
|
-
|
|
402
|
+
---
|
|
286
403
|
|
|
287
|
-
#### `
|
|
404
|
+
#### `verifyRefreshToken(token, fpHash, secret)`
|
|
405
|
+
|
|
406
|
+
Verifies a refresh token and fingerprint binding.
|
|
407
|
+
|
|
408
|
+
**Parameters:** Same as `verifyAccessToken`
|
|
409
|
+
|
|
410
|
+
**Returns:** `VerificationResult`
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
#### `buildRefreshCookie(refreshToken, options?)`
|
|
288
415
|
|
|
289
416
|
Generates a secure HTTP-only cookie string for refresh tokens.
|
|
290
417
|
|
|
291
|
-
**
|
|
292
|
-
- `
|
|
293
|
-
- `
|
|
294
|
-
- `
|
|
295
|
-
- `
|
|
418
|
+
**Parameters:**
|
|
419
|
+
- `refreshToken` (string) - The refresh token
|
|
420
|
+
- `options` (object, optional):
|
|
421
|
+
- `maxAge` (number) - Cookie lifetime in seconds (default: 604800 = 7 days)
|
|
422
|
+
- `domain` (string) - Cookie domain
|
|
423
|
+
- `sameSite` ("Strict" | "Lax" | "None") - SameSite policy (default: "Strict")
|
|
424
|
+
- `secure` (boolean) - HTTPS only (default: true)
|
|
425
|
+
|
|
426
|
+
**Returns:** `string` (Set-Cookie header value)
|
|
427
|
+
|
|
428
|
+
**Example:**
|
|
429
|
+
```javascript
|
|
430
|
+
const cookie = buildRefreshCookie(refreshToken, {
|
|
431
|
+
maxAge: 86400,
|
|
432
|
+
sameSite: "Strict",
|
|
433
|
+
secure: true
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
res.setHeader("Set-Cookie", cookie);
|
|
437
|
+
```
|
|
296
438
|
|
|
297
439
|
---
|
|
298
440
|
|
|
299
|
-
|
|
441
|
+
#### `buildClearRefreshCookie()`
|
|
442
|
+
|
|
443
|
+
Generates a cookie string to clear the refresh token (for logout).
|
|
444
|
+
|
|
445
|
+
**Returns:** `string`
|
|
446
|
+
|
|
447
|
+
**Example:**
|
|
448
|
+
```javascript
|
|
449
|
+
app.post("/api/logout", (req, res) => {
|
|
450
|
+
res.setHeader("Set-Cookie", buildClearRefreshCookie());
|
|
451
|
+
res.json({ message: "Logged out" });
|
|
452
|
+
});
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
#### `deriveSecret(secret, userId, fpHash)`
|
|
458
|
+
|
|
459
|
+
Derives a unique signing secret using HMAC-SHA256 with the application's pepper.
|
|
460
|
+
|
|
461
|
+
**Parameters:**
|
|
462
|
+
- `secret` (string) - Base secret (access or refresh)
|
|
463
|
+
- `userId` (string) - User identifier
|
|
464
|
+
- `fpHash` (string) - Fingerprint hash
|
|
465
|
+
|
|
466
|
+
**Returns:** `string` (Hex-encoded derived secret)
|
|
467
|
+
|
|
468
|
+
**Note:** This is used internally. You typically don't need to call this directly.
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## Security Best Practices
|
|
300
473
|
|
|
301
474
|
### 1. Environment Variables
|
|
302
475
|
|
|
303
476
|
Never hardcode secrets. Use environment variables:
|
|
304
477
|
|
|
305
478
|
```bash
|
|
306
|
-
# .env
|
|
307
|
-
|
|
308
|
-
|
|
479
|
+
# .env (add to .gitignore)
|
|
480
|
+
BRO_AUTH_SECRET_PEPPER=min-32-chars-random-string
|
|
481
|
+
ACCESS_SECRET=min-32-chars-random-string
|
|
482
|
+
REFRESH_SECRET=min-32-chars-random-string
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Generate secrets using:
|
|
486
|
+
```bash
|
|
487
|
+
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
309
488
|
```
|
|
310
489
|
|
|
311
490
|
### 2. Token Storage
|
|
312
491
|
|
|
313
492
|
**Access tokens:**
|
|
314
493
|
- Store in memory (React state, Vue reactive)
|
|
315
|
-
-
|
|
494
|
+
- Session storage is acceptable for SPAs
|
|
495
|
+
- **Never** in localStorage (XSS vulnerable)
|
|
316
496
|
|
|
317
497
|
**Refresh tokens:**
|
|
318
|
-
-
|
|
319
|
-
-
|
|
498
|
+
- HTTP-only, Secure, SameSite=Strict cookies (recommended)
|
|
499
|
+
- **Never** accessible to JavaScript
|
|
500
|
+
|
|
501
|
+
**Example:**
|
|
502
|
+
```javascript
|
|
503
|
+
// ✓ Good: In-memory
|
|
504
|
+
const [accessToken, setAccessToken] = useState(null);
|
|
505
|
+
|
|
506
|
+
// ✗ Bad: localStorage
|
|
507
|
+
localStorage.setItem("token", accessToken); // XSS vulnerable
|
|
508
|
+
```
|
|
320
509
|
|
|
321
510
|
### 3. HTTPS Only
|
|
322
511
|
|
|
323
|
-
Always use HTTPS in production
|
|
512
|
+
Always use HTTPS in production:
|
|
324
513
|
|
|
325
|
-
|
|
514
|
+
```javascript
|
|
515
|
+
const cookie = buildRefreshCookie(refreshToken, {
|
|
516
|
+
secure: process.env.NODE_ENV === "production",
|
|
517
|
+
sameSite: "Strict"
|
|
518
|
+
});
|
|
519
|
+
```
|
|
326
520
|
|
|
327
|
-
|
|
521
|
+
### 4. Short-Lived Access Tokens
|
|
522
|
+
|
|
523
|
+
Keep access token TTL short (5-15 minutes):
|
|
524
|
+
|
|
525
|
+
```javascript
|
|
526
|
+
generateAccessToken(userId, fpHash, secret, "15m");
|
|
527
|
+
```
|
|
328
528
|
|
|
329
529
|
### 5. Token Rotation
|
|
330
530
|
|
|
331
|
-
Rotate refresh tokens on each use
|
|
531
|
+
Rotate refresh tokens on each use:
|
|
332
532
|
|
|
333
533
|
```javascript
|
|
334
|
-
|
|
335
|
-
|
|
534
|
+
app.post("/api/refresh", async (req, res) => {
|
|
535
|
+
const result = verifyRefreshToken(/* ... */);
|
|
536
|
+
|
|
537
|
+
if (result.valid) {
|
|
538
|
+
// Issue new token pair
|
|
539
|
+
const newTokens = generateTokens(/* ... */);
|
|
540
|
+
|
|
541
|
+
// Optional: Invalidate old refresh token in database
|
|
542
|
+
await revokeToken(req.body.refreshToken);
|
|
543
|
+
|
|
544
|
+
res.json(newTokens);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
336
547
|
```
|
|
337
548
|
|
|
338
|
-
|
|
549
|
+
### 6. Rate Limiting
|
|
339
550
|
|
|
340
|
-
|
|
551
|
+
Implement rate limiting on auth endpoints:
|
|
341
552
|
|
|
342
|
-
|
|
553
|
+
```javascript
|
|
554
|
+
import rateLimit from "express-rate-limit";
|
|
343
555
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
556
|
+
const loginLimiter = rateLimit({
|
|
557
|
+
windowMs: 15 * 60 * 1000,
|
|
558
|
+
max: 5
|
|
559
|
+
});
|
|
348
560
|
|
|
349
|
-
|
|
350
|
-
const { username, password, fingerprint } = await req.json();
|
|
351
|
-
|
|
352
|
-
// Your auth logic
|
|
353
|
-
const user = await authenticate(username, password);
|
|
354
|
-
|
|
355
|
-
const tokens = generateTokens({
|
|
356
|
-
userId: user.id,
|
|
357
|
-
fingerprintHash: fingerprint,
|
|
358
|
-
accessSecret: process.env.ACCESS_SECRET!,
|
|
359
|
-
refreshSecret: process.env.REFRESH_SECRET!
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
return NextResponse.json(tokens);
|
|
363
|
-
}
|
|
561
|
+
app.post("/api/login", loginLimiter, handleLogin);
|
|
364
562
|
```
|
|
365
563
|
|
|
564
|
+
---
|
|
565
|
+
|
|
566
|
+
## Framework Examples
|
|
567
|
+
|
|
366
568
|
### Express.js Middleware
|
|
367
569
|
|
|
368
570
|
```javascript
|
|
369
|
-
import { verifyAccessToken } from "bro-auth";
|
|
571
|
+
import { verifyAccessToken } from "bro-auth/core";
|
|
370
572
|
|
|
371
573
|
export const authMiddleware = (req, res, next) => {
|
|
372
574
|
const token = req.headers.authorization?.replace("Bearer ", "");
|
|
373
575
|
const fingerprint = req.headers["x-fingerprint"];
|
|
374
576
|
|
|
577
|
+
if (!token || !fingerprint) {
|
|
578
|
+
return res.status(401).json({ error: "Unauthorized" });
|
|
579
|
+
}
|
|
580
|
+
|
|
375
581
|
const result = verifyAccessToken(
|
|
376
582
|
token,
|
|
377
583
|
fingerprint,
|
|
@@ -379,10 +585,10 @@ export const authMiddleware = (req, res, next) => {
|
|
|
379
585
|
);
|
|
380
586
|
|
|
381
587
|
if (!result.valid) {
|
|
382
|
-
return res.status(401).json({ error:
|
|
588
|
+
return res.status(401).json({ error: result.error });
|
|
383
589
|
}
|
|
384
590
|
|
|
385
|
-
req.userId = result.payload.
|
|
591
|
+
req.userId = result.payload.sub;
|
|
386
592
|
next();
|
|
387
593
|
};
|
|
388
594
|
|
|
@@ -392,57 +598,225 @@ app.get("/api/user", authMiddleware, (req, res) => {
|
|
|
392
598
|
});
|
|
393
599
|
```
|
|
394
600
|
|
|
395
|
-
|
|
601
|
+
### Next.js 14 App Router
|
|
396
602
|
|
|
397
|
-
|
|
603
|
+
**Server Action:**
|
|
604
|
+
```typescript
|
|
605
|
+
// app/actions/auth.ts
|
|
606
|
+
'use server'
|
|
398
607
|
|
|
399
|
-
|
|
608
|
+
import { generateTokens } from "bro-auth/core";
|
|
609
|
+
import { cookies } from "next/headers";
|
|
400
610
|
|
|
401
|
-
|
|
611
|
+
export async function loginAction(formData: FormData) {
|
|
612
|
+
const username = formData.get("username") as string;
|
|
613
|
+
const password = formData.get("password") as string;
|
|
614
|
+
const fingerprint = formData.get("fingerprint") as string;
|
|
615
|
+
|
|
616
|
+
const user = await authenticateUser(username, password);
|
|
617
|
+
if (!user) {
|
|
618
|
+
return { error: "Invalid credentials" };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const tokens = generateTokens(
|
|
622
|
+
user.id,
|
|
623
|
+
fingerprint,
|
|
624
|
+
process.env.ACCESS_SECRET!,
|
|
625
|
+
process.env.REFRESH_SECRET!
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
cookies().set("refreshToken", tokens.refreshToken, {
|
|
629
|
+
httpOnly: true,
|
|
630
|
+
secure: true,
|
|
631
|
+
sameSite: "strict",
|
|
632
|
+
maxAge: 60 * 60 * 24 * 7
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
return { accessToken: tokens.accessToken };
|
|
636
|
+
}
|
|
637
|
+
```
|
|
402
638
|
|
|
403
|
-
|
|
639
|
+
**Client Component:**
|
|
640
|
+
```typescript
|
|
641
|
+
// app/login/page.tsx
|
|
642
|
+
'use client'
|
|
404
643
|
|
|
405
|
-
|
|
644
|
+
import { getFingerprint } from "bro-auth/browser";
|
|
645
|
+
import { loginAction } from "@/app/actions/auth";
|
|
406
646
|
|
|
407
|
-
|
|
647
|
+
export default function LoginPage() {
|
|
648
|
+
async function handleSubmit(e: FormEvent) {
|
|
649
|
+
e.preventDefault();
|
|
650
|
+
const formData = new FormData(e.target as HTMLFormElement);
|
|
651
|
+
|
|
652
|
+
const fpHash = await getFingerprint();
|
|
653
|
+
formData.append("fingerprint", fpHash);
|
|
654
|
+
|
|
655
|
+
const result = await loginAction(formData);
|
|
656
|
+
if (result.accessToken) {
|
|
657
|
+
sessionStorage.setItem("accessToken", result.accessToken);
|
|
658
|
+
router.push("/dashboard");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return (
|
|
663
|
+
<form onSubmit={handleSubmit}>
|
|
664
|
+
<input name="username" type="email" required />
|
|
665
|
+
<input name="password" type="password" required />
|
|
666
|
+
<button type="submit">Login</button>
|
|
667
|
+
</form>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
```
|
|
408
671
|
|
|
409
|
-
|
|
672
|
+
### React Auth Context
|
|
410
673
|
|
|
411
|
-
|
|
674
|
+
```javascript
|
|
675
|
+
import { createContext, useContext, useState, useEffect } from "react";
|
|
676
|
+
import { getFingerprint } from "bro-auth/browser";
|
|
412
677
|
|
|
413
|
-
|
|
678
|
+
const AuthContext = createContext();
|
|
414
679
|
|
|
415
|
-
|
|
680
|
+
export function AuthProvider({ children }) {
|
|
681
|
+
const [accessToken, setAccessToken] = useState(null);
|
|
682
|
+
const [fingerprint, setFingerprint] = useState(null);
|
|
683
|
+
|
|
684
|
+
useEffect(() => {
|
|
685
|
+
getFingerprint().then(setFingerprint);
|
|
686
|
+
}, []);
|
|
687
|
+
|
|
688
|
+
async function login(username, password) {
|
|
689
|
+
const response = await fetch("/api/login", {
|
|
690
|
+
method: "POST",
|
|
691
|
+
headers: { "Content-Type": "application/json" },
|
|
692
|
+
body: JSON.stringify({ username, password, fingerprint })
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const data = await response.json();
|
|
696
|
+
setAccessToken(data.accessToken);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function apiCall(endpoint, options = {}) {
|
|
700
|
+
return fetch(endpoint, {
|
|
701
|
+
...options,
|
|
702
|
+
headers: {
|
|
703
|
+
...options.headers,
|
|
704
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
705
|
+
"X-Fingerprint": fingerprint
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return (
|
|
711
|
+
<AuthContext.Provider value={{ login, apiCall, accessToken }}>
|
|
712
|
+
{children}
|
|
713
|
+
</AuthContext.Provider>
|
|
714
|
+
);
|
|
715
|
+
}
|
|
416
716
|
|
|
417
|
-
|
|
717
|
+
export const useAuth = () => useContext(AuthContext);
|
|
718
|
+
```
|
|
418
719
|
|
|
419
720
|
---
|
|
420
721
|
|
|
421
|
-
##
|
|
722
|
+
## FAQ
|
|
422
723
|
|
|
423
|
-
|
|
724
|
+
**Q: Can users have multiple devices?**
|
|
424
725
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
726
|
+
A: Yes. Each device generates its own fingerprint. Issue separate token pairs for each device. Optionally track active sessions by storing fingerprint hashes.
|
|
727
|
+
|
|
728
|
+
**Q: What if the fingerprint changes (browser update)?**
|
|
729
|
+
|
|
730
|
+
A: The user must re-authenticate. This is intentional—it prevents fingerprint spoofing. For better UX, implement a "trusted devices" feature or send email notifications for new logins.
|
|
731
|
+
|
|
732
|
+
**Q: What about privacy concerns?**
|
|
733
|
+
|
|
734
|
+
A: The fingerprint is SHA-256 hashed before transmission. Only the hash is sent to the server. However, disclose fingerprinting in your privacy policy and comply with GDPR/CCPA.
|
|
735
|
+
|
|
736
|
+
**Q: Does this work with mobile apps?**
|
|
737
|
+
|
|
738
|
+
A: The browser module is web-only. For mobile apps, use native device identifiers:
|
|
739
|
+
- iOS: `identifierForVendor`
|
|
740
|
+
- Android: `ANDROID_ID`
|
|
741
|
+
- React Native: `react-native-device-info`
|
|
742
|
+
|
|
743
|
+
**Q: How do I invalidate tokens immediately?**
|
|
744
|
+
|
|
745
|
+
A: `bro-auth` is stateless, so tokens can't be revoked until expiry. For immediate invalidation:
|
|
746
|
+
- Maintain a token blacklist in Redis
|
|
747
|
+
- Use short-lived access tokens (5-15 min)
|
|
748
|
+
- Implement token versioning (increment on password change)
|
|
749
|
+
|
|
750
|
+
**Q: Can I use this with GraphQL?**
|
|
751
|
+
|
|
752
|
+
A: Yes. Pass credentials in HTTP headers:
|
|
753
|
+
|
|
754
|
+
```javascript
|
|
755
|
+
const client = new ApolloClient({
|
|
756
|
+
uri: '/graphql',
|
|
757
|
+
request: async (operation) => {
|
|
758
|
+
const fpHash = await getFingerprint();
|
|
759
|
+
operation.setContext({
|
|
760
|
+
headers: {
|
|
761
|
+
authorization: `Bearer ${accessToken}`,
|
|
762
|
+
'x-fingerprint': fpHash
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
```
|
|
430
768
|
|
|
431
769
|
---
|
|
432
770
|
|
|
433
|
-
##
|
|
771
|
+
## Testing
|
|
434
772
|
|
|
435
|
-
|
|
773
|
+
### Run Backend Tests
|
|
774
|
+
|
|
775
|
+
```bash
|
|
776
|
+
npm test
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
Tests:
|
|
780
|
+
- Token generation
|
|
781
|
+
- Token verification
|
|
782
|
+
- Fingerprint binding
|
|
783
|
+
- Secret derivation
|
|
784
|
+
- Invalid fingerprint rejection
|
|
785
|
+
|
|
786
|
+
### Run Browser Tests
|
|
787
|
+
|
|
788
|
+
```bash
|
|
789
|
+
npx serve .
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
Open: `http://localhost:3000/tests/test-browser.html`
|
|
793
|
+
|
|
794
|
+
Tests:
|
|
795
|
+
- Fingerprint generation
|
|
796
|
+
- SHA-256 hashing
|
|
797
|
+
- Browser compatibility
|
|
436
798
|
|
|
437
799
|
---
|
|
438
800
|
|
|
801
|
+
## Contributing
|
|
802
|
+
|
|
803
|
+
Contributions welcome. Please:
|
|
804
|
+
|
|
805
|
+
1. Fork the repository
|
|
806
|
+
2. Create a feature branch
|
|
807
|
+
3. Run tests (`npm test`)
|
|
808
|
+
4. Submit a pull request
|
|
809
|
+
|
|
439
810
|
---
|
|
440
811
|
|
|
441
|
-
##
|
|
812
|
+
## License
|
|
442
813
|
|
|
443
|
-
|
|
444
|
-
- [GitHub Repository](https://github.com/ChakraVaishnav/bro-auth)
|
|
814
|
+
MIT © Vaishnav
|
|
445
815
|
|
|
446
816
|
---
|
|
447
817
|
|
|
448
|
-
|
|
818
|
+
## Links
|
|
819
|
+
|
|
820
|
+
- [NPM Package](https://www.npmjs.com/package/bro-auth)
|
|
821
|
+
- [GitHub Repository](https://github.com/ChakraVaishnav/bro-auth)
|
|
822
|
+
- [Report Issues](https://github.com/ChakraVaishnav/bro-auth/issues)
|