bro-auth 0.2.0 → 0.2.2
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 +378 -268
- package/dist/browser.cjs +2 -0
- package/dist/browser.js +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,174 +1,178 @@
|
|
|
1
|
+
|
|
1
2
|
# bro-auth
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
```
|
|
5
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
6
|
+
║ █▄▄ █▀█ █▀█ █▀█ █ █ ▀█▀ █ █ ║
|
|
7
|
+
║ █▄█ █▀▄ █▄█ █▀█ █▄█ █ █▀█ ║
|
|
8
|
+
╠══════════════════════════════════════════════════════════════╣
|
|
9
|
+
║ Stateless JWT · Device Fingerprinting ║
|
|
10
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Stateless JWT authentication with browser fingerprint binding and derived signing keys.**
|
|
14
|
+
|
|
4
15
|
|
|
5
16
|
[](https://www.npmjs.com/package/bro-auth)
|
|
6
17
|
[](https://opensource.org/licenses/MIT)
|
|
7
|
-
|
|
8
18
|
---
|
|
9
19
|
|
|
10
20
|
## Overview
|
|
11
21
|
|
|
12
|
-
|
|
22
|
+
bro-auth is a stateless authentication library that strengthens JWT security by binding tokens to a browser/device fingerprint and deriving signing keys per user + device.
|
|
13
23
|
|
|
14
|
-
|
|
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
|
|
24
|
+
It is designed to:
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
* Prevent token-only theft
|
|
27
|
+
* Block non-browser and cross-device replay
|
|
28
|
+
* Keep authentication stateless and scalable
|
|
29
|
+
* Be plug-and-play for developers
|
|
23
30
|
|
|
31
|
+
> **bro-auth strengthens stateless JWT authentication by binding tokens to browser context.**
|
|
32
|
+
> It is designed to make token theft, cross-device reuse, and blind replay attacks significantly harder — while remaining fully stateless and developer-friendly.
|
|
24
33
|
---
|
|
25
34
|
|
|
26
|
-
## Why
|
|
35
|
+
## Why bro-auth?
|
|
27
36
|
|
|
28
|
-
|
|
37
|
+
JWT-based authentication is simple and scalable — but **by default, JWTs are bearer tokens**.
|
|
38
|
+
If a token is stolen, it can often be reused from anywhere.
|
|
29
39
|
|
|
30
|
-
|
|
40
|
+
**bro-auth raises the security bar without adding server-side state.**
|
|
31
41
|
|
|
32
|
-
|
|
42
|
+
It does this by:
|
|
43
|
+
- Binding tokens to the browser/device that created them
|
|
44
|
+
- Deriving signing keys per user + device context
|
|
45
|
+
- Making token-only theft insufficient for reuse
|
|
46
|
+
- Preventing cross-browser and non-browser replay
|
|
47
|
+
- Keeping the system fully stateless and horizontally scalable
|
|
33
48
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
bro-auth is ideal when you want:
|
|
50
|
+
- Better security than plain JWT
|
|
51
|
+
- Zero session storage
|
|
52
|
+
- Simple developer experience
|
|
53
|
+
- Clear, well-defined security trade-offs
|
|
39
54
|
|
|
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
55
|
|
|
45
|
-
|
|
56
|
+
---
|
|
46
57
|
|
|
47
|
-
|
|
48
|
-
- Token replay from different devices
|
|
49
|
-
- Token forgery without server secrets
|
|
50
|
-
- Credential stuffing across devices
|
|
58
|
+
## Threat Model (Important – Read This)
|
|
51
59
|
|
|
52
|
-
|
|
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)
|
|
60
|
+
### bro-auth protects against:
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
* JWT copied from logs, storage, or memory
|
|
63
|
+
* Token reuse from a different browser/device
|
|
64
|
+
* Non-browser attacks (curl, Postman, bots)
|
|
65
|
+
* Token swapping across users
|
|
66
|
+
* Blind replay without browser context
|
|
63
67
|
|
|
64
|
-
|
|
68
|
+
### bro-auth does NOT protect against:
|
|
65
69
|
|
|
66
|
-
|
|
70
|
+
* XSS with active JS execution
|
|
71
|
+
* Malicious browser extensions
|
|
72
|
+
* Compromised dependencies running in-browser
|
|
73
|
+
* Replay after fingerprint observation
|
|
74
|
+
* Full device compromise
|
|
67
75
|
|
|
68
|
-
|
|
69
|
-
npm install bro-auth
|
|
70
|
-
```
|
|
76
|
+
> **This is a hard limit of stateless authentication, not a bug.**
|
|
71
77
|
|
|
72
78
|
---
|
|
73
79
|
|
|
74
|
-
##
|
|
80
|
+
## High-Level Design
|
|
81
|
+
|
|
82
|
+
### Core idea
|
|
83
|
+
|
|
84
|
+
A JWT is only valid if the same browser fingerprint that created it is presented again.
|
|
75
85
|
|
|
76
|
-
###
|
|
86
|
+
### Authentication Flow
|
|
87
|
+
|
|
88
|
+
#### 1. Login
|
|
77
89
|
|
|
78
90
|
```
|
|
79
|
-
Browser
|
|
91
|
+
Browser Server
|
|
80
92
|
│
|
|
81
|
-
├─ Generate fingerprint
|
|
82
|
-
│ (Canvas, GPU,
|
|
93
|
+
├─ Generate RAW fingerprint
|
|
94
|
+
│ (Canvas, GPU, UA, timezone, etc.)
|
|
83
95
|
│
|
|
84
|
-
├─ POST /login
|
|
85
|
-
│ { username, password,
|
|
86
|
-
│
|
|
87
|
-
│
|
|
88
|
-
│
|
|
89
|
-
│
|
|
90
|
-
│
|
|
91
|
-
│
|
|
92
|
-
│
|
|
93
|
-
│ {
|
|
96
|
+
├─ POST /login ─────────────────────▶ Verify credentials
|
|
97
|
+
│ { username, password, rawFP } │
|
|
98
|
+
│ ├─ Normalize & hash fingerprint:
|
|
99
|
+
│ │ fpHash = SHA256(rawFP)
|
|
100
|
+
│ │
|
|
101
|
+
│ ├─ Derive signing secret:
|
|
102
|
+
│ │ HMAC(pepper, secret|userId|fpHash)
|
|
103
|
+
│ │
|
|
104
|
+
│ ├─ Sign JWT
|
|
105
|
+
│ │ Payload: { sub, fp: fpHash }
|
|
94
106
|
│
|
|
107
|
+
◀──────────────────────────────────── Return tokens
|
|
95
108
|
```
|
|
96
109
|
|
|
97
|
-
|
|
110
|
+
#### 2. Protected API Request
|
|
98
111
|
|
|
99
112
|
```
|
|
100
|
-
Browser
|
|
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 ✓
|
|
113
|
+
Browser Server
|
|
117
114
|
│
|
|
115
|
+
├─ GET /api/protected ──────────────▶
|
|
116
|
+
│ Authorization: Bearer <token> │
|
|
117
|
+
│ X-Fingerprint: <rawFP> │
|
|
118
|
+
│ │
|
|
119
|
+
│ ├─ Hash fingerprint again:
|
|
120
|
+
│ │ SHA256(rawFP)
|
|
121
|
+
│ │
|
|
122
|
+
│ ├─ Re-derive signing secret
|
|
123
|
+
│ │
|
|
124
|
+
│ ├─ Verify JWT signature
|
|
125
|
+
│ │
|
|
126
|
+
│ ├─ Compare fp hashes
|
|
127
|
+
│ │
|
|
128
|
+
◀──────────────────────────────────── Access granted
|
|
118
129
|
```
|
|
119
130
|
|
|
120
|
-
###
|
|
131
|
+
### Why Token-Only Theft Fails
|
|
121
132
|
|
|
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
|
-
```
|
|
133
|
+
If an attacker steals **only the JWT:**
|
|
139
134
|
|
|
140
|
-
|
|
135
|
+
* They do not know the browser fingerprint
|
|
136
|
+
* Signature verification fails
|
|
137
|
+
* Token cannot be reused elsewhere
|
|
141
138
|
|
|
142
|
-
|
|
139
|
+
If the attacker also steals the raw fingerprint:
|
|
143
140
|
|
|
144
|
-
|
|
141
|
+
* Replay may succeed until token expiry
|
|
142
|
+
* This is a known stateless limitation
|
|
145
143
|
|
|
146
|
-
|
|
144
|
+
---
|
|
147
145
|
|
|
148
|
-
|
|
146
|
+
## Installation
|
|
149
147
|
|
|
150
148
|
```bash
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
149
|
+
npm install bro-auth
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
154
153
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
## Environment Variables
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
BRO_AUTH_SECRET_PEPPER=long-random-server-only-value
|
|
158
|
+
ACCESS_SECRET=your-access-secret
|
|
159
|
+
REFRESH_SECRET=your-refresh-secret
|
|
158
160
|
```
|
|
159
161
|
|
|
160
|
-
**
|
|
162
|
+
> ⚠️ **These secrets must never be exposed to the browser.**
|
|
163
|
+
|
|
164
|
+
---
|
|
161
165
|
|
|
162
|
-
|
|
166
|
+
## Browser Usage
|
|
167
|
+
|
|
168
|
+
### Generate fingerprint (RAW)
|
|
163
169
|
|
|
164
170
|
```javascript
|
|
165
171
|
import { getFingerprint } from "bro-auth/browser";
|
|
166
172
|
|
|
167
173
|
async function handleLogin() {
|
|
168
174
|
// Generate device fingerprint hash
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
// fpHash is a string: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
175
|
+
const rawFingerprint = await getFingerprint();
|
|
172
176
|
|
|
173
177
|
const response = await fetch("/api/login", {
|
|
174
178
|
method: "POST",
|
|
@@ -176,18 +180,27 @@ async function handleLogin() {
|
|
|
176
180
|
body: JSON.stringify({
|
|
177
181
|
username: "user@example.com",
|
|
178
182
|
password: "password123",
|
|
179
|
-
fingerprint:
|
|
183
|
+
fingerprint: rawFingerprint
|
|
180
184
|
})
|
|
181
185
|
});
|
|
182
186
|
|
|
183
187
|
const { accessToken, refreshToken } = await response.json();
|
|
184
188
|
|
|
185
|
-
// Store
|
|
189
|
+
// Store access token (see Token Storage Best Practices below)
|
|
186
190
|
sessionStorage.setItem("accessToken", accessToken);
|
|
191
|
+
|
|
192
|
+
// Refresh token is typically set as HTTP-only cookie by the server
|
|
187
193
|
}
|
|
188
194
|
```
|
|
189
195
|
|
|
190
|
-
|
|
196
|
+
**Returns:** A normalized raw string
|
|
197
|
+
**Note:** Never hashed on the client, sent as-is to the backend
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Server Usage
|
|
202
|
+
|
|
203
|
+
### Generate Tokens
|
|
191
204
|
|
|
192
205
|
```javascript
|
|
193
206
|
import { generateTokens } from "bro-auth/core";
|
|
@@ -204,7 +217,7 @@ app.post("/api/login", async (req, res) => {
|
|
|
204
217
|
// 2. Generate device-bound tokens
|
|
205
218
|
const { accessToken, refreshToken } = generateTokens(
|
|
206
219
|
user.id, // userId
|
|
207
|
-
fingerprint, //
|
|
220
|
+
fingerprint, // RAW fingerprint from browser
|
|
208
221
|
process.env.ACCESS_SECRET, // your secret
|
|
209
222
|
process.env.REFRESH_SECRET // your secret
|
|
210
223
|
);
|
|
@@ -213,7 +226,9 @@ app.post("/api/login", async (req, res) => {
|
|
|
213
226
|
});
|
|
214
227
|
```
|
|
215
228
|
|
|
216
|
-
|
|
229
|
+
> **Note:** bro-auth hashes the fingerprint internally using SHA-256.
|
|
230
|
+
|
|
231
|
+
### Verify Access Token
|
|
217
232
|
|
|
218
233
|
```javascript
|
|
219
234
|
import { verifyAccessToken } from "bro-auth/core";
|
|
@@ -228,7 +243,7 @@ app.get("/api/protected", (req, res) => {
|
|
|
228
243
|
|
|
229
244
|
const result = verifyAccessToken(
|
|
230
245
|
token,
|
|
231
|
-
fingerprint,
|
|
246
|
+
fingerprint, // RAW fingerprint
|
|
232
247
|
process.env.ACCESS_SECRET
|
|
233
248
|
);
|
|
234
249
|
|
|
@@ -242,7 +257,37 @@ app.get("/api/protected", (req, res) => {
|
|
|
242
257
|
});
|
|
243
258
|
```
|
|
244
259
|
|
|
245
|
-
###
|
|
260
|
+
### Why RAW FP → Server-Side Hashing?
|
|
261
|
+
|
|
262
|
+
* Prevents trusting client-side hashes blindly
|
|
263
|
+
* Ensures consistent normalization
|
|
264
|
+
* Improves debuggability
|
|
265
|
+
* Avoids mismatch bugs
|
|
266
|
+
* Keeps hashing logic centralized
|
|
267
|
+
|
|
268
|
+
> **Hashing is not claimed as replay prevention** — it is a binding and consistency mechanism.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## API Reference (Key Functions)
|
|
273
|
+
|
|
274
|
+
### `getFingerprint()`
|
|
275
|
+
|
|
276
|
+
Returns normalized raw fingerprint string.
|
|
277
|
+
|
|
278
|
+
### `generateTokens(userId, rawFP, accessSecret, refreshSecret)`
|
|
279
|
+
|
|
280
|
+
* Hashes fingerprint internally
|
|
281
|
+
* Derives signing secrets
|
|
282
|
+
* Issues access + refresh tokens
|
|
283
|
+
|
|
284
|
+
### `verifyAccessToken(token, rawFP, secret)`
|
|
285
|
+
|
|
286
|
+
* Hashes fingerprint again
|
|
287
|
+
* Re-derives signing key
|
|
288
|
+
* Verifies JWT integrity & binding
|
|
289
|
+
|
|
290
|
+
### Refresh Tokens
|
|
246
291
|
|
|
247
292
|
```javascript
|
|
248
293
|
import { verifyRefreshToken, generateTokens } from "bro-auth/core";
|
|
@@ -274,7 +319,154 @@ app.post("/api/refresh", (req, res) => {
|
|
|
274
319
|
|
|
275
320
|
---
|
|
276
321
|
|
|
277
|
-
##
|
|
322
|
+
## Token Storage Best Practices
|
|
323
|
+
|
|
324
|
+
### Access Token Storage
|
|
325
|
+
|
|
326
|
+
You can store access tokens in:
|
|
327
|
+
|
|
328
|
+
* **HTTP-only cookies** (Recommended) - Most secure, immune to XSS
|
|
329
|
+
* **Session storage** - Good for SPAs, cleared on tab close
|
|
330
|
+
* **Local storage** - Not recommended, vulnerable to XSS attacks
|
|
331
|
+
* **Memory (React state/Vue reactive)** - Secure but lost on page refresh
|
|
332
|
+
|
|
333
|
+
**Best practice:** Use HTTP-only cookies for maximum security.
|
|
334
|
+
|
|
335
|
+
#### Example: Storing Access Token in HTTP-only Cookie (Server-side)
|
|
336
|
+
|
|
337
|
+
```javascript
|
|
338
|
+
import { generateTokens } from "bro-auth/core";
|
|
339
|
+
|
|
340
|
+
app.post("/api/login", async (req, res) => {
|
|
341
|
+
const { username, password, fingerprint } = req.body;
|
|
342
|
+
const user = await authenticateUser(username, password);
|
|
343
|
+
|
|
344
|
+
if (!user) {
|
|
345
|
+
return res.status(401).json({ error: "Invalid credentials" });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const { accessToken, refreshToken } = generateTokens(
|
|
349
|
+
user.id,
|
|
350
|
+
fingerprint,
|
|
351
|
+
process.env.ACCESS_SECRET,
|
|
352
|
+
process.env.REFRESH_SECRET
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// Set access token as HTTP-only cookie
|
|
356
|
+
res.cookie("accessToken", accessToken, {
|
|
357
|
+
httpOnly: true,
|
|
358
|
+
secure: true,
|
|
359
|
+
sameSite: "strict",
|
|
360
|
+
maxAge: 15 * 60 * 1000 // 15 minutes
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Set refresh token as HTTP-only cookie
|
|
364
|
+
res.cookie("refreshToken", refreshToken, {
|
|
365
|
+
httpOnly: true,
|
|
366
|
+
secure: true,
|
|
367
|
+
sameSite: "strict",
|
|
368
|
+
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
res.json({ success: true });
|
|
372
|
+
});
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Refresh Token Storage
|
|
376
|
+
|
|
377
|
+
**Always store refresh tokens in HTTP-only cookies.**
|
|
378
|
+
|
|
379
|
+
bro-auth provides a built-in helper method `buildRefreshCookie` to simplify this.
|
|
380
|
+
|
|
381
|
+
#### Using `buildRefreshCookie`
|
|
382
|
+
|
|
383
|
+
```javascript
|
|
384
|
+
import { generateTokens, buildRefreshCookie } from "bro-auth/core";
|
|
385
|
+
|
|
386
|
+
app.post("/api/login", async (req, res) => {
|
|
387
|
+
const { username, password, fingerprint } = req.body;
|
|
388
|
+
const user = await authenticateUser(username, password);
|
|
389
|
+
|
|
390
|
+
if (!user) {
|
|
391
|
+
return res.status(401).json({ error: "Invalid credentials" });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const { accessToken, refreshToken } = generateTokens(
|
|
395
|
+
user.id,
|
|
396
|
+
fingerprint,
|
|
397
|
+
process.env.ACCESS_SECRET,
|
|
398
|
+
process.env.REFRESH_SECRET
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
// Use bro-auth's built-in helper for refresh token cookie
|
|
402
|
+
const refreshCookie = buildRefreshCookie(refreshToken);
|
|
403
|
+
|
|
404
|
+
res.cookie(
|
|
405
|
+
refreshCookie.name,
|
|
406
|
+
refreshCookie.value,
|
|
407
|
+
refreshCookie.options
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
// Return access token (client can store in sessionStorage or cookie)
|
|
411
|
+
res.json({ accessToken });
|
|
412
|
+
});
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
#### Custom Configuration for `buildRefreshCookie`
|
|
416
|
+
|
|
417
|
+
```javascript
|
|
418
|
+
import { buildRefreshCookie } from "bro-auth/core";
|
|
419
|
+
|
|
420
|
+
// Default: 7 days
|
|
421
|
+
const cookie = buildRefreshCookie(refreshToken);
|
|
422
|
+
|
|
423
|
+
// Custom expiry: 24 hours
|
|
424
|
+
const cookie24h = buildRefreshCookie(refreshToken, 60 * 60 * 24);
|
|
425
|
+
|
|
426
|
+
// The cookie object contains:
|
|
427
|
+
// {
|
|
428
|
+
// name: "bro_refresh",
|
|
429
|
+
// value: "<token>",
|
|
430
|
+
// options: {
|
|
431
|
+
// httpOnly: true,
|
|
432
|
+
// secure: true,
|
|
433
|
+
// sameSite: "strict",
|
|
434
|
+
// path: "/",
|
|
435
|
+
// maxAge: <seconds>
|
|
436
|
+
// }
|
|
437
|
+
// }
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
#### Clearing Refresh Token on Logout
|
|
441
|
+
|
|
442
|
+
```javascript
|
|
443
|
+
import { buildClearRefreshCookie } from "bro-auth/core";
|
|
444
|
+
|
|
445
|
+
app.post("/api/logout", (req, res) => {
|
|
446
|
+
const clearCookie = buildClearRefreshCookie();
|
|
447
|
+
|
|
448
|
+
res.cookie(
|
|
449
|
+
clearCookie.name,
|
|
450
|
+
clearCookie.value,
|
|
451
|
+
clearCookie.options
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
res.json({ message: "Logged out successfully" });
|
|
455
|
+
});
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Additional Security Best Practices
|
|
459
|
+
|
|
460
|
+
* Use short-lived access tokens (5–15 min)
|
|
461
|
+
* Enable CSP (Content Security Policy) to reduce XSS risk
|
|
462
|
+
* Rotate secrets on compromise
|
|
463
|
+
* Always use HTTPS in production
|
|
464
|
+
* Implement rate limiting on authentication endpoints
|
|
465
|
+
* Treat bro-auth as hardening, not magic
|
|
466
|
+
|
|
467
|
+
---
|
|
468
|
+
|
|
469
|
+
## Full API Reference
|
|
278
470
|
|
|
279
471
|
### Browser Module (`bro-auth/browser`)
|
|
280
472
|
|
|
@@ -411,43 +603,72 @@ Verifies a refresh token and fingerprint binding.
|
|
|
411
603
|
|
|
412
604
|
---
|
|
413
605
|
|
|
414
|
-
#### `buildRefreshCookie(refreshToken,
|
|
606
|
+
#### `buildRefreshCookie(refreshToken, maxAge?)`
|
|
415
607
|
|
|
416
|
-
Generates a secure HTTP-only cookie
|
|
608
|
+
Generates a secure HTTP-only cookie configuration object for refresh tokens.
|
|
417
609
|
|
|
418
610
|
**Parameters:**
|
|
419
611
|
- `refreshToken` (string) - The refresh token
|
|
420
|
-
- `
|
|
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)
|
|
612
|
+
- `maxAge` (number, optional) - Cookie lifetime in seconds (default: 604800 = 7 days)
|
|
425
613
|
|
|
426
|
-
**Returns:** `
|
|
614
|
+
**Returns:** `Object`
|
|
615
|
+
```javascript
|
|
616
|
+
{
|
|
617
|
+
name: "bro_refresh",
|
|
618
|
+
value: string,
|
|
619
|
+
options: {
|
|
620
|
+
httpOnly: true,
|
|
621
|
+
secure: true,
|
|
622
|
+
sameSite: "strict",
|
|
623
|
+
path: "/",
|
|
624
|
+
maxAge: number
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
```
|
|
427
628
|
|
|
428
629
|
**Example:**
|
|
429
630
|
```javascript
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
631
|
+
import { buildRefreshCookie } from "bro-auth/core";
|
|
632
|
+
|
|
633
|
+
const refreshCookie = buildRefreshCookie(refreshToken);
|
|
634
|
+
|
|
635
|
+
// Express/Fastify
|
|
636
|
+
res.cookie(
|
|
637
|
+
refreshCookie.name,
|
|
638
|
+
refreshCookie.value,
|
|
639
|
+
refreshCookie.options
|
|
640
|
+
);
|
|
435
641
|
|
|
436
|
-
|
|
642
|
+
// Next.js App Router
|
|
643
|
+
import { cookies } from "next/headers";
|
|
644
|
+
cookies().set(
|
|
645
|
+
refreshCookie.name,
|
|
646
|
+
refreshCookie.value,
|
|
647
|
+
refreshCookie.options
|
|
648
|
+
);
|
|
437
649
|
```
|
|
438
650
|
|
|
439
651
|
---
|
|
440
652
|
|
|
441
653
|
#### `buildClearRefreshCookie()`
|
|
442
654
|
|
|
443
|
-
Generates a cookie
|
|
655
|
+
Generates a cookie configuration object to clear the refresh token (for logout).
|
|
444
656
|
|
|
445
|
-
**Returns:** `
|
|
657
|
+
**Returns:** `Object` (same structure as `buildRefreshCookie` but with empty value and maxAge: 0)
|
|
446
658
|
|
|
447
659
|
**Example:**
|
|
448
660
|
```javascript
|
|
661
|
+
import { buildClearRefreshCookie } from "bro-auth/core";
|
|
662
|
+
|
|
449
663
|
app.post("/api/logout", (req, res) => {
|
|
450
|
-
|
|
664
|
+
const clearCookie = buildClearRefreshCookie();
|
|
665
|
+
|
|
666
|
+
res.cookie(
|
|
667
|
+
clearCookie.name,
|
|
668
|
+
clearCookie.value,
|
|
669
|
+
clearCookie.options
|
|
670
|
+
);
|
|
671
|
+
|
|
451
672
|
res.json({ message: "Logged out" });
|
|
452
673
|
});
|
|
453
674
|
```
|
|
@@ -469,101 +690,38 @@ Derives a unique signing secret using HMAC-SHA256 with the application's pepper.
|
|
|
469
690
|
|
|
470
691
|
---
|
|
471
692
|
|
|
472
|
-
##
|
|
693
|
+
## FAQ (Corrected)
|
|
473
694
|
|
|
474
|
-
|
|
695
|
+
**"Is bro-auth replay-proof?"**
|
|
475
696
|
|
|
476
|
-
|
|
697
|
+
❌ No.
|
|
698
|
+
It blocks token-only replay, not replay after full browser compromise.
|
|
477
699
|
|
|
478
|
-
|
|
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'))"
|
|
488
|
-
```
|
|
700
|
+
**"Is this better than plain JWT?"**
|
|
489
701
|
|
|
490
|
-
|
|
702
|
+
✅ Yes. Significantly.
|
|
491
703
|
|
|
492
|
-
**
|
|
493
|
-
- Store in memory (React state, Vue reactive)
|
|
494
|
-
- Session storage is acceptable for SPAs
|
|
495
|
-
- **Never** in localStorage (XSS vulnerable)
|
|
704
|
+
**"Is this WebAuthn?"**
|
|
496
705
|
|
|
497
|
-
|
|
498
|
-
-
|
|
499
|
-
- **Never** accessible to JavaScript
|
|
706
|
+
❌ No.
|
|
707
|
+
bro-auth is stateless. WebAuthn is stateful + hardware-backed.
|
|
500
708
|
|
|
501
|
-
|
|
502
|
-
```javascript
|
|
503
|
-
// ✓ Good: In-memory
|
|
504
|
-
const [accessToken, setAccessToken] = useState(null);
|
|
505
|
-
|
|
506
|
-
// ✗ Bad: localStorage
|
|
507
|
-
localStorage.setItem("token", accessToken); // XSS vulnerable
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
### 3. HTTPS Only
|
|
511
|
-
|
|
512
|
-
Always use HTTPS in production:
|
|
513
|
-
|
|
514
|
-
```javascript
|
|
515
|
-
const cookie = buildRefreshCookie(refreshToken, {
|
|
516
|
-
secure: process.env.NODE_ENV === "production",
|
|
517
|
-
sameSite: "Strict"
|
|
518
|
-
});
|
|
519
|
-
```
|
|
520
|
-
|
|
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
|
-
```
|
|
528
|
-
|
|
529
|
-
### 5. Token Rotation
|
|
530
|
-
|
|
531
|
-
Rotate refresh tokens on each use:
|
|
532
|
-
|
|
533
|
-
```javascript
|
|
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
|
-
});
|
|
547
|
-
```
|
|
709
|
+
---
|
|
548
710
|
|
|
549
|
-
|
|
711
|
+
## Final Positioning (Important)
|
|
550
712
|
|
|
551
|
-
|
|
713
|
+
**bro-auth is a stateless JWT hardening library.**
|
|
714
|
+
It raises the bar against real-world JWT misuse while remaining scalable and developer-friendly.
|
|
552
715
|
|
|
553
|
-
|
|
554
|
-
import rateLimit from "express-rate-limit";
|
|
716
|
+
That statement is:
|
|
555
717
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
app.post("/api/login", loginLimiter, handleLogin);
|
|
562
|
-
```
|
|
718
|
+
* technically correct
|
|
719
|
+
* interview-safe
|
|
720
|
+
* production-honest
|
|
563
721
|
|
|
564
722
|
---
|
|
565
723
|
|
|
566
|
-
## Framework Examples
|
|
724
|
+
## Advanced Framework Examples
|
|
567
725
|
|
|
568
726
|
### Express.js Middleware
|
|
569
727
|
|
|
@@ -719,55 +877,6 @@ export const useAuth = () => useContext(AuthContext);
|
|
|
719
877
|
|
|
720
878
|
---
|
|
721
879
|
|
|
722
|
-
## FAQ
|
|
723
|
-
|
|
724
|
-
**Q: Can users have multiple devices?**
|
|
725
|
-
|
|
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
|
-
```
|
|
768
|
-
|
|
769
|
-
---
|
|
770
|
-
|
|
771
880
|
## Testing
|
|
772
881
|
|
|
773
882
|
### Run Backend Tests
|
|
@@ -811,7 +920,7 @@ Contributions welcome. Please:
|
|
|
811
920
|
|
|
812
921
|
## License
|
|
813
922
|
|
|
814
|
-
MIT © Vaishnav
|
|
923
|
+
MIT © Vaishnav - Creator of bro-auth
|
|
815
924
|
|
|
816
925
|
---
|
|
817
926
|
|
|
@@ -819,4 +928,5 @@ MIT © Vaishnav
|
|
|
819
928
|
|
|
820
929
|
- [NPM Package](https://www.npmjs.com/package/bro-auth)
|
|
821
930
|
- [GitHub Repository](https://github.com/ChakraVaishnav/bro-auth)
|
|
822
|
-
- [Report Issues](https://github.com/ChakraVaishnav/bro-auth/issues)
|
|
931
|
+
- [Report Issues](https://github.com/ChakraVaishnav/bro-auth/issues)
|
|
932
|
+
- [Portfolio](https://giyu.me)
|
package/dist/browser.cjs
CHANGED
package/dist/browser.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bro-auth",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "bro-auth — Stateless, fingerprint-bound JWT authentication. Server utilities + browser fingerprinting module.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -46,4 +46,4 @@
|
|
|
46
46
|
"tsup": "^8.5.1",
|
|
47
47
|
"typescript": "^5.9.3"
|
|
48
48
|
}
|
|
49
|
-
}
|
|
49
|
+
}
|