@dax-crafta/auth 2.1.6 → 2.1.7
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/package.json +1 -1
- package/src/index.d.ts +206 -107
- package/src/index.js +601 -442
- package/src/middlewares/auth.middleware.js +35 -11
- package/src/middlewares/cors.middleware.js +73 -0
- package/src/middlewares/validation.middleware.js +2 -1
- package/src/models/user.js +10 -11
- package/src/services/auth.service.js +35 -33
- package/src/services/passwordless.service.js +66 -0
- package/src/services/session.service.js +100 -0
- package/src/services/webhook.service.js +93 -0
- package/src/utils/api-error.js +165 -8
- package/src/utils/email.js +53 -8
- package/src/utils/mfa.js +27 -18
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// packages/auth/src/services/webhook.service.js
|
|
2
|
+
/**
|
|
3
|
+
* WebhookService — fire HTTP callbacks for auth events so downstream
|
|
4
|
+
* systems (CRM, analytics, SIEM) can react to registration, login, etc.
|
|
5
|
+
*
|
|
6
|
+
* Failures are swallowed (logged) so they never break auth flows.
|
|
7
|
+
*/
|
|
8
|
+
const https = require('https');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const URL = require('url');
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
|
|
13
|
+
class WebhookService {
|
|
14
|
+
constructor(config = {}) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.enabled = !!(config.webhooks && config.webhooks.url);
|
|
17
|
+
this.url = this.enabled ? config.webhooks.url : null;
|
|
18
|
+
this.secret = this.enabled ? config.webhooks.secret : null;
|
|
19
|
+
this.events = this.enabled
|
|
20
|
+
? config.webhooks.events || ['user.registered', 'user.login', 'user.logout', 'user.password_changed']
|
|
21
|
+
: [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sign the payload with HMAC-SHA256 so receivers can verify authenticity.
|
|
26
|
+
*/
|
|
27
|
+
sign(payload) {
|
|
28
|
+
if (!this.secret) return null;
|
|
29
|
+
return crypto
|
|
30
|
+
.createHmac('sha256', this.secret)
|
|
31
|
+
.update(payload)
|
|
32
|
+
.digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fire a webhook event. Non-blocking — failures are caught and returned,
|
|
37
|
+
* never thrown.
|
|
38
|
+
*/
|
|
39
|
+
async emit(event, data = {}) {
|
|
40
|
+
if (!this.enabled) return { delivered: false, reason: 'webhooks_disabled' };
|
|
41
|
+
if (this.events.length && !this.events.includes(event)) {
|
|
42
|
+
return { delivered: false, reason: 'event_not_subscribed', event };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const body = JSON.stringify({
|
|
46
|
+
event,
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
data
|
|
49
|
+
});
|
|
50
|
+
const signature = this.sign(body);
|
|
51
|
+
|
|
52
|
+
const parsed = URL.parse(this.url);
|
|
53
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
54
|
+
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const req = transport.request(
|
|
57
|
+
{
|
|
58
|
+
hostname: parsed.hostname,
|
|
59
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
60
|
+
path: parsed.path || '/',
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: {
|
|
63
|
+
'Content-Type': 'application/json',
|
|
64
|
+
'Content-Length': Buffer.byteLength(body),
|
|
65
|
+
'X-Crafta-Event': event,
|
|
66
|
+
...(signature ? { 'X-Crafta-Signature': signature } : {})
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
(res) => {
|
|
70
|
+
let payload = '';
|
|
71
|
+
res.setEncoding('utf8');
|
|
72
|
+
res.on('data', (chunk) => (payload += chunk));
|
|
73
|
+
res.on('end', () => {
|
|
74
|
+
resolve({
|
|
75
|
+
delivered: res.statusCode >= 200 && res.statusCode < 300,
|
|
76
|
+
status: res.statusCode,
|
|
77
|
+
body: payload.slice(0, 500)
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
req.on('error', (err) => {
|
|
84
|
+
resolve({ delivered: false, reason: err.message });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
req.write(body);
|
|
88
|
+
req.end();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = WebhookService;
|
package/src/utils/api-error.js
CHANGED
|
@@ -1,8 +1,165 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
// packages/auth/src/utils/api-error.js
|
|
2
|
+
/**
|
|
3
|
+
* Standardized, machine-readable auth errors.
|
|
4
|
+
*
|
|
5
|
+
* Every error thrown by the library carries:
|
|
6
|
+
* - code : stable, namespaced error code (AUTH_*)
|
|
7
|
+
* - message : human-readable description
|
|
8
|
+
* - status : HTTP status code
|
|
9
|
+
* - details : optional structured payload (e.g. field-level messages)
|
|
10
|
+
*
|
|
11
|
+
* Frontends can switch on `code` instead of parsing prose strings.
|
|
12
|
+
*/
|
|
13
|
+
class ApiError extends Error {
|
|
14
|
+
constructor(message, code = 'AUTH_INTERNAL', status = 400, details = undefined) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'ApiError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.status = status;
|
|
19
|
+
if (details !== undefined) this.details = details;
|
|
20
|
+
|
|
21
|
+
// keep V8 stack trace behaviour intact
|
|
22
|
+
Error.captureStackTrace(this, this.constructor);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---- factory helpers -------------------------------------------------
|
|
26
|
+
static unauthorized(message = 'Authentication required', details) {
|
|
27
|
+
return new ApiError(message, 'AUTH_UNAUTHORIZED', 401, details);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static forbidden(message = 'Insufficient permissions', details) {
|
|
31
|
+
return new ApiError(message, 'AUTH_FORBIDDEN', 403, details);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static notFound(message = 'Resource not found', details) {
|
|
35
|
+
return new ApiError(message, 'AUTH_NOT_FOUND', 404, details);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static validation(message = 'Invalid request data', details) {
|
|
39
|
+
return new ApiError(message, 'AUTH_VALIDATION', 400, details);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static badRequest(message = 'Bad request', details) {
|
|
43
|
+
return new ApiError(message, 'AUTH_BAD_REQUEST', 400, details);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static conflict(message = 'Resource already exists', details) {
|
|
47
|
+
return new ApiError(message, 'AUTH_CONFLICT', 409, details);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static tooManyRequests(message = 'Too many requests, please try later', details) {
|
|
51
|
+
return new ApiError(message, 'AUTH_RATE_LIMITED', 429, details);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static accountLocked(message = 'Account locked. Try again later.', details) {
|
|
55
|
+
return new ApiError(message, 'AUTH_ACCOUNT_LOCKED', 403, details);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static invalidCredentials(message = 'Invalid credentials', details) {
|
|
59
|
+
return new ApiError(message, 'AUTH_INVALID_CREDENTIALS', 401, details);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static invalidToken(message = 'Invalid or expired token', details) {
|
|
63
|
+
return new ApiError(message, 'AUTH_INVALID_TOKEN', 401, details);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static expiredToken(message = 'Token expired', details) {
|
|
67
|
+
return new ApiError(message, 'AUTH_EXPIRED_TOKEN', 401, details);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static emailNotVerified(message = 'Email not verified', details) {
|
|
71
|
+
return new ApiError(message, 'AUTH_EMAIL_NOT_VERIFIED', 403, details);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static twoFactorRequired(message = 'Two-factor authentication required', details) {
|
|
75
|
+
return new ApiError(message, 'AUTH_2FA_REQUIRED', 403, details);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static twoFactorFailed(message = 'Invalid 2FA code', details) {
|
|
79
|
+
return new ApiError(message, 'AUTH_2FA_FAILED', 401, details);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static passwordPolicy(message = 'Password policy violation', details) {
|
|
83
|
+
return new ApiError(message, 'AUTH_PASSWORD_POLICY', 400, details);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static passwordReuse(message = 'Cannot reuse previous passwords', details) {
|
|
87
|
+
return new ApiError(message, 'AUTH_PASSWORD_REUSE', 400, details);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static smtpDisabled(message = 'Email delivery is not configured', details) {
|
|
91
|
+
return new ApiError(message, 'AUTH_SMTP_DISABLED', 503, details);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
static socialAuthFailed(message = 'Social login failed', details) {
|
|
95
|
+
return new ApiError(message, 'AUTH_SOCIAL_FAILED', 400, details);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static internal(message = 'Internal server error', details) {
|
|
99
|
+
return new ApiError(message, 'AUTH_INTERNAL', 500, details);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- predicates -------------------------------------------------------
|
|
103
|
+
/** true when the thrown value is one of our ApiError instances */
|
|
104
|
+
static isApiError(err) {
|
|
105
|
+
return err instanceof ApiError;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Coerce any thrown value into an ApiError (never throws) */
|
|
109
|
+
static normalize(err) {
|
|
110
|
+
if (err instanceof ApiError) return err;
|
|
111
|
+
|
|
112
|
+
if (err && typeof err === 'object') {
|
|
113
|
+
// mongoose ValidationError
|
|
114
|
+
if (err.name === 'ValidationError') {
|
|
115
|
+
const details = {};
|
|
116
|
+
for (const [k, v] of Object.entries(err.errors || {})) {
|
|
117
|
+
details[k] = v.message;
|
|
118
|
+
}
|
|
119
|
+
return new ApiError(
|
|
120
|
+
'Validation failed',
|
|
121
|
+
'AUTH_VALIDATION',
|
|
122
|
+
400,
|
|
123
|
+
Object.keys(details).length ? details : undefined
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
// jwt errors
|
|
127
|
+
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
|
|
128
|
+
return new ApiError(
|
|
129
|
+
err.message || 'Invalid token',
|
|
130
|
+
err.name === 'TokenExpiredError' ? 'AUTH_EXPIRED_TOKEN' : 'AUTH_INVALID_TOKEN',
|
|
131
|
+
401
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
// CastError (invalid ObjectId etc.)
|
|
135
|
+
if (err.name === 'CastError') {
|
|
136
|
+
return new ApiError('Invalid identifier', 'AUTH_INVALID_ID', 400);
|
|
137
|
+
}
|
|
138
|
+
// duplicate key
|
|
139
|
+
if (err.code === 11000) {
|
|
140
|
+
const field = err.keyValue ? Object.keys(err.keyValue)[0] : 'field';
|
|
141
|
+
return new ApiError(
|
|
142
|
+
`${field} already exists`,
|
|
143
|
+
'AUTH_CONFLICT',
|
|
144
|
+
409,
|
|
145
|
+
{ field }
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (typeof err.status === 'number') {
|
|
149
|
+
return new ApiError(
|
|
150
|
+
err.message || 'Request failed',
|
|
151
|
+
err.code || 'AUTH_EXTERNAL',
|
|
152
|
+
err.status
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (err instanceof Error) {
|
|
158
|
+
return new ApiError(err.message, 'AUTH_INTERNAL', 500);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return new ApiError('Internal server error', 'AUTH_INTERNAL', 500);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = ApiError;
|
package/src/utils/email.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
const nodemailer = require('nodemailer');
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const ApiError = require('./api-error');
|
|
5
6
|
|
|
6
7
|
class EmailService {
|
|
7
8
|
constructor(config) {
|
|
8
9
|
this.config = config;
|
|
10
|
+
this.baseUrl = (config && config.baseUrl) || '';
|
|
9
11
|
this.smtpEnabled = !!(
|
|
10
12
|
config.smtp &&
|
|
11
13
|
config.smtp.host &&
|
|
@@ -20,10 +22,21 @@ class EmailService {
|
|
|
20
22
|
this.templateDir = config.emailTemplateDir || path.join(process.cwd(), "templates/email");
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
// Build a safe absolute URL from a base and a path, avoiding double slashes
|
|
26
|
+
// or `undefined/...` when baseUrl is missing.
|
|
27
|
+
joinUrl(base, urlPath) {
|
|
28
|
+
let b = (base == null ? '' : String(base)).trim();
|
|
29
|
+
if (b && b !== '/' && b.endsWith('/')) b = b.slice(0, -1);
|
|
30
|
+
let p = (urlPath == null ? '' : String(urlPath)).trim();
|
|
31
|
+
if (!p) p = '/';
|
|
32
|
+
if (!p.startsWith('/')) p = '/' + p;
|
|
33
|
+
return `${b}${p}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
// Reusable sender
|
|
24
37
|
async send({ to, subject, html, text }) {
|
|
25
38
|
if (!this.smtpEnabled) {
|
|
26
|
-
|
|
39
|
+
throw ApiError.smtpDisabled('Email delivery is not configured. Configure smtp in auth config.');
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
try {
|
|
@@ -58,7 +71,7 @@ class EmailService {
|
|
|
58
71
|
|
|
59
72
|
async sendVerificationEmail(user, token) {
|
|
60
73
|
const verifyPath = this.config.routes?.verify || '/verify';
|
|
61
|
-
const url = `${this.
|
|
74
|
+
const url = `${this.joinUrl(this.baseUrl, verifyPath)}?token=${token}`;
|
|
62
75
|
|
|
63
76
|
const html = this.loadTemplate("email-verification", {
|
|
64
77
|
name: user.name || user.email,
|
|
@@ -75,7 +88,7 @@ class EmailService {
|
|
|
75
88
|
|
|
76
89
|
async sendPasswordResetEmail(user, token) {
|
|
77
90
|
const resetPath = this.config.routes?.resetPassword || '/reset-password';
|
|
78
|
-
const url = `${this.
|
|
91
|
+
const url = `${this.joinUrl(this.baseUrl, resetPath)}?token=${token}`;
|
|
79
92
|
|
|
80
93
|
const html = this.loadTemplate("password-reset", {
|
|
81
94
|
name: user.name || user.email,
|
|
@@ -91,21 +104,25 @@ class EmailService {
|
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
async sendLoginAlert(user, info) {
|
|
107
|
+
info = info || {};
|
|
108
|
+
const ip = info.ip || 'unknown';
|
|
109
|
+
const userAgent = info.userAgent || 'unknown';
|
|
110
|
+
|
|
94
111
|
const html = this.loadTemplate("login-alert", {
|
|
95
112
|
name: user.name || user.email,
|
|
96
|
-
ip
|
|
97
|
-
agent:
|
|
113
|
+
ip,
|
|
114
|
+
agent: userAgent
|
|
98
115
|
}) || `
|
|
99
116
|
New login detected.<br>
|
|
100
|
-
IP: ${
|
|
101
|
-
User Agent: ${
|
|
117
|
+
IP: ${ip}<br>
|
|
118
|
+
User Agent: ${userAgent}
|
|
102
119
|
`;
|
|
103
120
|
|
|
104
121
|
return this.send({
|
|
105
122
|
to: user.email,
|
|
106
123
|
subject: "New Login Detected",
|
|
107
124
|
html,
|
|
108
|
-
text: `New login from IP ${
|
|
125
|
+
text: `New login from IP ${ip}`
|
|
109
126
|
});
|
|
110
127
|
}
|
|
111
128
|
|
|
@@ -123,6 +140,34 @@ class EmailService {
|
|
|
123
140
|
});
|
|
124
141
|
}
|
|
125
142
|
|
|
143
|
+
async sendMagicLink(user, url) {
|
|
144
|
+
const html = this.loadTemplate("magic-link", {
|
|
145
|
+
name: user.name || user.email,
|
|
146
|
+
url
|
|
147
|
+
}) || `Sign in with this magic link: <a href="${url}">${url}</a><br>This link expires in 15 minutes.`;
|
|
148
|
+
|
|
149
|
+
return this.send({
|
|
150
|
+
to: user.email,
|
|
151
|
+
subject: "Your Magic Sign-In Link",
|
|
152
|
+
html,
|
|
153
|
+
text: `Sign in here: ${url} (expires in 15 minutes)`
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async sendMagicLink(user, url) {
|
|
158
|
+
const html = this.loadTemplate("magic-link", {
|
|
159
|
+
name: user.name || user.email,
|
|
160
|
+
url
|
|
161
|
+
}) || `Sign in with this magic link: <a href="${url}">${url}</a><br>This link expires in 15 minutes.`;
|
|
162
|
+
|
|
163
|
+
return this.send({
|
|
164
|
+
to: user.email,
|
|
165
|
+
subject: "Your Magic Sign-In Link",
|
|
166
|
+
html,
|
|
167
|
+
text: `Sign in here: ${url} (expires in 15 minutes)`
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
126
171
|
async sendAccountLockEmail(user) {
|
|
127
172
|
const html = this.loadTemplate("account-locked", {
|
|
128
173
|
name: user.name || user.email
|
package/src/utils/mfa.js
CHANGED
|
@@ -2,30 +2,39 @@
|
|
|
2
2
|
const speakeasy = require('speakeasy');
|
|
3
3
|
const qrcode = require('qrcode');
|
|
4
4
|
const crypto = require('crypto');
|
|
5
|
+
const ApiError = require('./api-error');
|
|
5
6
|
|
|
6
7
|
class MFAService {
|
|
7
8
|
async generateSecret(label, issuer = 'App') {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
9
|
+
try {
|
|
10
|
+
const secret = speakeasy.generateSecret({
|
|
11
|
+
length: 32,
|
|
12
|
+
name: label,
|
|
13
|
+
issuer
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const qrCodeUrl = await qrcode.toDataURL(secret.otpauth_url);
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
secret: secret.base32,
|
|
20
|
+
qrCode: qrCodeUrl
|
|
21
|
+
};
|
|
22
|
+
} catch (err) {
|
|
23
|
+
throw ApiError.internal('Failed to generate MFA secret');
|
|
24
|
+
}
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
verifyToken(token, secret) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
try {
|
|
29
|
+
return speakeasy.totp.verify({
|
|
30
|
+
secret,
|
|
31
|
+
encoding: 'base32',
|
|
32
|
+
token,
|
|
33
|
+
window: 1
|
|
34
|
+
});
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
// Generate hashed backup codes (safer)
|