@dax-crafta/auth 2.1.5 → 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.
@@ -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;
@@ -1,8 +1,165 @@
1
- class ApiError extends Error {
2
- constructor(message, status = 400) {
3
- super(message);
4
- this.status = status;
5
- }
6
- }
7
-
8
- module.exports = ApiError;
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;
@@ -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
- return { sent: false, reason: 'smtp_disabled' };
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.config.baseUrl}${verifyPath}?token=${token}`;
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.config.baseUrl}${resetPath}?token=${token}`;
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: info.ip,
97
- agent: info.userAgent
113
+ ip,
114
+ agent: userAgent
98
115
  }) || `
99
116
  New login detected.<br>
100
- IP: ${info.ip}<br>
101
- User Agent: ${info.userAgent}
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 ${info.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
- const secret = speakeasy.generateSecret({
9
- length: 32,
10
- name: label,
11
- issuer
12
- });
13
-
14
- const qrCodeUrl = await qrcode.toDataURL(secret.otpauth_url);
15
-
16
- return {
17
- secret: secret.base32,
18
- qrCode: qrCodeUrl
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
- return speakeasy.totp.verify({
24
- secret,
25
- encoding: 'base32',
26
- token,
27
- window: 1
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)