auth-verify 1.4.0 → 1.6.1
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/index.js +20 -1
- package/package.json +8 -5
- package/readme.md +404 -15
- package/src/jwt/index.js +70 -0
- package/src/oauth/index.js +178 -0
- package/src/passkey/index.js +195 -0
- package/tests/jwt.middleware.test.js +89 -0
- package/tests/oauth.test.js +77 -0
- package/tests/passkeymanager.test.js +114 -0
package/index.js
CHANGED
|
@@ -3,6 +3,7 @@ const OTPManager = require("./src/otp");
|
|
|
3
3
|
const SessionManager = require("./src/session");
|
|
4
4
|
const OAuthManager = require("./src/oauth");
|
|
5
5
|
const TOTPManager = require("./src/totp");
|
|
6
|
+
const PasskeyManager = require("./src/passkey");
|
|
6
7
|
|
|
7
8
|
class AuthVerify {
|
|
8
9
|
constructor(options = {}) {
|
|
@@ -18,7 +19,10 @@ class AuthVerify {
|
|
|
18
19
|
digits: 6,
|
|
19
20
|
step: 30,
|
|
20
21
|
alg: "SHA1"
|
|
21
|
-
}
|
|
22
|
+
},
|
|
23
|
+
rpName = "auth-verify",
|
|
24
|
+
saveBy = "id",
|
|
25
|
+
passExp = "2m"
|
|
22
26
|
} = options;
|
|
23
27
|
|
|
24
28
|
// ✅ Ensure cookieName and secret always exist
|
|
@@ -44,6 +48,8 @@ class AuthVerify {
|
|
|
44
48
|
this.totp = new TOTPManager(totp);
|
|
45
49
|
|
|
46
50
|
this.senders = new Map();
|
|
51
|
+
|
|
52
|
+
this.passkey = new PasskeyManager({rpName, storeTokens, saveBy, passExp});
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
// --- Session helpers ---
|
|
@@ -59,6 +65,19 @@ class AuthVerify {
|
|
|
59
65
|
return this.session.destroy(sessionId);
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
// --- Passkey helpers ---
|
|
69
|
+
async registerPasskey(user) {
|
|
70
|
+
return this.passkey.register(user);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async finishPasskey(clientResponse) {
|
|
74
|
+
return this.passkey.finish(clientResponse);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async loginPasskey(user) {
|
|
78
|
+
return this.passkey.login(user);
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
// --- Sender registration ---
|
|
63
82
|
register = {
|
|
64
83
|
sender: (name, fn) => {
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"dependencies": {
|
|
3
3
|
"axios": "^1.12.2",
|
|
4
|
+
"base64url": "^3.0.1",
|
|
5
|
+
"cbor": "^10.0.11",
|
|
4
6
|
"crypto": "^1.0.1",
|
|
5
7
|
"ioredis": "^5.8.1",
|
|
6
8
|
"jsonwebtoken": "^9.0.2",
|
|
7
9
|
"node-telegram-bot-api": "^0.66.0",
|
|
8
10
|
"nodemailer": "^7.0.6",
|
|
9
11
|
"qrcode": "^1.5.4",
|
|
10
|
-
"redis": "^5.8.3",
|
|
11
|
-
"twilio": "^5.10.3",
|
|
12
12
|
"uuid": "^9.0.1"
|
|
13
13
|
},
|
|
14
14
|
"name": "auth-verify",
|
|
15
|
-
"version": "1.
|
|
16
|
-
"description": "A simple Node.js library for sending and verifying OTP via email, SMS and Telegram bot. And handling JWT with Cookies",
|
|
15
|
+
"version": "1.6.1",
|
|
16
|
+
"description": "A simple Node.js library for sending and verifying OTP via email, SMS and Telegram bot. And generating TOTP codes and QR codes. And handling JWT with Cookies",
|
|
17
17
|
"main": "index.js",
|
|
18
18
|
"scripts": {
|
|
19
19
|
"test": "jest --runInBand"
|
|
@@ -42,7 +42,10 @@
|
|
|
42
42
|
"redis",
|
|
43
43
|
"cookie",
|
|
44
44
|
"jwa",
|
|
45
|
-
"jsonwebtoken"
|
|
45
|
+
"jsonwebtoken",
|
|
46
|
+
"totp",
|
|
47
|
+
"google-authenticator",
|
|
48
|
+
"signin"
|
|
46
49
|
],
|
|
47
50
|
"author": "Jahongir Sobirov",
|
|
48
51
|
"license": "MIT",
|
package/readme.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# auth-verify
|
|
2
2
|
|
|
3
3
|
**auth-verify** is a Node.js authentication utility that provides:
|
|
4
|
-
- ✅ Secure OTP (one-time password) generation and verification
|
|
5
|
-
- ✅ Sending OTPs via Email, SMS (pluggable helpers), and Telegram bot
|
|
6
|
-
- ✅ TOTP (Time-based One Time Passwords) generation
|
|
7
|
-
- ✅ JWT creation, verification
|
|
8
|
-
- ✅ Session management (in-memory or Redis)
|
|
9
|
-
- ✅
|
|
10
|
-
- ⚙️ Developer extensibility: custom senders
|
|
4
|
+
- ✅ Secure OTP (one-time password) generation and verification.
|
|
5
|
+
- ✅ Sending OTPs via Email, SMS (pluggable helpers), and Telegram bot.
|
|
6
|
+
- ✅ TOTP (Time-based One Time Passwords) generation, QR code generation, and verification (Google Authenticator support).
|
|
7
|
+
- ✅ JWT creation, verification, optional token revocation with memory/Redis storage, and advanced middleware for protecting routes, custom cookie/header handling, role-based guards, and token extraction from custom sources.
|
|
8
|
+
- ✅ Session management (in-memory or Redis).
|
|
9
|
+
- ✅ OAuth 2.0 integration for Google, Facebook, GitHub, X (Twitter), Linkedin, and additional providers like Apple, Discord, Slack, Microsoft, Telegram,and WhatsApp.
|
|
10
|
+
- ⚙️ Developer extensibility: custom senders via `auth.register.sender()` and chainable sending via `auth.use(name).send(...)`.
|
|
11
|
+
- ✅ Automatic JWT cookie handling for Express apps, supporting secure, HTTP-only cookies and optional auto-verification.
|
|
12
|
+
- ✅ Fully asynchronous/Promise-based API, with callback support where applicable.
|
|
13
|
+
- ✅ Chainable OTP workflow with cooldowns, max attempts, and resend functionality.
|
|
11
14
|
---
|
|
12
15
|
|
|
13
16
|
## 🧩 Installation
|
|
@@ -24,11 +27,13 @@ npm install auth-verify
|
|
|
24
27
|
|
|
25
28
|
## ⚙️ Quick overview
|
|
26
29
|
|
|
27
|
-
- `AuthVerify` (entry): constructs and exposes `.jwt`, `.otp`, (optionally) `.session` and `.oauth` managers.
|
|
28
|
-
- `JWTManager`: sign, verify, decode, revoke tokens. Supports `storeTokens: "memory" | "redis" | "none"
|
|
30
|
+
- `AuthVerify` (entry): constructs and exposes `.jwt`, `.otp`, (optionally) `.session`, `.totp` and `.oauth` managers.
|
|
31
|
+
- `JWTManager`: sign, verify, decode, revoke tokens. Supports `storeTokens: "memory" | "redis" | "none"` and middleware with custom cookie, header, and token extraction.
|
|
29
32
|
- `OTPManager`: generate, store, send, verify, resend OTPs. Supports `storeTokens: "memory" | "redis" | "none"`. Supports email, SMS helper, Telegram bot, and custom dev senders.
|
|
33
|
+
- `TOTPManager`: generate, verify uri, codes and QR codes.
|
|
30
34
|
- `SessionManager`: simple session creation/verification/destroy with memory or Redis backend.
|
|
31
|
-
- `OAuthManager`: Handle OAuth 2.0 logins for Google, Facebook, GitHub, X and
|
|
35
|
+
- `OAuthManager`: Handle OAuth 2.0 logins for Google, Facebook, GitHub, X, Linkedin, Apple, Discord, Slack, Microsoft, Telegram and WhatsApp.
|
|
36
|
+
- `PasskeyManager`: Handle passwordless login and registration using WebAuthn/passkey.
|
|
32
37
|
---
|
|
33
38
|
|
|
34
39
|
## 🚀 Example: Initialize library (CommonJS)
|
|
@@ -49,6 +54,108 @@ const auth = new AuthVerify({
|
|
|
49
54
|
|
|
50
55
|
## 🔐 JWT Usage
|
|
51
56
|
|
|
57
|
+
### JWT Middleware (`protect`) (v1.5.0+)
|
|
58
|
+
|
|
59
|
+
auth-verify comes with a fully customizable JWT middleware, making it easy to **protect routes**, **attach decoded data to the request**, and **check user roles**.
|
|
60
|
+
|
|
61
|
+
#### ⚙️ `protect` Method Overview
|
|
62
|
+
|
|
63
|
+
Function signature:
|
|
64
|
+
```js
|
|
65
|
+
protect(options = {})
|
|
66
|
+
```
|
|
67
|
+
**Description**:
|
|
68
|
+
- Returns an Express-style middleware.
|
|
69
|
+
- Automatically reads JWT from cookie, header, or custom extractor.
|
|
70
|
+
- Verifies the token and optionally checks for roles.
|
|
71
|
+
- Attaches the decoded payload to req (default property: req.user).
|
|
72
|
+
|
|
73
|
+
| Option | Type | Default | Description |
|
|
74
|
+
| ---------------- | -------- | ----------------- | -------------------------------------------------------------------------------------- |
|
|
75
|
+
| `onError` | Function | `null` | Custom error handler. `(err, req, res)` |
|
|
76
|
+
| `attachProperty` | String | `"user"` | Where to attach decoded token payload on `req` |
|
|
77
|
+
| `requiredRole` | String | `null` | Optional role check. Throws error if decoded role does not match |
|
|
78
|
+
| `cookieName` | String | `this.cookieName` | Name of the cookie to read JWT from |
|
|
79
|
+
| `headerName` | String | `"authorization"` | Header name to read JWT from. `"authorization"` splits `Bearer TOKEN` automatically |
|
|
80
|
+
| `extractor` | Function | `null` | Custom function to extract token. Receives `req` as argument and must return the token |
|
|
81
|
+
|
|
82
|
+
#### Middleware Behavior
|
|
83
|
+
|
|
84
|
+
1. **Token extraction order:**
|
|
85
|
+
- First: `extractor(req)` if provided
|
|
86
|
+
- Second: `req.headers[headerName]` (for `authorization`, splits `Bearer TOKEN`)
|
|
87
|
+
- Third: `cookieName` from request cookies
|
|
88
|
+
2. **Verification**:
|
|
89
|
+
- Calls `this.verify(token)`
|
|
90
|
+
- Throws `NO_TOKEN` if no token is found
|
|
91
|
+
- Throws `ROLE_NOT_ALLOWED` if `requiredRole`is provided but decoded role does not match
|
|
92
|
+
3. **Attachment**:
|
|
93
|
+
- Decoded token is attached to `req[attachProperty]`
|
|
94
|
+
4. **Error handling**:
|
|
95
|
+
- Default: responds with `401` and JSON `{ success: false, error: err.message }`
|
|
96
|
+
- Custom: if `onError` is provided, it is called instead of default behavior
|
|
97
|
+
|
|
98
|
+
#### Example Usage
|
|
99
|
+
##### Basic Usage
|
|
100
|
+
```js
|
|
101
|
+
const express = require("express");
|
|
102
|
+
const AuthVerify = require("auth-verify");
|
|
103
|
+
const app = express();
|
|
104
|
+
|
|
105
|
+
const auth = new AuthVerify({ jwtSecret: "supersecret" });
|
|
106
|
+
|
|
107
|
+
// Protect route
|
|
108
|
+
app.get("/dashboard", auth.jwt.protect(), (req, res) => {
|
|
109
|
+
// req.user contains decoded JWT payload
|
|
110
|
+
res.json({ message: `Welcome, ${req.user.userId}` });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
app.listen(3000, () => console.log("Server running on port 3000"));
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
##### Custom Cookie & Header
|
|
117
|
+
```js
|
|
118
|
+
app.get("/profile", auth.jwt.protect({
|
|
119
|
+
cookieName: "myToken",
|
|
120
|
+
headerName: "x-access-token"
|
|
121
|
+
}), (req, res) => {
|
|
122
|
+
res.json({ user: req.user });
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
- JWT will be read from the cookie named `"myToken"` or from the header `"x-access-token"`.
|
|
126
|
+
|
|
127
|
+
#### Role-based Guard
|
|
128
|
+
```js
|
|
129
|
+
app.get("/admin", auth.jwt.protect({
|
|
130
|
+
requiredRole: "admin"
|
|
131
|
+
}), (req, res) => {
|
|
132
|
+
res.json({ message: "Welcome Admin" });
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
- Throws error if decoded token does not have role: `"admin"`.
|
|
136
|
+
|
|
137
|
+
#### Custom Token Extractor
|
|
138
|
+
```js
|
|
139
|
+
app.get("/custom", auth.jwt.protect({
|
|
140
|
+
extractor: (req) => req.query.token
|
|
141
|
+
}), (req, res) => {
|
|
142
|
+
res.json({ user: req.user });
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
- Allows you to read token from any custom location (e.g., query params).
|
|
146
|
+
|
|
147
|
+
#### Custom Error Handler
|
|
148
|
+
```js
|
|
149
|
+
app.get("/custom-error", auth.jwt.protect({
|
|
150
|
+
onError: (err, req, res) => {
|
|
151
|
+
res.status(403).json({ error: "Access denied", details: err.message });
|
|
152
|
+
}
|
|
153
|
+
}), (req, res) => {
|
|
154
|
+
res.json({ user: req.user });
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
- Overrides default `401` response with custom logic.
|
|
158
|
+
|
|
52
159
|
### JWA Handling (v1.3.0+)
|
|
53
160
|
|
|
54
161
|
You can choose json web algorithm for signing jwt
|
|
@@ -144,8 +251,26 @@ auth.otp.setSender({
|
|
|
144
251
|
auth.otp.setSender({
|
|
145
252
|
via: 'sms',
|
|
146
253
|
provider: 'infobip',
|
|
147
|
-
apiKey: '
|
|
148
|
-
apiSecret: '
|
|
254
|
+
apiKey: 'API_KEY',
|
|
255
|
+
apiSecret: 'API_SECRET',
|
|
256
|
+
sender: 'SENDER_NAME',
|
|
257
|
+
mock: true // in dev prints message instead of sending
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
auth.otp.setSender({
|
|
261
|
+
via: 'sms',
|
|
262
|
+
provider: 'twilio',
|
|
263
|
+
apiKey: 'ACCOUNT_SID',
|
|
264
|
+
apiSecret: 'AUTH_TOKEN',
|
|
265
|
+
sender: 'SENDER_NAME',
|
|
266
|
+
mock: true // in dev prints message instead of sending
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
auth.otp.setSender({
|
|
270
|
+
via: 'sms',
|
|
271
|
+
provider: 'vonage',
|
|
272
|
+
apiKey: 'API_KEY',
|
|
273
|
+
apiSecret: 'API_SECRET',
|
|
149
274
|
sender: 'SENDER_NAME',
|
|
150
275
|
mock: true // in dev prints message instead of sending
|
|
151
276
|
});
|
|
@@ -175,7 +300,20 @@ auth.otp.generate(6).set('user@example.com', (err) => {
|
|
|
175
300
|
else console.log('sent', info && info.messageId);
|
|
176
301
|
});
|
|
177
302
|
});
|
|
303
|
+
|
|
304
|
+
// Sending OTP with SMS
|
|
305
|
+
auth.otp.generate(6).set('+1234567890', (err) => {
|
|
306
|
+
if (err) throw err;
|
|
307
|
+
auth.otp.message({
|
|
308
|
+
to: '+1234567890',
|
|
309
|
+
text: `Your code: <b>${auth.otp.code}</b>`
|
|
310
|
+
}, (err, info) => {
|
|
311
|
+
if (err) console.error('send error', err);
|
|
312
|
+
else console.log('sent', info && info.messageId);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
178
315
|
```
|
|
316
|
+
`+1234567890` is reciever number
|
|
179
317
|
|
|
180
318
|
Async/await style:
|
|
181
319
|
|
|
@@ -218,7 +356,153 @@ auth.otp.verify({ check: 'user@example.com', code: '123456' }, (err, isValid)=>{
|
|
|
218
356
|
|
|
219
357
|
---
|
|
220
358
|
|
|
221
|
-
##
|
|
359
|
+
## 🗝️ Passkey (WebAuthn) (New in v1.6.1)
|
|
360
|
+
|
|
361
|
+
`AuthVerify` includes a `PasskeyManager` class to handle passwordless login using WebAuthn / passkeys. You can **register** users, **verify login**, and manage **challenges** safely.
|
|
362
|
+
|
|
363
|
+
### Setup
|
|
364
|
+
```js
|
|
365
|
+
const AuthVerify = require("auth-verify");
|
|
366
|
+
|
|
367
|
+
const auth = new AuthVerify({
|
|
368
|
+
passExp: "2m", // passkey challenge TTL
|
|
369
|
+
rpName: "MyApp",
|
|
370
|
+
storeTokens: "memory" // or "redis"
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const user = {
|
|
374
|
+
id: "user1",
|
|
375
|
+
username: "john_doe",
|
|
376
|
+
credentials: [] // will store registered credentials
|
|
377
|
+
};
|
|
378
|
+
```
|
|
379
|
+
### 1️⃣ Register a new Passkey
|
|
380
|
+
The registration process consists of **two steps**:
|
|
381
|
+
**1.** Generate a registration challenge
|
|
382
|
+
**2.** Complete attestation after client responds
|
|
383
|
+
|
|
384
|
+
#### Step 1: Generate challenge
|
|
385
|
+
```js
|
|
386
|
+
// register user
|
|
387
|
+
await auth.passkey.register(user);
|
|
388
|
+
|
|
389
|
+
// get WebAuthn options for the client
|
|
390
|
+
const options = auth.passkey.getOptions();
|
|
391
|
+
console.log(options);
|
|
392
|
+
|
|
393
|
+
/* Example output:
|
|
394
|
+
{
|
|
395
|
+
challenge: "base64url-challenge",
|
|
396
|
+
rp: { name: "MyApp" },
|
|
397
|
+
user: { id: "dXNlcjE", name: "john_doe", displayName: "john_doe" },
|
|
398
|
+
pubKeyCredParams: [{ alg: -7, type: "public-key" }]
|
|
399
|
+
}
|
|
400
|
+
*/
|
|
401
|
+
```
|
|
402
|
+
> Send options to the browser to call:
|
|
403
|
+
> ```js
|
|
404
|
+
> navigator.credentials.create({ publicKey: options })
|
|
405
|
+
> ```
|
|
406
|
+
|
|
407
|
+
#### Step 2: Finish attestation
|
|
408
|
+
Once the client returns the attestation response:
|
|
409
|
+
```js
|
|
410
|
+
const clientResponse = {
|
|
411
|
+
id: "...", // credentialId from browser
|
|
412
|
+
response: {
|
|
413
|
+
clientDataJSON: "...",
|
|
414
|
+
attestationObject: "..."
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const result = await auth.passkey.finish(clientResponse);
|
|
419
|
+
console.log(result);
|
|
420
|
+
/* Example result:
|
|
421
|
+
{
|
|
422
|
+
status: "ok",
|
|
423
|
+
user: {
|
|
424
|
+
id: "user1",
|
|
425
|
+
username: "john_doe",
|
|
426
|
+
credentials: [
|
|
427
|
+
{ id: "credentialId", publicKey: "pem-key" }
|
|
428
|
+
]
|
|
429
|
+
},
|
|
430
|
+
challengeVerified: true,
|
|
431
|
+
rawAuthData: <Buffer ...>
|
|
432
|
+
}
|
|
433
|
+
*/
|
|
434
|
+
```
|
|
435
|
+
> After this, the user now has a **registered passkey**.
|
|
436
|
+
|
|
437
|
+
### 2️⃣ Login with Passkey
|
|
438
|
+
Login also consists of **two steps**:
|
|
439
|
+
**1.** Generate assertion challenge
|
|
440
|
+
**2.** Complete verification
|
|
441
|
+
#### Step 1: Generate login challenge
|
|
442
|
+
```js
|
|
443
|
+
await auth.passkey.login(user);
|
|
444
|
+
|
|
445
|
+
const options = auth.passkey.getOptions();
|
|
446
|
+
console.log(options);
|
|
447
|
+
|
|
448
|
+
/* Example output:
|
|
449
|
+
{
|
|
450
|
+
challenge: "base64url-challenge",
|
|
451
|
+
allowCredentials: [
|
|
452
|
+
{ id: <Buffer...>, type: "public-key" }
|
|
453
|
+
],
|
|
454
|
+
timeout: 60000
|
|
455
|
+
}
|
|
456
|
+
*/
|
|
457
|
+
```
|
|
458
|
+
> Send this `options` to the browser for `navigator.credentials.get({ publicKey: options })`.
|
|
459
|
+
|
|
460
|
+
#### Step 2: Finish login assertion
|
|
461
|
+
```js
|
|
462
|
+
const clientLoginResponse = {
|
|
463
|
+
id: "credentialId",
|
|
464
|
+
response: {
|
|
465
|
+
clientDataJSON: "...",
|
|
466
|
+
authenticatorData: "...",
|
|
467
|
+
signature: "..."
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const loginResult = await auth.passkey.finish(clientLoginResponse);
|
|
472
|
+
console.log(loginResult);
|
|
473
|
+
/* Example output:
|
|
474
|
+
{
|
|
475
|
+
status: "ok",
|
|
476
|
+
user: {
|
|
477
|
+
id: "user1",
|
|
478
|
+
username: "john_doe",
|
|
479
|
+
credentials: [...]
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
*/
|
|
483
|
+
```
|
|
484
|
+
> If `status === "ok"`, the login is successful.
|
|
485
|
+
|
|
486
|
+
### 3️⃣ Notes
|
|
487
|
+
|
|
488
|
+
- `auth.passkey.register()` and `auth.passkey.login()` return this so you can chain:
|
|
489
|
+
```js
|
|
490
|
+
await auth.passkey
|
|
491
|
+
.register(user)
|
|
492
|
+
.getOptions(); // get WebAuthn options
|
|
493
|
+
```
|
|
494
|
+
- `finish()` **must be called after `register()` or `login()`** with the client’s response.
|
|
495
|
+
- TTL (`passExp`) ensures challenges **expire automatically** (memory or Redis store).
|
|
496
|
+
|
|
497
|
+
### 4️⃣ Summary of Methods
|
|
498
|
+
| Method | Purpose | Returns |
|
|
499
|
+
| ------------------------ | ------------------------------- | ------------------ |
|
|
500
|
+
| `register(user)` | Start passkey registration | `this` (chainable) |
|
|
501
|
+
| `login(user)` | Start passkey login | `this` (chainable) |
|
|
502
|
+
| `getOptions()` | Get WebAuthn options for client | Object |
|
|
503
|
+
| `finish(clientResponse)` | Complete attestation/assertion | Result object |
|
|
504
|
+
|
|
505
|
+
## ✅ TOTP (Time-based One Time Passwords) — Google Authenticator support (v1.4.0+)
|
|
222
506
|
```js
|
|
223
507
|
const AuthVerify = require("auth-verify");
|
|
224
508
|
const auth = new AuthVerify();
|
|
@@ -288,7 +572,22 @@ if (auth.totp.verify({ secret, token })) {
|
|
|
288
572
|
```
|
|
289
573
|
---
|
|
290
574
|
## 🌍 OAuth 2.0 Integration (v1.2.0+)
|
|
291
|
-
`auth.oauth` supports login via Google, Facebook, GitHub, X (Twitter) and
|
|
575
|
+
`auth.oauth` supports login via Google, Facebook, GitHub, X (Twitter), Linkedin, Microsoft, Telegram, Slack, WhatsApp, Apple and Discord.
|
|
576
|
+
### Providers & Routes table
|
|
577
|
+
| Provider | Redirect URL | Callback URL | Scopes / Notes |
|
|
578
|
+
| ----------- | ----------------- | -------------------------- | -------------------------------------- |
|
|
579
|
+
| Google | `/auth/google` | `/auth/google/callback` | `openid email profile` |
|
|
580
|
+
| Facebook | `/auth/facebook` | `/auth/facebook/callback` | `email,public_profile` |
|
|
581
|
+
| GitHub | `/auth/github` | `/auth/github/callback` | `user:email` |
|
|
582
|
+
| X (Twitter) | `/auth/x` | `/auth/x/callback` | `tweet.read users.read offline.access` |
|
|
583
|
+
| LinkedIn | `/auth/linkedin` | `/auth/linkedin/callback` | `r_liteprofile r_emailaddress` |
|
|
584
|
+
| Microsoft | `/auth/microsoft` | `/auth/microsoft/callback` | `User.Read` |
|
|
585
|
+
| Telegram | `/auth/telegram` | `/auth/telegram/callback` | Bot deep-link |
|
|
586
|
+
| Slack | `/auth/slack` | `/auth/slack/callback` | `identity.basic identity.email` |
|
|
587
|
+
| WhatsApp | `/auth/whatsapp` | `/auth/whatsapp/callback` | QR / deep-link |
|
|
588
|
+
| Apple | `/auth/apple` | `/auth/apple/callback` | `name email` |
|
|
589
|
+
| Discord | `/auth/discord` | `/auth/discord/callback` | `identify email` |
|
|
590
|
+
|
|
292
591
|
### Example (Google Login with Express)
|
|
293
592
|
```js
|
|
294
593
|
const express = require('express');
|
|
@@ -369,6 +668,79 @@ const linkedin = auth.oauth.linkedin({
|
|
|
369
668
|
redirectUri: "http://localhost:3000/auth/linkedin/callback"
|
|
370
669
|
});
|
|
371
670
|
|
|
671
|
+
// --- MICROSOFT ---
|
|
672
|
+
const microsoft = auth.oauth.microsoft({
|
|
673
|
+
clientId: "YOUR_MICROSOFT_CLIENT_ID",
|
|
674
|
+
clientSecret: "YOUR_MICROSOFT_CLIENT_SECRET",
|
|
675
|
+
redirectUri: "http://localhost:3000/auth/microsoft/callback"
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
app.get("/auth/microsoft", (req, res) => microsoft.redirect(res));
|
|
679
|
+
|
|
680
|
+
app.get("/auth/microsoft/callback", async (req, res) => {
|
|
681
|
+
try {
|
|
682
|
+
const { code } = req.query;
|
|
683
|
+
const user = await microsoft.callback(code);
|
|
684
|
+
res.json({ success: true, provider: "microsoft", user });
|
|
685
|
+
} catch (err) {
|
|
686
|
+
res.status(400).json({ error: err.message });
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// --- TELEGRAM ---
|
|
691
|
+
const telegram = auth.oauth.telegram({
|
|
692
|
+
botId: "YOUR_BOT_ID",
|
|
693
|
+
redirectUri: "http://localhost:3000/auth/telegram/callback"
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
app.get("/auth/telegram", (req, res) => telegram.redirect(res));
|
|
697
|
+
|
|
698
|
+
app.get("/auth/telegram/callback", async (req, res) => {
|
|
699
|
+
try {
|
|
700
|
+
const { code } = req.query;
|
|
701
|
+
const result = await telegram.callback(code);
|
|
702
|
+
res.json({ success: true, provider: "telegram", ...result });
|
|
703
|
+
} catch (err) {
|
|
704
|
+
res.status(400).json({ error: err.message });
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// --- SLACK ---
|
|
709
|
+
const slack = auth.oauth.slack({
|
|
710
|
+
clientId: "YOUR_SLACK_CLIENT_ID",
|
|
711
|
+
clientSecret: "YOUR_SLACK_CLIENT_SECRET",
|
|
712
|
+
redirectUri: "http://localhost:3000/auth/slack/callback"
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
app.get("/auth/slack", (req, res) => slack.redirect(res));
|
|
716
|
+
|
|
717
|
+
app.get("/auth/slack/callback", async (req, res) => {
|
|
718
|
+
try {
|
|
719
|
+
const { code } = req.query;
|
|
720
|
+
const user = await slack.callback(code);
|
|
721
|
+
res.json({ success: true, provider: "slack", user });
|
|
722
|
+
} catch (err) {
|
|
723
|
+
res.status(400).json({ error: err.message });
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// --- WHATSAPP ---
|
|
728
|
+
const whatsapp = auth.oauth.whatsapp({
|
|
729
|
+
phoneNumberId: "YOUR_PHONE_ID",
|
|
730
|
+
redirectUri: "http://localhost:3000/auth/whatsapp/callback"
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
app.get("/auth/whatsapp", (req, res) => whatsapp.redirect(res));
|
|
734
|
+
|
|
735
|
+
app.get("/auth/whatsapp/callback", async (req, res) => {
|
|
736
|
+
try {
|
|
737
|
+
const { code } = req.query;
|
|
738
|
+
const result = await whatsapp.callback(code);
|
|
739
|
+
res.json({ success: true, provider: "whatsapp", ...result });
|
|
740
|
+
} catch (err) {
|
|
741
|
+
res.status(400).json({ error: err.message });
|
|
742
|
+
}
|
|
743
|
+
});
|
|
372
744
|
|
|
373
745
|
// ===== FACEBOOK ROUTES =====
|
|
374
746
|
app.get("/auth/facebook", (req, res) => facebook.redirect(res));
|
|
@@ -428,6 +800,21 @@ app.get("/auth/linkedin/callback", async (req, res)=>{
|
|
|
428
800
|
app.listen(PORT, () => console.log(`🚀 Server running at http://localhost:${PORT}`));
|
|
429
801
|
|
|
430
802
|
```
|
|
803
|
+
|
|
804
|
+
### ✅ Notes for Devs
|
|
805
|
+
1. Each provider has **redirect** and **callback** URLs.
|
|
806
|
+
2. Scopes can be customized per provider.
|
|
807
|
+
3. **Telegram & WhatsApp** use deep-link / QR-style flows.
|
|
808
|
+
4. The result of `callback()` is a JSON object containing the user info and `access_token` (except deep-link flows, which return code/messages).
|
|
809
|
+
5. You can **register custom providers** via:
|
|
810
|
+
```js
|
|
811
|
+
auth.oauth.register("myCustom", (options) => {
|
|
812
|
+
return {
|
|
813
|
+
redirect(res) { /* redirect user */ },
|
|
814
|
+
callback: async (code) => { /* handle callback */ }
|
|
815
|
+
};
|
|
816
|
+
});
|
|
817
|
+
```
|
|
431
818
|
---
|
|
432
819
|
|
|
433
820
|
## Telegram integration
|
|
@@ -521,12 +908,14 @@ auth-verify/
|
|
|
521
908
|
│ ├─ /session/index.js
|
|
522
909
|
| ├─ /oauth/index.js
|
|
523
910
|
│ └─ helpers/helper.js
|
|
524
|
-
├─
|
|
911
|
+
├─ tests/
|
|
525
912
|
│ ├─ jwa.test.js
|
|
526
913
|
│ ├─ jwtmanager.multitab.test.js
|
|
527
914
|
│ ├─ jwtmanager.test.js
|
|
528
915
|
│ ├─ otpmanager.test.js
|
|
916
|
+
│ ├─ oauth.test.js
|
|
529
917
|
│ ├─ totpmanager.test.js
|
|
918
|
+
│ ├─ passkeymanager.test.js
|
|
530
919
|
├─ babel.config.js
|
|
531
920
|
```
|
|
532
921
|
|
package/src/jwt/index.js
CHANGED
|
@@ -391,6 +391,76 @@ class JWTManager {
|
|
|
391
391
|
await this.redis.set(token, JSON.stringify(data));
|
|
392
392
|
}
|
|
393
393
|
}
|
|
394
|
+
|
|
395
|
+
// JWT middleware part
|
|
396
|
+
readCookie(req, name) {
|
|
397
|
+
const cookieHeader = req.headers.cookie;
|
|
398
|
+
if (!cookieHeader) return null;
|
|
399
|
+
|
|
400
|
+
const cookies = cookieHeader.split(';').map(v => v.trim());
|
|
401
|
+
for (const c of cookies) {
|
|
402
|
+
const [key, val] = c.split('=');
|
|
403
|
+
if (key === name) return val;
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
protect(options = {}) {
|
|
409
|
+
// customizable messages / behavior
|
|
410
|
+
const {
|
|
411
|
+
onError,
|
|
412
|
+
attachProperty = "user", // where to put decoded data on req
|
|
413
|
+
requiredRole = null, // optional: role check
|
|
414
|
+
cookieName = this.cookieName,
|
|
415
|
+
headerName = "authorization",
|
|
416
|
+
extractor = null
|
|
417
|
+
} = options;
|
|
418
|
+
|
|
419
|
+
return async (req, res, next) => {
|
|
420
|
+
try {
|
|
421
|
+
// read token (cookie → header)
|
|
422
|
+
let token = null;
|
|
423
|
+
|
|
424
|
+
if (extractor && typeof extractor === "function") {
|
|
425
|
+
token = extractor(req);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if(!token && req.headers[headerName]){
|
|
429
|
+
if(headerName == "authorization"){
|
|
430
|
+
token = req.headers.authorization.split(" ")[1];
|
|
431
|
+
}else{
|
|
432
|
+
token = req.headers[headerName]; // custom header — return raw
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!token) {
|
|
437
|
+
token = this.readCookie(req, cookieName);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!token) throw new Error("NO_TOKEN");
|
|
441
|
+
|
|
442
|
+
const decoded = await this.verify(token);
|
|
443
|
+
|
|
444
|
+
// role guard?
|
|
445
|
+
if (requiredRole && decoded.role !== requiredRole) {
|
|
446
|
+
throw new Error("ROLE_NOT_ALLOWED");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// attach user
|
|
450
|
+
req[attachProperty] = decoded;
|
|
451
|
+
|
|
452
|
+
next();
|
|
453
|
+
} catch (err) {
|
|
454
|
+
if (onError) return onError(err, req, res);
|
|
455
|
+
|
|
456
|
+
return res.status(401).json({
|
|
457
|
+
success: false,
|
|
458
|
+
error: err.message
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
394
464
|
}
|
|
395
465
|
|
|
396
466
|
module.exports = JWTManager;
|
package/src/oauth/index.js
CHANGED
|
@@ -235,6 +235,184 @@ class OAuthManager {
|
|
|
235
235
|
};
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
// --- APPLE LOGIN ---
|
|
239
|
+
apple({ clientId, clientSecret, redirectUri }) {
|
|
240
|
+
return {
|
|
241
|
+
redirect: (res) => {
|
|
242
|
+
const url =
|
|
243
|
+
"https://appleid.apple.com/auth/authorize?" +
|
|
244
|
+
new URLSearchParams({
|
|
245
|
+
response_type: "code",
|
|
246
|
+
client_id: clientId,
|
|
247
|
+
redirect_uri: redirectUri,
|
|
248
|
+
scope: "name email",
|
|
249
|
+
response_mode: "form_post",
|
|
250
|
+
});
|
|
251
|
+
res.redirect(url);
|
|
252
|
+
},
|
|
253
|
+
callback: async (code) => {
|
|
254
|
+
const tokenRes = await fetch("https://appleid.apple.com/auth/token", {
|
|
255
|
+
method: "POST",
|
|
256
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
257
|
+
body: new URLSearchParams({
|
|
258
|
+
grant_type: "authorization_code",
|
|
259
|
+
code,
|
|
260
|
+
client_id: clientId,
|
|
261
|
+
client_secret: clientSecret,
|
|
262
|
+
redirect_uri: redirectUri,
|
|
263
|
+
}),
|
|
264
|
+
});
|
|
265
|
+
const tokenData = await tokenRes.json();
|
|
266
|
+
if (tokenData.error) throw new Error("OAuth Error: " + tokenData.error);
|
|
267
|
+
return tokenData;
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --- DISCORD LOGIN ---
|
|
273
|
+
discord({ clientId, clientSecret, redirectUri }) {
|
|
274
|
+
return {
|
|
275
|
+
redirect: (res) => {
|
|
276
|
+
const url =
|
|
277
|
+
"https://discord.com/api/oauth2/authorize?" +
|
|
278
|
+
new URLSearchParams({
|
|
279
|
+
client_id: clientId,
|
|
280
|
+
redirect_uri: redirectUri,
|
|
281
|
+
response_type: "code",
|
|
282
|
+
scope: "identify email",
|
|
283
|
+
});
|
|
284
|
+
res.redirect(url);
|
|
285
|
+
},
|
|
286
|
+
callback: async (code) => {
|
|
287
|
+
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
290
|
+
body: new URLSearchParams({
|
|
291
|
+
client_id: clientId,
|
|
292
|
+
client_secret: clientSecret,
|
|
293
|
+
code,
|
|
294
|
+
grant_type: "authorization_code",
|
|
295
|
+
redirect_uri: redirectUri,
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
const tokenData = await tokenRes.json();
|
|
299
|
+
if (tokenData.error) throw new Error("OAuth Error: " + tokenData.error);
|
|
300
|
+
const userRes = await fetch("https://discord.com/api/users/@me", {
|
|
301
|
+
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
|
302
|
+
});
|
|
303
|
+
const user = await userRes.json();
|
|
304
|
+
return { ...user, access_token: tokenData.access_token };
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- SLACK LOGIN ---
|
|
310
|
+
slack({ clientId, clientSecret, redirectUri }) {
|
|
311
|
+
return {
|
|
312
|
+
redirect: (res) => {
|
|
313
|
+
const url =
|
|
314
|
+
"https://slack.com/oauth/v2/authorize?" +
|
|
315
|
+
new URLSearchParams({
|
|
316
|
+
client_id: clientId,
|
|
317
|
+
redirect_uri: redirectUri,
|
|
318
|
+
scope: "identity.basic identity.email",
|
|
319
|
+
});
|
|
320
|
+
res.redirect(url);
|
|
321
|
+
},
|
|
322
|
+
callback: async (code) => {
|
|
323
|
+
const tokenRes = await fetch("https://slack.com/api/oauth.v2.access?" +
|
|
324
|
+
new URLSearchParams({
|
|
325
|
+
client_id: clientId,
|
|
326
|
+
client_secret: clientSecret,
|
|
327
|
+
code,
|
|
328
|
+
redirect_uri: redirectUri,
|
|
329
|
+
}));
|
|
330
|
+
const tokenData = await tokenRes.json();
|
|
331
|
+
if (!tokenData.ok) throw new Error("OAuth Error: " + tokenData.error);
|
|
332
|
+
return { ...tokenData, access_token: tokenData.access_token };
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- MICROSOFT LOGIN ---
|
|
338
|
+
microsoft({ clientId, clientSecret, redirectUri }) {
|
|
339
|
+
return {
|
|
340
|
+
redirect: (res) => {
|
|
341
|
+
const url =
|
|
342
|
+
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize?" +
|
|
343
|
+
new URLSearchParams({
|
|
344
|
+
client_id: clientId,
|
|
345
|
+
redirect_uri: redirectUri,
|
|
346
|
+
response_type: "code",
|
|
347
|
+
scope: "User.Read",
|
|
348
|
+
});
|
|
349
|
+
res.redirect(url);
|
|
350
|
+
},
|
|
351
|
+
callback: async (code) => {
|
|
352
|
+
const tokenRes = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
|
353
|
+
method: "POST",
|
|
354
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
355
|
+
body: new URLSearchParams({
|
|
356
|
+
client_id: clientId,
|
|
357
|
+
client_secret: clientSecret,
|
|
358
|
+
code,
|
|
359
|
+
redirect_uri: redirectUri,
|
|
360
|
+
grant_type: "authorization_code",
|
|
361
|
+
}),
|
|
362
|
+
});
|
|
363
|
+
const tokenData = await tokenRes.json();
|
|
364
|
+
if (tokenData.error) throw new Error("OAuth Error: " + tokenData.error);
|
|
365
|
+
return tokenData;
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --- TELEGRAM LOGIN ---
|
|
371
|
+
telegram({ botId, redirectUri }) {
|
|
372
|
+
return {
|
|
373
|
+
redirect: (res) => {
|
|
374
|
+
const url =
|
|
375
|
+
"https://t.me/" + botId + "?start=auth&redirect_uri=" + encodeURIComponent(redirectUri);
|
|
376
|
+
res.redirect(url);
|
|
377
|
+
},
|
|
378
|
+
callback: async (code) => {
|
|
379
|
+
// Telegram uses bot login; typically handled via deep links
|
|
380
|
+
return { code, message: "Telegram login uses deep link auth" };
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// --- WHATSAPP LOGIN ---
|
|
386
|
+
whatsapp({ phoneNumberId, redirectUri }) {
|
|
387
|
+
return {
|
|
388
|
+
redirect: (res) => {
|
|
389
|
+
const url =
|
|
390
|
+
"https://api.whatsapp.com/send?" +
|
|
391
|
+
new URLSearchParams({
|
|
392
|
+
phone: phoneNumberId,
|
|
393
|
+
text: "Please authorize: " + redirectUri,
|
|
394
|
+
});
|
|
395
|
+
res.redirect(url);
|
|
396
|
+
},
|
|
397
|
+
callback: async (code) => {
|
|
398
|
+
// WhatsApp login usually handled via QR / deep link
|
|
399
|
+
return { code, message: "WhatsApp login uses QR/deep link auth" };
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// --- CUSTOM PROVIDER ---
|
|
405
|
+
register(name, fn) {
|
|
406
|
+
if (!name || typeof fn !== "function") throw new Error("Provider registration requires a name and function");
|
|
407
|
+
this.providers[name] = fn;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
use(name, options) {
|
|
411
|
+
const provider = this.providers[name];
|
|
412
|
+
if (!provider) throw new Error(`Provider "${name}" not found`);
|
|
413
|
+
return provider(options);
|
|
414
|
+
}
|
|
415
|
+
|
|
238
416
|
}
|
|
239
417
|
|
|
240
418
|
module.exports = OAuthManager;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
const base64url = require('base64url');
|
|
2
|
+
const crypto = require("crypto");
|
|
3
|
+
const Redis = require('ioredis');
|
|
4
|
+
const cbor = require("cbor");
|
|
5
|
+
|
|
6
|
+
class PasskeyManager {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.rpName = options.rpName || "auth-verify";
|
|
9
|
+
this.storeType = options.storeTokens || "memory";
|
|
10
|
+
this.saveByToMemory = options.saveBy || "id";
|
|
11
|
+
this.ttl = options.passExp || "2m";
|
|
12
|
+
|
|
13
|
+
if (this.storeType === "memory") {
|
|
14
|
+
this.tokenStore = new Map();
|
|
15
|
+
} else if (this.storeType === 'redis') {
|
|
16
|
+
this.redis = new Redis(options.redisUrl || "redis://localhost:6379");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_generateChallenge() {
|
|
21
|
+
return base64url(crypto.randomBytes(32));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_encode(buffer) {
|
|
25
|
+
return base64url(buffer);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_decode(str) {
|
|
29
|
+
return Buffer.from(base64url.toBuffer(str));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_parseTTL() {
|
|
33
|
+
if (typeof this.ttl !== 'string') return this.ttl;
|
|
34
|
+
const ttlValue = parseInt(this.ttl);
|
|
35
|
+
if (this.ttl.endsWith('m')) return ttlValue * 60 * 1000;
|
|
36
|
+
if (this.ttl.endsWith('s')) return ttlValue * 1000;
|
|
37
|
+
throw new Error("TTL must end with 's' or 'm'");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_setWithTTL(key, challengeValue, ttlMs = 2 * 60 * 1000) {
|
|
41
|
+
this.tokenStore.set(key, {
|
|
42
|
+
value: challengeValue,
|
|
43
|
+
expiresAt: Date.now() + ttlMs
|
|
44
|
+
});
|
|
45
|
+
setTimeout(() => this.tokenStore.delete(key), ttlMs);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_coseToPEM(coseKey) {
|
|
49
|
+
const x = coseKey.get(-2);
|
|
50
|
+
const y = coseKey.get(-3);
|
|
51
|
+
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]);
|
|
52
|
+
const pubKeyDER = Buffer.concat([
|
|
53
|
+
Buffer.from("3059301306072A8648CE3D020106082A8648CE3D030107034200", "hex"),
|
|
54
|
+
pubKeyBuffer
|
|
55
|
+
]);
|
|
56
|
+
return "-----BEGIN PUBLIC KEY-----\n" +
|
|
57
|
+
pubKeyDER.toString("base64").match(/.{1,64}/g).join("\n") +
|
|
58
|
+
"\n-----END PUBLIC KEY-----\n";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async _finishAttestation(clientResponse) {
|
|
62
|
+
const { user, challenge } = this._pending;
|
|
63
|
+
|
|
64
|
+
const clientDataJSON = JSON.parse(
|
|
65
|
+
Buffer.from(clientResponse.response.clientDataJSON, 'base64').toString()
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if (clientDataJSON.challenge !== challenge)
|
|
69
|
+
throw new Error("Challenge mismatch");
|
|
70
|
+
|
|
71
|
+
const attestationBuffer = Buffer.from(clientResponse.response.attestationObject, 'base64');
|
|
72
|
+
const attestationStruct = await cbor.decodeFirst(attestationBuffer);
|
|
73
|
+
|
|
74
|
+
// Parse authData
|
|
75
|
+
const authData = attestationStruct.authData;
|
|
76
|
+
const rpIdHash = authData.slice(0, 32);
|
|
77
|
+
const flags = authData[32];
|
|
78
|
+
const signCount = authData.readUInt32BE(33);
|
|
79
|
+
|
|
80
|
+
const credIdLen = authData.readUInt16BE(37);
|
|
81
|
+
const credentialId = authData.slice(39, 39 + credIdLen);
|
|
82
|
+
|
|
83
|
+
const coseKeyBytes = authData.slice(39 + credIdLen);
|
|
84
|
+
const coseKey = await cbor.decodeFirst(coseKeyBytes);
|
|
85
|
+
const publicKeyPEM = this._coseToPEM(coseKey);
|
|
86
|
+
|
|
87
|
+
// Save credential in user object
|
|
88
|
+
user.credentials = user.credentials || [];
|
|
89
|
+
user.credentials.push({
|
|
90
|
+
id: this._encode(credentialId),
|
|
91
|
+
publicKey: publicKeyPEM,
|
|
92
|
+
signCount
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return { status: "ok", user, credentialId: this._encode(credentialId) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async _finishAssertion(clientResponse) {
|
|
99
|
+
const { user, challenge } = this._pending;
|
|
100
|
+
|
|
101
|
+
const clientDataJSON = JSON.parse(
|
|
102
|
+
Buffer.from(clientResponse.response.clientDataJSON, 'base64').toString()
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (clientDataJSON.challenge !== challenge)
|
|
106
|
+
throw new Error("Challenge mismatch");
|
|
107
|
+
|
|
108
|
+
const credentialId = this._encode(Buffer.from(clientResponse.id, 'base64'));
|
|
109
|
+
const credential = user.credentials?.find(c => c.id === credentialId);
|
|
110
|
+
if (!credential) throw new Error("Unknown credential");
|
|
111
|
+
|
|
112
|
+
const signature = Buffer.from(clientResponse.response.signature, 'base64');
|
|
113
|
+
const authData = Buffer.from(clientResponse.response.authenticatorData, 'base64');
|
|
114
|
+
|
|
115
|
+
const verify = crypto.createVerify('SHA256');
|
|
116
|
+
const clientHash = crypto.createHash('sha256').update(Buffer.from(clientResponse.response.clientDataJSON, 'base64')).digest();
|
|
117
|
+
verify.update(Buffer.concat([authData, clientHash]));
|
|
118
|
+
|
|
119
|
+
const verified = verify.verify(credential.publicKey, signature);
|
|
120
|
+
|
|
121
|
+
return { status: verified ? "ok" : "failed", user };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async register(user) {
|
|
125
|
+
const challenge = this._generateChallenge();
|
|
126
|
+
|
|
127
|
+
if (this.storeType === "memory") {
|
|
128
|
+
this._setWithTTL(user[this.saveByToMemory], challenge, this._parseTTL());
|
|
129
|
+
} else if (this.storeType === "redis") {
|
|
130
|
+
await this.redis.set(user[this.saveByToMemory], challenge, "PX", this._parseTTL());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this._pending = { type: "register", user, challenge };
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async login(user) {
|
|
138
|
+
const challenge = this._generateChallenge();
|
|
139
|
+
|
|
140
|
+
if (this.storeType === "memory") {
|
|
141
|
+
this._setWithTTL(user[this.saveByToMemory], challenge, this._parseTTL());
|
|
142
|
+
} else if (this.storeType === "redis") {
|
|
143
|
+
await this.redis.set(user[this.saveByToMemory], challenge, "PX", this._parseTTL());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this._pending = { type: "login", user, challenge };
|
|
147
|
+
return this;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getOptions() {
|
|
151
|
+
if (!this._pending) throw new Error("No pending operation");
|
|
152
|
+
|
|
153
|
+
const { type, user, challenge } = this._pending;
|
|
154
|
+
|
|
155
|
+
if (type === "register") {
|
|
156
|
+
return {
|
|
157
|
+
challenge,
|
|
158
|
+
rp: { name: this.rpName },
|
|
159
|
+
user: {
|
|
160
|
+
id: this._encode(Buffer.from(user.id)),
|
|
161
|
+
name: user.username,
|
|
162
|
+
displayName: user.username
|
|
163
|
+
},
|
|
164
|
+
pubKeyCredParams: [{ alg: -7, type: "public-key" }]
|
|
165
|
+
};
|
|
166
|
+
} else if (type === "login") {
|
|
167
|
+
return {
|
|
168
|
+
challenge,
|
|
169
|
+
allowCredentials: user.credentials?.map(c => ({
|
|
170
|
+
id: this._decode(c.id),
|
|
171
|
+
type: "public-key"
|
|
172
|
+
})) || [],
|
|
173
|
+
timeout: 60000
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async finish(clientResponse) {
|
|
179
|
+
if (!this._pending) throw new Error("No pending operation");
|
|
180
|
+
|
|
181
|
+
let result;
|
|
182
|
+
if (this._pending.type === "register") {
|
|
183
|
+
result = await this._finishAttestation(clientResponse);
|
|
184
|
+
} else if (this._pending.type === "login") {
|
|
185
|
+
result = await this._finishAssertion(clientResponse);
|
|
186
|
+
} else {
|
|
187
|
+
throw new Error("Unknown pending operation");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
this._pending = null;
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = PasskeyManager;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const request = require("supertest");
|
|
2
|
+
const express = require("express");
|
|
3
|
+
const AuthVerify = require("../index"); // path to your AuthVerify file
|
|
4
|
+
|
|
5
|
+
describe("AuthVerify protect() with custom options", () => {
|
|
6
|
+
let app;
|
|
7
|
+
let auth;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
auth = new AuthVerify({ jwtSecret: "test_secret" });
|
|
11
|
+
|
|
12
|
+
app = express();
|
|
13
|
+
|
|
14
|
+
// route that signs a token + sets cookie
|
|
15
|
+
app.get("/login", async (req, res) => {
|
|
16
|
+
const token = await auth.jwt.sign({ id: 123 }, "30m", { res });
|
|
17
|
+
res.json({ token });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// standard protected route
|
|
21
|
+
app.get("/profile", auth.jwt.protect(), (req, res) => {
|
|
22
|
+
res.json({ ok: true, user: req.user });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// route with custom cookie
|
|
26
|
+
app.get(
|
|
27
|
+
"/cookie",
|
|
28
|
+
auth.jwt.protect({ cookieName: "my_cookie" }),
|
|
29
|
+
(req, res) => {
|
|
30
|
+
res.json({ ok: true, user: req.user });
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// route with custom header
|
|
35
|
+
app.get(
|
|
36
|
+
"/header",
|
|
37
|
+
auth.jwt.protect({ headerName: "x-access-token" }),
|
|
38
|
+
(req, res) => {
|
|
39
|
+
res.json({ ok: true, user: req.user });
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// route with custom extractor
|
|
44
|
+
app.get(
|
|
45
|
+
"/query",
|
|
46
|
+
auth.jwt.protect({
|
|
47
|
+
extractor: (req) => req.query.token
|
|
48
|
+
}),
|
|
49
|
+
(req, res) => {
|
|
50
|
+
res.json({ ok: true, user: req.user });
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("custom cookie name works", async () => {
|
|
56
|
+
const token = await auth.jwt.sign({ id: 555 });
|
|
57
|
+
const res = await request(app)
|
|
58
|
+
.get("/cookie")
|
|
59
|
+
.set("Cookie", `my_cookie=${token}`);
|
|
60
|
+
expect(res.status).toBe(200);
|
|
61
|
+
expect(res.body.user.id).toBe(555);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("custom header name works", async () => {
|
|
65
|
+
const token = await auth.jwt.sign({ id: 777 });
|
|
66
|
+
const res = await request(app)
|
|
67
|
+
.get("/header")
|
|
68
|
+
.set("x-access-token", token);
|
|
69
|
+
expect(res.status).toBe(200);
|
|
70
|
+
expect(res.body.user.id).toBe(777);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("custom extractor works (query param)", async () => {
|
|
74
|
+
const token = await auth.jwt.sign({ id: 999 });
|
|
75
|
+
const res = await request(app)
|
|
76
|
+
.get(`/query?token=${token}`);
|
|
77
|
+
expect(res.status).toBe(200);
|
|
78
|
+
expect(res.body.user.id).toBe(999);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("default fallback still works", async () => {
|
|
82
|
+
const token = await auth.jwt.sign({ id: 123 });
|
|
83
|
+
const res = await request(app)
|
|
84
|
+
.get("/profile")
|
|
85
|
+
.set("Authorization", `Bearer ${token}`);
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
expect(res.body.user.id).toBe(123);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// __tests__/authVerify.oauth.direct.test.js
|
|
2
|
+
const AuthVerify = require("../index"); // your wrapper
|
|
3
|
+
|
|
4
|
+
describe("AuthVerify OAuth - Direct Provider Tests", () => {
|
|
5
|
+
let auth;
|
|
6
|
+
let res;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
auth = new AuthVerify();
|
|
10
|
+
res = { redirect: jest.fn() }; // mock Express response
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
global.fetch = jest.fn(); // mock fetch
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
jest.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("Apple redirect and callback", async () => {
|
|
22
|
+
const apple = auth.oauth.apple({
|
|
23
|
+
clientId: "apple_id",
|
|
24
|
+
clientSecret: "apple_secret",
|
|
25
|
+
redirectUri: "http://localhost/callback",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
apple.redirect(res);
|
|
29
|
+
expect(res.redirect).toHaveBeenCalled();
|
|
30
|
+
expect(res.redirect.mock.calls[0][0]).toContain("https://appleid.apple.com/auth/authorize");
|
|
31
|
+
|
|
32
|
+
global.fetch.mockResolvedValueOnce({ json: async () => ({ access_token: "apple_token" }) });
|
|
33
|
+
const tokenData = await apple.callback("code123");
|
|
34
|
+
expect(tokenData.access_token).toBe("apple_token");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("Discord redirect and callback", async () => {
|
|
38
|
+
const discord = auth.oauth.discord({
|
|
39
|
+
clientId: "discord_id",
|
|
40
|
+
clientSecret: "discord_secret",
|
|
41
|
+
redirectUri: "http://localhost/callback",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
discord.redirect(res);
|
|
45
|
+
expect(res.redirect).toHaveBeenCalled();
|
|
46
|
+
|
|
47
|
+
global.fetch
|
|
48
|
+
.mockResolvedValueOnce({ json: async () => ({ access_token: "discord_token" }) })
|
|
49
|
+
.mockResolvedValueOnce({ json: async () => ({ id: "discord_123", username: "user" }) });
|
|
50
|
+
|
|
51
|
+
const userData = await discord.callback("code123");
|
|
52
|
+
expect(userData.access_token).toBe("discord_token");
|
|
53
|
+
expect(userData.id).toBe("discord_123");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("Telegram callback returns message", async () => {
|
|
57
|
+
const telegram = auth.oauth.telegram({ botId: "bot123", redirectUri: "http://localhost/callback" });
|
|
58
|
+
const tResult = await telegram.callback("code123");
|
|
59
|
+
expect(tResult.message).toContain("Telegram login");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("WhatsApp callback returns message", async () => {
|
|
63
|
+
const whatsapp = auth.oauth.whatsapp({ phoneNumberId: "12345", redirectUri: "http://localhost/callback" });
|
|
64
|
+
const wResult = await whatsapp.callback("code456");
|
|
65
|
+
expect(wResult.message).toContain("WhatsApp login");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("Slack and Microsoft redirect URLs", () => {
|
|
69
|
+
const slack = auth.oauth.slack({ clientId: "slack_id", redirectUri: "http://localhost/callback" });
|
|
70
|
+
slack.redirect(res);
|
|
71
|
+
expect(res.redirect).toHaveBeenCalled();
|
|
72
|
+
|
|
73
|
+
const ms = auth.oauth.microsoft({ clientId: "ms_id", redirectUri: "http://localhost/callback" });
|
|
74
|
+
ms.redirect(res);
|
|
75
|
+
expect(res.redirect).toHaveBeenCalledTimes(2);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const AuthVerify = require("../index");
|
|
2
|
+
const base64url = require("base64url");
|
|
3
|
+
|
|
4
|
+
describe("AuthVerify Passkey Flow (mocked for Jest)", () => {
|
|
5
|
+
let auth;
|
|
6
|
+
const user = { id: "123", username: "testuser", credentials: [] };
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
auth = new AuthVerify({
|
|
10
|
+
storeTokens: "memory",
|
|
11
|
+
passExp: "1m",
|
|
12
|
+
rpName: "TestApp",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Mock _finishAttestation to bypass CBOR decoding
|
|
16
|
+
auth.passkey._finishAttestation = async (clientResponse) => ({
|
|
17
|
+
status: "ok",
|
|
18
|
+
user,
|
|
19
|
+
challengeVerified: true,
|
|
20
|
+
rawAuthData: "FAKE_AUTHDATA",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Mock _finishAssertion to bypass signature verification
|
|
24
|
+
auth.passkey._finishAssertion = async (clientResponse) => {
|
|
25
|
+
const { user, challenge } = auth.passkey._pending;
|
|
26
|
+
const clientDataJSON = JSON.parse(
|
|
27
|
+
Buffer.from(clientResponse.response.clientDataJSON, "base64").toString()
|
|
28
|
+
);
|
|
29
|
+
if (clientDataJSON.challenge !== challenge)
|
|
30
|
+
throw new Error("Challenge mismatch");
|
|
31
|
+
|
|
32
|
+
// Find credential by exact id
|
|
33
|
+
const credentialId = clientResponse.id;
|
|
34
|
+
const credential = user.credentials?.find(c => c.id === credentialId);
|
|
35
|
+
if (!credential) throw new Error("Unknown credential");
|
|
36
|
+
|
|
37
|
+
return { status: "ok", user };
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("register() should store challenge and be chainable", async () => {
|
|
42
|
+
const chainable = await auth.passkey.register(user);
|
|
43
|
+
expect(chainable).toBe(auth.passkey);
|
|
44
|
+
expect(auth.passkey._pending.type).toBe("register");
|
|
45
|
+
expect(auth.passkey._pending.user).toEqual(user);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("getOptions() after register() returns correct options", async () => {
|
|
49
|
+
await auth.passkey.register(user);
|
|
50
|
+
const options = auth.passkey.getOptions();
|
|
51
|
+
expect(options.rp.name).toBe("TestApp");
|
|
52
|
+
expect(options.user.id).toBe(base64url(Buffer.from(user.id)));
|
|
53
|
+
expect(options.pubKeyCredParams[0].type).toBe("public-key");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("finish() after register() verifies attestation", async () => {
|
|
57
|
+
await auth.passkey.register(user);
|
|
58
|
+
const pendingChallenge = auth.passkey._pending.challenge;
|
|
59
|
+
|
|
60
|
+
const clientResponse = {
|
|
61
|
+
response: {
|
|
62
|
+
clientDataJSON: Buffer.from(
|
|
63
|
+
JSON.stringify({ challenge: pendingChallenge })
|
|
64
|
+
).toString("base64"),
|
|
65
|
+
attestationObject: "FAKE_CBOR",
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const result = await auth.passkey.finish(clientResponse);
|
|
70
|
+
expect(result.status).toBe("ok");
|
|
71
|
+
expect(result.challengeVerified).toBe(true);
|
|
72
|
+
expect(result.user).toEqual(user);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("login() should store challenge and allow credentials", async () => {
|
|
76
|
+
// Add a fake credential
|
|
77
|
+
user.credentials.push({
|
|
78
|
+
id: base64url(Buffer.from("cred1")),
|
|
79
|
+
publicKey: "FAKE_KEY",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await auth.passkey.login(user);
|
|
83
|
+
const options = auth.passkey.getOptions();
|
|
84
|
+
|
|
85
|
+
expect(auth.passkey._pending.type).toBe("login");
|
|
86
|
+
expect(options.allowCredentials.length).toBe(1);
|
|
87
|
+
expect(Buffer.isBuffer(options.allowCredentials[0].id)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("finish() after login() verifies assertion challenge", async () => {
|
|
91
|
+
user.credentials.push({
|
|
92
|
+
id: base64url(Buffer.from("cred1")),
|
|
93
|
+
publicKey: "FAKE_KEY",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await auth.passkey.login(user);
|
|
97
|
+
const challenge = auth.passkey._pending.challenge;
|
|
98
|
+
|
|
99
|
+
const clientResponse = {
|
|
100
|
+
id: user.credentials[0].id,
|
|
101
|
+
response: {
|
|
102
|
+
clientDataJSON: Buffer.from(JSON.stringify({ challenge })).toString(
|
|
103
|
+
"base64"
|
|
104
|
+
),
|
|
105
|
+
authenticatorData: Buffer.from("rawAuthData").toString("base64"),
|
|
106
|
+
signature: Buffer.from("signature").toString("base64"),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const result = await auth.passkey.finish(clientResponse);
|
|
111
|
+
expect(result.status).toBe("ok"); // mocked result
|
|
112
|
+
expect(result.user).toEqual(user);
|
|
113
|
+
});
|
|
114
|
+
});
|