auth-verify 1.7.0 → 1.8.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/authverify.client.js +103 -0
- package/index.js +6 -1
- package/package.json +6 -3
- package/readme.md +304 -31
- package/src/magiclink/index.js +114 -0
- package/src/otp/index.js +4 -4
- package/tests/magiclinkmanager.test.js +67 -0
package/authverify.client.js
CHANGED
|
@@ -68,4 +68,107 @@ window.AuthVerify = class AuthVerify {
|
|
|
68
68
|
async verify(code){
|
|
69
69
|
return this.data({code});
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
// -----------------------------
|
|
73
|
+
// Helper: decode Base64URL to Uint8Array
|
|
74
|
+
// -----------------------------
|
|
75
|
+
base64urlToUint8Array(base64url) {
|
|
76
|
+
if (!base64url) throw new Error("Missing Base64URL data");
|
|
77
|
+
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
78
|
+
while (base64.length % 4) base64 += '=';
|
|
79
|
+
const str = atob(base64);
|
|
80
|
+
return new Uint8Array([...str].map(c => c.charCodeAt(0)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
start(route){
|
|
84
|
+
this.startRegisterApi = route;
|
|
85
|
+
return this;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
finish(route){
|
|
89
|
+
this.finishRegisterApi = route;
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// -----------------------------
|
|
94
|
+
// REGISTER PASSKEY (full flow)
|
|
95
|
+
// -----------------------------
|
|
96
|
+
async registerPasskey(user) {
|
|
97
|
+
try {
|
|
98
|
+
// 1️⃣ Get registration options from backend
|
|
99
|
+
const publicKey = await this.post(`${this.startRegisterApi}`).data({user});
|
|
100
|
+
|
|
101
|
+
// 2️⃣ Decode challenge & user.id automatically
|
|
102
|
+
publicKey.challenge = this.base64urlToUint8Array(publicKey.challenge);
|
|
103
|
+
publicKey.user.id = this.base64urlToUint8Array(publicKey.user.id);
|
|
104
|
+
|
|
105
|
+
// 3️⃣ Ask browser to create credential
|
|
106
|
+
const credential = await navigator.credentials.create({ publicKey });
|
|
107
|
+
|
|
108
|
+
// 4️⃣ Convert ArrayBuffers to base64
|
|
109
|
+
const data = {
|
|
110
|
+
id: credential.id,
|
|
111
|
+
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))),
|
|
112
|
+
type: credential.type,
|
|
113
|
+
response: {
|
|
114
|
+
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))),
|
|
115
|
+
attestationObject: btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject))),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// 5️⃣ Send credential to backend to finish registration
|
|
120
|
+
const result = await this.post(`${this.finishRegisterApi}`).data(data);
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error("[AuthVerify registerPasskey]", err);
|
|
126
|
+
return { error: true, message: err.message };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// -----------------------------
|
|
131
|
+
// LOGIN / AUTHENTICATE PASSKEY
|
|
132
|
+
// -----------------------------
|
|
133
|
+
async loginPasskey(user) {
|
|
134
|
+
try {
|
|
135
|
+
// 1️⃣ Get assertion options (challenge) from backend
|
|
136
|
+
const publicKey = await this.post(`${this.startRegisterApi}`).data({ user, login: true });
|
|
137
|
+
|
|
138
|
+
// 2️⃣ Decode Base64URL fields
|
|
139
|
+
publicKey.challenge = this.base64urlToUint8Array(publicKey.challenge);
|
|
140
|
+
publicKey.allowCredentials = publicKey.allowCredentials.map(cred => ({
|
|
141
|
+
...cred,
|
|
142
|
+
id: this.base64urlToUint8Array(cred.id)
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
// 3️⃣ Ask browser to get credential
|
|
146
|
+
const credential = await navigator.credentials.get({ publicKey });
|
|
147
|
+
|
|
148
|
+
// 4️⃣ Convert ArrayBuffers to Base64
|
|
149
|
+
const data = {
|
|
150
|
+
id: credential.id,
|
|
151
|
+
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))),
|
|
152
|
+
type: credential.type,
|
|
153
|
+
response: {
|
|
154
|
+
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))),
|
|
155
|
+
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(credential.response.authenticatorData))),
|
|
156
|
+
signature: btoa(String.fromCharCode(...new Uint8Array(credential.response.signature))),
|
|
157
|
+
userHandle: credential.response.userHandle
|
|
158
|
+
? btoa(String.fromCharCode(...new Uint8Array(credential.response.userHandle)))
|
|
159
|
+
: null,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// 5️⃣ Send assertion to backend for verification
|
|
164
|
+
const result = await this.post(`${this.finishRegisterApi}`).data(data);
|
|
165
|
+
|
|
166
|
+
return result;
|
|
167
|
+
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.error("[AuthVerify loginPasskey]", err);
|
|
170
|
+
return { error: true, message: err.message };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
71
174
|
}
|
package/index.js
CHANGED
|
@@ -4,6 +4,7 @@ const SessionManager = require("./src/session");
|
|
|
4
4
|
const OAuthManager = require("./src/oauth");
|
|
5
5
|
const TOTPManager = require("./src/totp");
|
|
6
6
|
const PasskeyManager = require("./src/passkey");
|
|
7
|
+
const MagicLinkManager = require("./src/magiclink");
|
|
7
8
|
|
|
8
9
|
class AuthVerify {
|
|
9
10
|
constructor(options = {}) {
|
|
@@ -22,7 +23,10 @@ class AuthVerify {
|
|
|
22
23
|
},
|
|
23
24
|
rpName = "auth-verify",
|
|
24
25
|
saveBy = "id",
|
|
25
|
-
passExp = "2m"
|
|
26
|
+
passExp = "2m",
|
|
27
|
+
mlSecret = "ml_secret",
|
|
28
|
+
mlExpiry = "5m",
|
|
29
|
+
appUrl = "https://yourapp.com"
|
|
26
30
|
} = options;
|
|
27
31
|
|
|
28
32
|
// ✅ Ensure cookieName and secret always exist
|
|
@@ -50,6 +54,7 @@ class AuthVerify {
|
|
|
50
54
|
this.senders = new Map();
|
|
51
55
|
|
|
52
56
|
this.passkey = new PasskeyManager({rpName, storeTokens, saveBy, passExp});
|
|
57
|
+
this.magic = new MagicLinkManager({mlSecret, mlExpiry, appUrl, storeTokens})
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
// --- Session helpers ---
|
package/package.json
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
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 generating TOTP codes and QR codes. And handling JWT with Cookies. And also handling passwordless logins with passkeys/
|
|
15
|
+
"version": "1.8.0",
|
|
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. And also handling passwordless logins with passkeys/webauth. And handling magiclink passwordless logins",
|
|
17
17
|
"main": "index.js",
|
|
18
18
|
"scripts": {
|
|
19
19
|
"test": "jest --runInBand"
|
|
@@ -45,7 +45,10 @@
|
|
|
45
45
|
"jsonwebtoken",
|
|
46
46
|
"totp",
|
|
47
47
|
"google-authenticator",
|
|
48
|
-
"signin"
|
|
48
|
+
"signin",
|
|
49
|
+
"webauthn",
|
|
50
|
+
"passkey",
|
|
51
|
+
"passwordless"
|
|
49
52
|
],
|
|
50
53
|
"author": "Jahongir Sobirov",
|
|
51
54
|
"license": "MIT",
|
package/readme.md
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
# auth-verify
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- ✅ Chainable OTP workflow with cooldowns, max attempts, and resend functionality.
|
|
3
|
+
**AuthVerify** is a modular authentication library for Node.js, providing JWT, OTP, TOTP, Passkeys (WebAuthn), Magic Links, Sessions, and OAuth helpers. You can easily register custom senders for OTPs or notifications.
|
|
4
|
+
- [Installation](https://github.com/Jahongir2007/auth-verify/blob/main/docs/docs.md#-installation)
|
|
5
|
+
- [Initialization](https://github.com/Jahongir2007/auth-verify/blob/main/docs/docs.md#-example-initialize-library-commonjs)
|
|
6
|
+
- [JWT](https://github.com/Jahongir2007/auth-verify/blob/main/docs/docs.md#-jwt-usage)
|
|
7
|
+
- [OTP](https://github.com/Jahongir2007/auth-verify/blob/main/docs/docs.md#-otp-email--sms--telegram--custom-sender)
|
|
8
|
+
- [TOTP](https://github.com/Jahongir2007/auth-verify/blob/main/docs/docs.md#-totp-time-based-one-time-passwords--google-authenticator-support-v140)
|
|
9
|
+
- [Passkeys](https://github.com/Jahongir2007/auth-verify/blob/main/docs/docs.md#%EF%B8%8F-passkey-webauthn-v161)
|
|
10
|
+
- [Auth-Verify Frontend SDK](https://github.com/Jahongir2007/auth-verify/blob/main/docs/docs.md#auth-verify-client)
|
|
11
|
+
- [OAuth](https://github.com/Jahongir2007/auth-verify/blob/main/docs/docs.md#-oauth-20-integration-v120)
|
|
12
|
+
- [Magic Links](https://github.com/Jahongir2007/auth-verify/blob/main/docs/docs.md#-magiclink-passwordless-login-new-in-v180)
|
|
13
|
+
- [Custom Senders](https://github.com/Jahongir2007/auth-verify/blob/main/docs/docs.md#developer-extensibility-custom-senders)
|
|
14
|
+
- [Session Management](https://github.com/Jahongir2007/auth-verify/blob/main/docs/docs.md#sessionmanager)
|
|
16
15
|
---
|
|
17
16
|
|
|
18
17
|
## 🧩 Installation
|
|
@@ -44,13 +43,35 @@ npm install auth-verify
|
|
|
44
43
|
const AuthVerify = require('auth-verify');
|
|
45
44
|
|
|
46
45
|
const auth = new AuthVerify({
|
|
47
|
-
jwtSecret:
|
|
48
|
-
|
|
49
|
-
otpExpiry:
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
jwtSecret: 'your_jwt_secret',
|
|
47
|
+
cookieName: 'jwt_token',
|
|
48
|
+
otpExpiry: 300, // in seconds
|
|
49
|
+
storeTokens: 'memory', // or 'redis'
|
|
50
|
+
redisUrl: 'redis://localhost:6379',
|
|
51
|
+
totp: { digits: 6, step: 30, alg: 'SHA1' },
|
|
52
|
+
rpName: 'myApp',
|
|
53
|
+
passExp: '2m',
|
|
54
|
+
mlSecret: 'ml_secret',
|
|
55
|
+
mlExpiry: '5m',
|
|
56
|
+
appUrl: 'https://yourapp.com'
|
|
52
57
|
});
|
|
53
58
|
```
|
|
59
|
+
### Options explained:
|
|
60
|
+
|
|
61
|
+
| Option | Default | Description |
|
|
62
|
+
| ------------- | -------------------------------------- | ---------------------------------------- |
|
|
63
|
+
| `jwtSecret` | `"jwt_secret"` | Secret key for JWT signing |
|
|
64
|
+
| `cookieName` | `"jwt_token"` | Cookie name for JWT storage |
|
|
65
|
+
| `otpExpiry` | `300` | OTP expiration in seconds |
|
|
66
|
+
| `storeTokens` | `"memory"` | Token storage type (`memory` or `redis`) |
|
|
67
|
+
| `redisUrl` | `undefined` | Redis connection string if using Redis |
|
|
68
|
+
| `totp` | `{ digits: 6, step: 30, alg: 'SHA1' }` | TOTP configuration |
|
|
69
|
+
| `rpName` | `"auth-verify"` | Relying party name for Passkeys |
|
|
70
|
+
| `passExp` | `"2m"` | Passkey expiration duration |
|
|
71
|
+
| `mlSecret` | `"ml_secret"` | Magic link secret |
|
|
72
|
+
| `mlExpiry` | `"5m"` | Magic link expiration duration |
|
|
73
|
+
| `appUrl` | `"https://yourapp.com"` | App base URL for Magic Links |
|
|
74
|
+
|
|
54
75
|
|
|
55
76
|
---
|
|
56
77
|
|
|
@@ -389,7 +410,7 @@ auth.otp.verify({ check: 'user@example.com', code: '123456' }, (err, isValid)=>{
|
|
|
389
410
|
|
|
390
411
|
---
|
|
391
412
|
|
|
392
|
-
## 🗝️ Passkey (WebAuthn) (
|
|
413
|
+
## 🗝️ Passkey (WebAuthn) (v1.6.1+)
|
|
393
414
|
|
|
394
415
|
`AuthVerify` includes a `PasskeyManager` class to handle passwordless login using WebAuthn / passkeys. You can **register** users, **verify login**, and manage **challenges** safely.
|
|
395
416
|
|
|
@@ -583,7 +604,7 @@ console.log("TOTP:", token);
|
|
|
583
604
|
```
|
|
584
605
|
### verify a code entered by user
|
|
585
606
|
```js
|
|
586
|
-
const ok = auth.totp.verify(
|
|
607
|
+
const ok = auth.totp.verify(secret, token);
|
|
587
608
|
console.log(ok); // true or false
|
|
588
609
|
```
|
|
589
610
|
### example real flow
|
|
@@ -599,7 +620,7 @@ const qr = await auth.totp.qrcode(uri);
|
|
|
599
620
|
const token = req.body.code;
|
|
600
621
|
|
|
601
622
|
// Verify
|
|
602
|
-
if (auth.totp.verify(
|
|
623
|
+
if (auth.totp.verify(secret, token )) {
|
|
603
624
|
// enable 2FA
|
|
604
625
|
}
|
|
605
626
|
```
|
|
@@ -614,7 +635,10 @@ It works with your backend APIs to:
|
|
|
614
635
|
- Verify user OTP codes
|
|
615
636
|
- Request JWT tokens from backend
|
|
616
637
|
- Send authenticated requests easily
|
|
617
|
-
|
|
638
|
+
- **Register a passkey** (create a new credential)
|
|
639
|
+
- **Login with a passkey** (authenticate existing credential)
|
|
640
|
+
- Handle **Base64URL decoding**, **ArrayBuffer conversion**, and **backend communication** automatically
|
|
641
|
+
- Easily integrate with your Node.js backend using `auth-verify`
|
|
618
642
|
Works like jQuery: just include the script in HTML, no module or bundler needed.
|
|
619
643
|
|
|
620
644
|
## 2️⃣ Installation
|
|
@@ -669,15 +693,63 @@ console.log(profile);
|
|
|
669
693
|
- `auth.header()` returns `{ Authorization: "Bearer <jwt>" }`
|
|
670
694
|
- Easy to attach JWT to any request
|
|
671
695
|
|
|
696
|
+
### Passkey part (new in v1.8.0)
|
|
697
|
+
#### API Methods
|
|
698
|
+
##### `start(route)`
|
|
699
|
+
Sets the backend endpoint to start a **registration or login flow**.
|
|
700
|
+
```js
|
|
701
|
+
auth.start('/api/register/start'); // registration start
|
|
702
|
+
auth.start('/api/login/start'); // login start
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
#### `finish(route)`
|
|
706
|
+
Sets the backend endpoint to **finish the flow** (verify credential/assertion).
|
|
707
|
+
```js
|
|
708
|
+
auth.finish('/api/register/finish'); // registration finish
|
|
709
|
+
auth.finish('/api/login/finish'); // login finish
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
#### `registerPasskey(user)`
|
|
713
|
+
Registers a new passkey for the user.
|
|
714
|
+
##### Parameters:
|
|
715
|
+
| Param | Type | Description |
|
|
716
|
+
| ----- | ------ | ---------------------------------------------------------------------- |
|
|
717
|
+
| user | Object | `{ id: "user1", username: "john_doe" }` — user info to send to backend |
|
|
718
|
+
|
|
719
|
+
##### Returns:
|
|
720
|
+
`Promise<Object>` — result from backend (`{ success: true/false, message: "..." }`)
|
|
721
|
+
##### Example:
|
|
722
|
+
```js
|
|
723
|
+
auth.start('/api/register/start').finish('/api/register/finish');
|
|
724
|
+
|
|
725
|
+
const result = await auth.registerPasskey({ id: 'user1', username: 'john_doe' });
|
|
726
|
+
|
|
727
|
+
if(result.success) alert("Passkey registered!");
|
|
728
|
+
else alert("Error: " + result.message);
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
##### What it does internally:
|
|
732
|
+
1. Calls `/start` endpoint → gets assertion options.
|
|
733
|
+
2. Decodes `challenge` and `allowCredentials[].id` from Base64URL → Uint8Array.
|
|
734
|
+
3. Calls `navigator.credentials.get({ publicKey })`.
|
|
735
|
+
4. Converts ArrayBuffers to Base64.
|
|
736
|
+
5. Sends assertion to `/finish` endpoint for verification.
|
|
737
|
+
#### `base64urlToUint8Array(base64url)`
|
|
738
|
+
Helper to convert Base64URL string to `Uint8Array`.
|
|
739
|
+
Used internally in registration & login flow. Devs can use it for custom WebAuthn handling if needed.
|
|
672
740
|
### 8️⃣ Method Summary
|
|
673
|
-
| Method
|
|
674
|
-
|
|
|
675
|
-
| `get(url)`
|
|
676
|
-
| `post(url)`
|
|
677
|
-
| `qr()`
|
|
678
|
-
| `data(payload)`
|
|
679
|
-
| `verify(code)`
|
|
680
|
-
| `header()`
|
|
741
|
+
| Method | Description |
|
|
742
|
+
| ----------------------- | ---------------------------------------------------------------------------------------------- |
|
|
743
|
+
| `get(url)` | Set GET endpoint |
|
|
744
|
+
| `post(url)` | Set POST endpoint |
|
|
745
|
+
| `qr()` | Fetch QR from backend and display |
|
|
746
|
+
| `data(payload)` | Send payload to backend; stores JWT if returned |
|
|
747
|
+
| `verify(code)` | Send OTP code to backend |
|
|
748
|
+
| `header()` | Return JWT auth header object |
|
|
749
|
+
| `start(route)` | Set backend endpoint to **start registration or login** |
|
|
750
|
+
| `finish(route)` | Set backend endpoint to **finish registration or login** |
|
|
751
|
+
| `registerPasskey(user)` | Full registration flow: fetch challenge, decode, create credential in browser, send to backend |
|
|
752
|
+
| `loginPasskey(user)` | Full login flow: fetch assertion, decode, get credential from browser, send to backend |
|
|
681
753
|
|
|
682
754
|
### 9️⃣ Example HTML
|
|
683
755
|
```html
|
|
@@ -686,7 +758,7 @@ console.log(profile);
|
|
|
686
758
|
<button id="getQRBtn">Get QR</button>
|
|
687
759
|
<button id="sendBtn">Send Data</button>
|
|
688
760
|
|
|
689
|
-
<script src="authverify.client.js"></script>
|
|
761
|
+
<script src="https://cdn.jsdelivr.net/gh/jahongir2007/auth-verify/authverify.client.js"></script>
|
|
690
762
|
<script>
|
|
691
763
|
const qrImage = document.getElementById('qrImage');
|
|
692
764
|
const responseDiv = document.getElementById('response');
|
|
@@ -703,10 +775,50 @@ document.getElementById('sendBtn').addEventListener('click', async () => {
|
|
|
703
775
|
</script>
|
|
704
776
|
```
|
|
705
777
|
|
|
778
|
+
### Passkey example
|
|
779
|
+
```html
|
|
780
|
+
<!DOCTYPE html>
|
|
781
|
+
<html>
|
|
782
|
+
<head>
|
|
783
|
+
<title>AuthVerify Demo</title>
|
|
784
|
+
</head>
|
|
785
|
+
<body>
|
|
786
|
+
<h1>AuthVerify Passkey Demo</h1>
|
|
787
|
+
<button id="register">Register Passkey</button>
|
|
788
|
+
<button id="login">Login with Passkey</button>
|
|
789
|
+
|
|
790
|
+
<script src="https://cdn.jsdelivr.net/gh/jahongir2007/auth-verify/authverify.client.js"></script>
|
|
791
|
+
<script>
|
|
792
|
+
const auth = new AuthVerify({ apiBase: "http://localhost:3000" });
|
|
793
|
+
|
|
794
|
+
// Registration setup
|
|
795
|
+
auth.start('/api/register/start').finish('/api/register/finish');
|
|
796
|
+
document.getElementById('register').addEventListener('click', async () => {
|
|
797
|
+
const result = await auth.registerPasskey({ id: 'user1', username: 'john_doe' });
|
|
798
|
+
alert(result.message);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// Login setup
|
|
802
|
+
auth.start('/api/login/start').finish('/api/login/finish');
|
|
803
|
+
document.getElementById('login').addEventListener('click', async () => {
|
|
804
|
+
const result = await auth.loginPasskey({ id: 'user1', username: 'john_doe' });
|
|
805
|
+
alert(result.message);
|
|
806
|
+
});
|
|
807
|
+
</script>
|
|
808
|
+
</body>
|
|
809
|
+
</html>
|
|
810
|
+
```
|
|
811
|
+
✅ Fully functional frontend passkey demo
|
|
812
|
+
✅ One line registration / login for devs
|
|
813
|
+
✅ Automatic Base64URL decoding and ArrayBuffer handling
|
|
814
|
+
|
|
706
815
|
### 10️⃣ Tips for Developers
|
|
707
816
|
- Always call `auth.get('/api/qr').qr()` **after page loads**
|
|
708
817
|
- Use `auth.header()` for any authenticated request
|
|
709
818
|
- Backend must provide endpoints for `/api/qr`, `/api/verify-totp`, `/api/sign-jwt`
|
|
819
|
+
- Make sure backend endpoints return **raw WebAuthn options** (`challenge`, `user`, `allowCredentials`) in **Base64URL format**.
|
|
820
|
+
- `user.id` and `challenge` must be **Base64URL encoded** on backend.
|
|
821
|
+
- JWT storage is automatic if backend returns **token**.
|
|
710
822
|
|
|
711
823
|
---
|
|
712
824
|
|
|
@@ -956,6 +1068,166 @@ app.listen(PORT, () => console.log(`🚀 Server running at http://localhost:${PO
|
|
|
956
1068
|
```
|
|
957
1069
|
---
|
|
958
1070
|
|
|
1071
|
+
## 💌 Magiclink (Passwordless login) (New in v1.8.0)
|
|
1072
|
+
The **Magic Link Manager** allows developers to implement **secure**, **passwordless login** using **email-based links**.
|
|
1073
|
+
Built directly into the AuthVerify SDK, it supports **Gmail**, **custom SMTP**, and token storage via **Memory** or **Redis**.
|
|
1074
|
+
|
|
1075
|
+
### 🚀 Basic Setup
|
|
1076
|
+
```js
|
|
1077
|
+
const AuthVerify = require('auth-verify');
|
|
1078
|
+
|
|
1079
|
+
const auth = new AuthVerify({
|
|
1080
|
+
mlSecret: 'super_secret_key',
|
|
1081
|
+
mlExp: '5m',
|
|
1082
|
+
appUrl: 'http://localhost:3000',
|
|
1083
|
+
storeTokens: 'memory'
|
|
1084
|
+
});
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
### ⚙️ Configure Magic Link Sender
|
|
1088
|
+
Before sending links, you must set up your email transport.
|
|
1089
|
+
#### Gmail Example
|
|
1090
|
+
```js
|
|
1091
|
+
await auth.magic.sender({
|
|
1092
|
+
service: 'gmail',
|
|
1093
|
+
sender: 'yourapp@gmail.com',
|
|
1094
|
+
pass: 'your_gmail_app_password'
|
|
1095
|
+
});
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
#### Custom SMTP Example
|
|
1099
|
+
```js
|
|
1100
|
+
await auth.magic.sender({
|
|
1101
|
+
host: 'smtp.mailgun.org',
|
|
1102
|
+
port: 587,
|
|
1103
|
+
secure: false,
|
|
1104
|
+
sender: 'noreply@yourdomain.com',
|
|
1105
|
+
pass: 'your_smtp_password'
|
|
1106
|
+
});
|
|
1107
|
+
```
|
|
1108
|
+
> ✅ Both Gmail and any SMTP provider are supported.
|
|
1109
|
+
> Use app passwords or tokens instead of your real password!
|
|
1110
|
+
|
|
1111
|
+
### 📩 Send Magic Link
|
|
1112
|
+
Send a secure, expiring link to the user’s email:
|
|
1113
|
+
```js
|
|
1114
|
+
await auth.magic.send('user@example.com', {
|
|
1115
|
+
subject: 'Your Secure Login Link ✨',
|
|
1116
|
+
html: `<p>Click below to sign in:</p>
|
|
1117
|
+
<a href="{{link}}">Login Now</a>`
|
|
1118
|
+
});
|
|
1119
|
+
```
|
|
1120
|
+
> The `{{link}}` placeholder will automatically be replaced with the generated magic link.
|
|
1121
|
+
|
|
1122
|
+
### 🪄 Generate Magic Link Manually
|
|
1123
|
+
If you just want to create a link (not send it yet):
|
|
1124
|
+
```js
|
|
1125
|
+
const token = await auth.magic.generate('user@example.com');
|
|
1126
|
+
console.log(token);
|
|
1127
|
+
```
|
|
1128
|
+
Then make your own URL:
|
|
1129
|
+
```js
|
|
1130
|
+
const link = `http://localhost:3000/auth/verify?token=${token}`;
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
### 🔐 Verify Magic Link
|
|
1134
|
+
Typically used in your backend `/auth/verify` route:
|
|
1135
|
+
```js
|
|
1136
|
+
app.get('/auth/verify', async (req, res) => {
|
|
1137
|
+
const { token } = req.query;
|
|
1138
|
+
try {
|
|
1139
|
+
const user = await auth.magic.verify(token);
|
|
1140
|
+
res.json({ success: true, user });
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
res.status(400).json({ success: false, message: err.message });
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
### 🧠 How It Works
|
|
1148
|
+
1. `auth.magic.generate()` → creates a short-lived JWT with the user’s email.
|
|
1149
|
+
2. `auth.magic.send()` → sends a secure login link by email.
|
|
1150
|
+
3. `auth.magic.verify()` → decodes & validates the token, optionally checks store.
|
|
1151
|
+
|
|
1152
|
+
### 💾 Storage Options
|
|
1153
|
+
| Mode | Description | Best For |
|
|
1154
|
+
| ------------------ | -------------------- | ------------------------------ |
|
|
1155
|
+
| `memory` (default) | Uses in-memory Map() | Single server / small projects |
|
|
1156
|
+
| `redis` | Uses Redis with TTL | Scalable, multi-server apps |
|
|
1157
|
+
|
|
1158
|
+
Example using Redis:
|
|
1159
|
+
```js
|
|
1160
|
+
const auth = new AuthVerify({
|
|
1161
|
+
storeTokens: 'redis',
|
|
1162
|
+
redisUrl: 'redis://localhost:6379'
|
|
1163
|
+
});
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
### 🧰 Callback Support
|
|
1167
|
+
You can also use Node-style callbacks if you prefer:
|
|
1168
|
+
```js
|
|
1169
|
+
auth.magic.send('user@example.com', (err) => {
|
|
1170
|
+
if (err) console.error('❌ Failed to send link:', err);
|
|
1171
|
+
else console.log('✅ Magic link sent!');
|
|
1172
|
+
});
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
### 🌍 Example Express Integration
|
|
1176
|
+
```js
|
|
1177
|
+
const express = require('express');
|
|
1178
|
+
const bodyParser = require('body-parser');
|
|
1179
|
+
const { AuthVerify } = require('auth-verify');
|
|
1180
|
+
|
|
1181
|
+
const app = express();
|
|
1182
|
+
app.use(bodyParser.json());
|
|
1183
|
+
|
|
1184
|
+
const auth = new AuthVerify({
|
|
1185
|
+
mlSecret: 'supersecretkey',
|
|
1186
|
+
appUrl: 'http://localhost:3000',
|
|
1187
|
+
storeTokens: 'memory'
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
auth.magic.sender({
|
|
1191
|
+
service: 'gmail',
|
|
1192
|
+
sender: 'yourapp@gmail.com',
|
|
1193
|
+
pass: 'your_app_password'
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
// Send link
|
|
1197
|
+
app.post('/auth/send', async (req, res) => {
|
|
1198
|
+
const { email } = req.body;
|
|
1199
|
+
await auth.magic.send(email);
|
|
1200
|
+
res.json({ message: 'Magic link sent!' });
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
// Verify link
|
|
1204
|
+
app.get('/auth/verify', async (req, res) => {
|
|
1205
|
+
try {
|
|
1206
|
+
const user = await auth.magic.verify(req.query.token);
|
|
1207
|
+
res.json({ message: 'Login successful!', user });
|
|
1208
|
+
} catch (err) {
|
|
1209
|
+
res.status(400).json({ message: err.message });
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
app.listen(3000, () => console.log('🚀 Server running on port 3000'));
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
### 🧾 Summary
|
|
1217
|
+
| Feature | Supported |
|
|
1218
|
+
| -------------------------- | --------- |
|
|
1219
|
+
| Gmail & SMTP | ✅ |
|
|
1220
|
+
| Memory & Redis Token Store | ✅ |
|
|
1221
|
+
| Token Expiry | ✅ |
|
|
1222
|
+
| Callback & Promise APIs | ✅ |
|
|
1223
|
+
| HTML Custom Email | ✅ |
|
|
1224
|
+
|
|
1225
|
+
### ⚡ Future Vision
|
|
1226
|
+
|
|
1227
|
+
`auth.magic` is built for **modern SaaS**, **fintech**, and **crypto** apps that need **passwordless**, **secure**, and **user-friendly** authentication.
|
|
1228
|
+
|
|
1229
|
+
---
|
|
1230
|
+
|
|
959
1231
|
## Telegram integration
|
|
960
1232
|
|
|
961
1233
|
There are two ways to use Telegram flow:
|
|
@@ -1041,6 +1313,7 @@ auth-verify/
|
|
|
1041
1313
|
| | ├─ index.js
|
|
1042
1314
|
| | ├─ cookie/index.js
|
|
1043
1315
|
│ ├─ /otp/index.js
|
|
1316
|
+
│ ├─ /magiclink/index.js
|
|
1044
1317
|
│ ├─ totp/
|
|
1045
1318
|
| | ├─ index.js
|
|
1046
1319
|
| | ├─ base32.js
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const jwt = require('jsonwebtoken');
|
|
2
|
+
const nodemailer = require('nodemailer');
|
|
3
|
+
const Redis = require('ioredis');
|
|
4
|
+
|
|
5
|
+
class MagicLinkManager {
|
|
6
|
+
constructor(config = {}) {
|
|
7
|
+
this.secret = config.mlSecret || 'authverify_secret';
|
|
8
|
+
this.expiresIn = config.mlExpiry || '5m';
|
|
9
|
+
this.appUrl = config.appUrl || 'https://yourapp.com';
|
|
10
|
+
this.storeType = config.storeTokens || 'memory';
|
|
11
|
+
|
|
12
|
+
if (this.storeType === 'memory') {
|
|
13
|
+
this.tokenStore = new Map();
|
|
14
|
+
} else if (this.storeType === 'redis') {
|
|
15
|
+
this.redis = new Redis(config.redisUrl || 'redis://localhost:6379');
|
|
16
|
+
} else if (this.storeType !== 'none') {
|
|
17
|
+
throw new Error("{storeTokens} must be 'memory' or 'redis'");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async generate(email) {
|
|
22
|
+
return jwt.sign({ email }, this.secret, { expiresIn: this.expiresIn });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
sender(config = {}) {
|
|
26
|
+
this.senderConfig = config;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async send(email, mailOption = {}, callback) {
|
|
30
|
+
if (typeof mailOption === 'function') {
|
|
31
|
+
callback = mailOption;
|
|
32
|
+
mailOption = {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sendProcess = async () => {
|
|
36
|
+
const token = await this.generate(email);
|
|
37
|
+
const link = `${this.appUrl}/auth/verify?token=${token}`;
|
|
38
|
+
|
|
39
|
+
const letterHtml = mailOption.html
|
|
40
|
+
? mailOption.html.replace(/{{\s*link\s*}}/gi, link)
|
|
41
|
+
: `<p>Click to login: <a href="${link}">${link}</a></p>`;
|
|
42
|
+
|
|
43
|
+
let transporter;
|
|
44
|
+
if (this.senderConfig.service === 'gmail') {
|
|
45
|
+
transporter = nodemailer.createTransport({
|
|
46
|
+
service: 'gmail',
|
|
47
|
+
auth: { user: this.senderConfig.sender, pass: this.senderConfig.pass }
|
|
48
|
+
});
|
|
49
|
+
} else {
|
|
50
|
+
transporter = nodemailer.createTransport({
|
|
51
|
+
host: this.senderConfig.host,
|
|
52
|
+
port: this.senderConfig.port || 587,
|
|
53
|
+
secure: this.senderConfig.secure || false,
|
|
54
|
+
auth: { user: this.senderConfig.sender, pass: this.senderConfig.pass }
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await transporter.sendMail({
|
|
59
|
+
from: this.senderConfig.sender,
|
|
60
|
+
to: email,
|
|
61
|
+
subject: mailOption.subject || 'Your Magic Login Link ✨',
|
|
62
|
+
html: letterHtml
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let timeVal = 5 * 60 * 1000;
|
|
66
|
+
if (typeof this.expiresIn === 'string') {
|
|
67
|
+
if (this.expiresIn.endsWith('m')) timeVal = parseInt(this.expiresIn) * 60 * 1000;
|
|
68
|
+
else if (this.expiresIn.endsWith('s')) timeVal = parseInt(this.expiresIn) * 1000;
|
|
69
|
+
} else if (typeof this.expiresIn === 'number') timeVal = this.expiresIn * 1000;
|
|
70
|
+
|
|
71
|
+
if (this.storeType === 'memory') {
|
|
72
|
+
this.tokenStore.set(email, token);
|
|
73
|
+
setTimeout(() => this.tokenStore.delete(email), timeVal);
|
|
74
|
+
} else if (this.storeType === 'redis') {
|
|
75
|
+
await this.redis.set(email, token, 'PX', timeVal);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { token, link };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (callback) sendProcess().then(r => callback(null, r)).catch(e => callback(e));
|
|
82
|
+
else return sendProcess();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async verify(token, callback) {
|
|
86
|
+
const verifyProcess = async () => {
|
|
87
|
+
try {
|
|
88
|
+
const decoded = jwt.verify(token, this.secret);
|
|
89
|
+
// console.log(decoded);
|
|
90
|
+
const email = decoded.email;
|
|
91
|
+
|
|
92
|
+
let stored;
|
|
93
|
+
if (this.storeType === 'memory') stored = this.tokenStore.get(email);
|
|
94
|
+
else if (this.storeType === 'redis') stored = await this.redis.get(email);
|
|
95
|
+
|
|
96
|
+
if (!stored) throw new Error('Magic link expired or not found');
|
|
97
|
+
if (stored !== token) throw new Error('Invalid or already used magic link');
|
|
98
|
+
|
|
99
|
+
if (this.storeType === 'memory') this.tokenStore.delete(email);
|
|
100
|
+
if (this.storeType === 'redis') await this.redis.del(email);
|
|
101
|
+
|
|
102
|
+
return { success: true, user: decoded };
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw new Error('Invalid or expired magic link');
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (callback && typeof callback === 'function')
|
|
109
|
+
verifyProcess().then(r => callback(null, r)).catch(e => callback(e));
|
|
110
|
+
else return verifyProcess();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = MagicLinkManager;
|
package/src/otp/index.js
CHANGED
|
@@ -12,7 +12,7 @@ class OTPManager {
|
|
|
12
12
|
}else{
|
|
13
13
|
this.otpExpiry = (otpOptions.otpExpiry || 300) * 1000;
|
|
14
14
|
}
|
|
15
|
-
this.storeType = otpOptions.storeTokens || '
|
|
15
|
+
this.storeType = otpOptions.storeTokens || 'memory';
|
|
16
16
|
this.hashAlgorithm = otpOptions.otpHash || 'sha256';
|
|
17
17
|
this.logger = null;
|
|
18
18
|
this.customSender = null;
|
|
@@ -56,12 +56,12 @@ class OTPManager {
|
|
|
56
56
|
// }
|
|
57
57
|
|
|
58
58
|
setSender(config){
|
|
59
|
-
if(!config.via) throw new Error("⚠️ Sender type { via } is required. It
|
|
59
|
+
if(!config.via) throw new Error("⚠️ Sender type { via } is required. It should be 'email' or 'sms' or 'telegram'");
|
|
60
60
|
this.senderConfig = config;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
sender(config){
|
|
64
|
-
if(!config.via) throw new Error("⚠️ Sender type { via } is required. It
|
|
64
|
+
if(!config.via) throw new Error("⚠️ Sender type { via } is required. It should be 'email' or 'sms' or 'telegram'");
|
|
65
65
|
this.senderConfig = config;
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -328,7 +328,7 @@ class OTPManager {
|
|
|
328
328
|
// if developer provided their own sender function
|
|
329
329
|
|
|
330
330
|
if (!this.senderConfig)
|
|
331
|
-
throw new Error("Sender not configured. Use setSender() before message().");
|
|
331
|
+
throw new Error("Sender not configured. Use setSender() or sender() before message().");
|
|
332
332
|
|
|
333
333
|
// ---- EMAIL PART ----
|
|
334
334
|
if (this.senderConfig.via === 'email') {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const jwt = require('jsonwebtoken');
|
|
2
|
+
const nodemailer = require('nodemailer');
|
|
3
|
+
const AuthVerify = require('../index');
|
|
4
|
+
|
|
5
|
+
// --- Mock nodemailer ---
|
|
6
|
+
jest.mock('nodemailer', () => ({
|
|
7
|
+
createTransport: jest.fn(() => ({
|
|
8
|
+
sendMail: jest.fn().mockResolvedValue(true)
|
|
9
|
+
}))
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe('MagicLinkManager', () => {
|
|
13
|
+
let magic;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
jest.clearAllMocks();
|
|
17
|
+
auth = new AuthVerify({
|
|
18
|
+
mlSecret: 'test_secret',
|
|
19
|
+
appUrl: 'http://localhost:3000',
|
|
20
|
+
storeTokens: 'memory',
|
|
21
|
+
mlExpiry: '1m'
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
auth.magic.sender({
|
|
25
|
+
service: 'gmail',
|
|
26
|
+
sender: 'test@gmail.com',
|
|
27
|
+
pass: 'fakepass'
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// --- 1️⃣ Generate token ---
|
|
32
|
+
test('should generate valid JWT token', async () => {
|
|
33
|
+
const token = await auth.magic.generate('user@example.com');
|
|
34
|
+
const decoded = jwt.verify(token, 'test_secret');
|
|
35
|
+
// console.log(decoded);
|
|
36
|
+
expect(decoded.email).toBe('user@example.com');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// --- 2️⃣ Send magic link (mocked) ---
|
|
40
|
+
test('should send email using nodemailer', async () => {
|
|
41
|
+
const res = await auth.magic.send('user@example.com');
|
|
42
|
+
expect(res.link).toContain('/auth/verify?token=');
|
|
43
|
+
expect(nodemailer.createTransport).toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// --- 3️⃣ Verify valid token ---
|
|
47
|
+
test('should verify a valid token', async () => {
|
|
48
|
+
const { token } = await auth.magic.send('user@example.com');
|
|
49
|
+
const decoded = await auth.magic.verify(token);
|
|
50
|
+
expect(decoded.user.email).toBe('user@example.com');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// --- 4️⃣ Reject invalid or expired token ---
|
|
54
|
+
test('should throw error for invalid token', async () => {
|
|
55
|
+
await expect(auth.magic.verify('fake.token.value'))
|
|
56
|
+
.rejects
|
|
57
|
+
.toThrow('Invalid or expired magic link');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// --- 5️⃣ Delete token after verification ---
|
|
61
|
+
test('should remove token after successful verification', async () => {
|
|
62
|
+
const { token } = await auth.magic.send('remove@example.com');
|
|
63
|
+
await auth.magic.verify(token);
|
|
64
|
+
// Second time should fail (already used)
|
|
65
|
+
await expect(auth.magic.verify(token)).rejects.toThrow('Invalid or expired magic link');
|
|
66
|
+
});
|
|
67
|
+
});
|