@dax-crafta/auth 2.1.6 → 2.1.8
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/.mocharc.cjs +6 -0
- package/README.md +167 -274
- package/image.png +0 -0
- package/package.json +6 -2
- package/src/index.d.ts +213 -107
- package/src/index.js +669 -442
- package/src/middlewares/auth.middleware.js +64 -19
- package/src/middlewares/cors.middleware.js +73 -0
- package/src/middlewares/validation.middleware.js +2 -1
- package/src/models/user.js +12 -12
- package/src/services/audit.service.js +66 -0
- package/src/services/auth.service.js +71 -42
- package/src/services/passwordless.service.js +66 -0
- package/src/services/role.service.js +5 -0
- package/src/services/session.service.js +141 -0
- package/src/services/webhook.service.js +134 -0
- package/src/utils/api-error.js +165 -8
- package/src/utils/email.js +57 -11
- package/src/utils/mfa.js +27 -18
- package/src/utils/password-policy.js +6 -1
- package/src/utils/password-strength.js +68 -0
- package/test/01-register-login.test.js +134 -0
- package/test/02-password-reset.test.js +50 -0
- package/test/03-2fa.test.js +54 -0
- package/test/04-rbac.test.js +51 -0
- package/test/05-audit-webhook.test.js +78 -0
- package/test/06-sessions.test.js +36 -0
- package/test/07-error-normalization.test.js +64 -0
- package/test/08-cors-health.test.js +45 -0
- package/test/09-passwordless.test.js +40 -0
- package/test/10-webhook-signing.test.js +37 -0
- package/test/helpers.js +130 -0
- package/test/run-tests.js +94 -0
- package/test/tiny-test.js +68 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 09 — Passwordless magic link
|
|
3
|
+
*/
|
|
4
|
+
const { describe, it, before, after } = require('./tiny-test');
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
const { buildApp, startApp, stopApp, req } = require('./helpers');
|
|
7
|
+
|
|
8
|
+
let server;
|
|
9
|
+
|
|
10
|
+
describe('09-passwordless', () => {
|
|
11
|
+
before(async () => {
|
|
12
|
+
const app = buildApp({
|
|
13
|
+
features: { passwordless: true, emailVerification: false, loginAlerts: false },
|
|
14
|
+
smtp: {
|
|
15
|
+
host: 'smtp.example.com',
|
|
16
|
+
port: 587,
|
|
17
|
+
auth: { user: 'test', pass: 'test' },
|
|
18
|
+
from: 'noreply@example.com',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
server = await startApp();
|
|
22
|
+
await req('POST', '/register', {
|
|
23
|
+
name: 'Magic User',
|
|
24
|
+
email: 'magic@example.com',
|
|
25
|
+
password: 'Strong@Pass1',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
after(async () => { await stopApp(); });
|
|
29
|
+
|
|
30
|
+
it('magic link request returns 200 (no oracle)', async () => {
|
|
31
|
+
const res = await req('POST', '/auth/magic-link', { email: 'ghost@example.com' });
|
|
32
|
+
assert.strictEqual(res.status, 200, `expected 200, got ${res.status}`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('magic link verify rejects invalid token', async () => {
|
|
36
|
+
const res = await req('GET', '/auth/magic-link/verify?token=invalid');
|
|
37
|
+
assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
|
|
38
|
+
assert.strictEqual(res.body.code, 'AUTH_INVALID_TOKEN');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 10 — Webhook service: signing, event filtering, delivery
|
|
3
|
+
*/
|
|
4
|
+
const { describe, it } = require('./tiny-test');
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
const WebhookService = require('../src/services/webhook.service');
|
|
7
|
+
|
|
8
|
+
describe('10-webhook-signing', () => {
|
|
9
|
+
it('sign returns HMAC-SHA256 hex when secret is set', () => {
|
|
10
|
+
const svc = new WebhookService({ webhooks: { url: 'http://example.com', secret: 's3cr3t' } });
|
|
11
|
+
const sig = svc.sign('hello');
|
|
12
|
+
assert.strictEqual(typeof sig, 'string');
|
|
13
|
+
assert.strictEqual(sig.length, 64, 'HMAC-SHA256 hex digest is 64 chars');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('sign returns null when no secret', () => {
|
|
17
|
+
const svc = new WebhookService({ webhooks: { url: 'http://example.com' } });
|
|
18
|
+
assert.strictEqual(svc.sign('hello'), null);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('emit is a no-op when webhooks disabled', async () => {
|
|
22
|
+
const svc = new WebhookService({});
|
|
23
|
+
const result = await svc.emit('login', { userId: 'u1' });
|
|
24
|
+
assert.strictEqual(result.delivered, false);
|
|
25
|
+
assert.strictEqual(result.reason, 'webhooks_disabled');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('emit filters events not subscribed', async () => {
|
|
29
|
+
const svc = new WebhookService({
|
|
30
|
+
webhooks: { url: 'http://example.com', secret: 's3cr3t', events: ['login'] },
|
|
31
|
+
});
|
|
32
|
+
const result = await svc.emit('logout', { userId: 'u1' });
|
|
33
|
+
assert.strictEqual(result.delivered, false);
|
|
34
|
+
assert.strictEqual(result.reason, 'event_not_subscribed');
|
|
35
|
+
assert.strictEqual(result.event, 'logout');
|
|
36
|
+
});
|
|
37
|
+
});
|
package/test/helpers.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helpers — shared setup for the live integration suite.
|
|
3
|
+
* Starts an Express server with @dax-crafta/auth against a real MongoDB.
|
|
4
|
+
*/
|
|
5
|
+
const mongoose = require('mongoose');
|
|
6
|
+
const express = require('express');
|
|
7
|
+
const cookieParser = require('cookie-parser');
|
|
8
|
+
const { auth, ApiError } = require('../src/index');
|
|
9
|
+
|
|
10
|
+
const MONGO_URL = process.env.MONGO_URL || 'mongodb://127.0.0.1:27017/crafta-auth-test';
|
|
11
|
+
|
|
12
|
+
let server = null;
|
|
13
|
+
let app = null;
|
|
14
|
+
|
|
15
|
+
async function connectMongo() {
|
|
16
|
+
if (mongoose.connection.readyState === 0) {
|
|
17
|
+
await mongoose.connect(MONGO_URL);
|
|
18
|
+
}
|
|
19
|
+
// Wipe test collections for a clean slate
|
|
20
|
+
const cols = Object.keys(mongoose.connection.collections);
|
|
21
|
+
for (const name of cols) {
|
|
22
|
+
await mongoose.connection.collections[name].deleteMany({});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function disconnectMongo() {
|
|
27
|
+
if (mongoose.connection.readyState !== 0) {
|
|
28
|
+
await mongoose.disconnect();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildApp(overrides = {}) {
|
|
33
|
+
app = express();
|
|
34
|
+
app.use(express.json());
|
|
35
|
+
app.use(cookieParser());
|
|
36
|
+
|
|
37
|
+
auth({
|
|
38
|
+
mongoUrl: MONGO_URL,
|
|
39
|
+
env: { JWT_SECRET: 'test-secret-256-bits-long-key' },
|
|
40
|
+
features: {
|
|
41
|
+
emailVerification: false,
|
|
42
|
+
loginAlerts: false,
|
|
43
|
+
csrf: false,
|
|
44
|
+
rateLimit: false,
|
|
45
|
+
auditLogs: true,
|
|
46
|
+
twoFactor: true,
|
|
47
|
+
passwordless: false,
|
|
48
|
+
webhooks: true,
|
|
49
|
+
},
|
|
50
|
+
session: { maxSessions: 3, sessionExpiryMs: 30 * 24 * 60 * 60 * 1000 },
|
|
51
|
+
webhooks: {
|
|
52
|
+
secret: 'test-webhook-secret',
|
|
53
|
+
events: ['user.registered', 'user.login', 'user.logout'],
|
|
54
|
+
},
|
|
55
|
+
...overrides,
|
|
56
|
+
})(app);
|
|
57
|
+
|
|
58
|
+
// Ensure MongoDB connection is established before returning, and models are registered.
|
|
59
|
+
// auth() connects async; without this the first test hits a duplicate-key
|
|
60
|
+
// error because the connection isn't ready when the request fires.
|
|
61
|
+
global.__resetDb = async () => {
|
|
62
|
+
if (mongoose.connection.readyState === 0) {
|
|
63
|
+
await mongoose.connect(MONGO_URL);
|
|
64
|
+
}
|
|
65
|
+
// Ensure models are registered by requiring user and audit-log models
|
|
66
|
+
try { require('../src/models/user'); } catch (e) {}
|
|
67
|
+
try { require('../src/models/audit-log'); } catch (e) {}
|
|
68
|
+
|
|
69
|
+
const cols = Object.keys(mongoose.connection.collections);
|
|
70
|
+
for (const name of cols) {
|
|
71
|
+
try {
|
|
72
|
+
await mongoose.connection.collections[name].drop();
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// Collection may not exist yet — ignore
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return app;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function startApp(port = 0) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
server = app.listen(port, () => {
|
|
85
|
+
resolve(server);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function stopApp() {
|
|
91
|
+
if (server) {
|
|
92
|
+
await new Promise((r) => server.close(r));
|
|
93
|
+
server = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── HTTP helpers ──
|
|
98
|
+
const BASE = () => `http://127.0.0.1:${server.address().port}`;
|
|
99
|
+
|
|
100
|
+
async function req(method, path, body, headers = {}) {
|
|
101
|
+
const url = `${BASE()}${path}`;
|
|
102
|
+
const opts = {
|
|
103
|
+
method,
|
|
104
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
105
|
+
};
|
|
106
|
+
// GET/HEAD requests cannot have a body per fetch spec
|
|
107
|
+
if (body !== undefined && !['GET', 'HEAD'].includes(method)) {
|
|
108
|
+
opts.body = JSON.stringify(body);
|
|
109
|
+
}
|
|
110
|
+
const res = await fetch(url, opts);
|
|
111
|
+
let json = null;
|
|
112
|
+
let text = null;
|
|
113
|
+
const clone = res.clone();
|
|
114
|
+
try {
|
|
115
|
+
json = await res.json();
|
|
116
|
+
} catch {
|
|
117
|
+
text = await clone.text();
|
|
118
|
+
}
|
|
119
|
+
return { status: res.status, body: json, text };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
connectMongo,
|
|
124
|
+
disconnectMongo,
|
|
125
|
+
buildApp,
|
|
126
|
+
startApp,
|
|
127
|
+
stopApp,
|
|
128
|
+
req,
|
|
129
|
+
ApiError,
|
|
130
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test runner — runs all test/*.test.js files sequentially.
|
|
3
|
+
* Each test file uses the tiny-test framework (describe/it/before/after).
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANT: helpers and tiny-test are NEVER cleared from cache between
|
|
6
|
+
* test files, because helpers registers Mongoose models via auth().
|
|
7
|
+
* Only the test file itself is cleared.
|
|
8
|
+
*/
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const mongoose = require('mongoose');
|
|
12
|
+
|
|
13
|
+
const testDir = __dirname;
|
|
14
|
+
const files = fs
|
|
15
|
+
.readdirSync(testDir)
|
|
16
|
+
.filter((f) => f.endsWith('.test.js') && !f.startsWith('run-') && !f.startsWith('tiny-'))
|
|
17
|
+
.sort();
|
|
18
|
+
|
|
19
|
+
const MONGO_URL = process.env.MONGO_URL || 'mongodb://127.0.0.1:27017/crafta-auth-test';
|
|
20
|
+
|
|
21
|
+
// Register Mongoose models eagerly so they exist before any test file runs.
|
|
22
|
+
// auth() normally does this lazily, but the DB reset happens BEFORE auth()
|
|
23
|
+
// is called in each test file's before() hook.
|
|
24
|
+
require('./helpers'); // triggers require chain that registers models
|
|
25
|
+
require('../src/models/user');
|
|
26
|
+
require('../src/models/audit-log');
|
|
27
|
+
|
|
28
|
+
async function resetMongoose() {
|
|
29
|
+
// Connect once and keep the connection — disconnecting invalidates
|
|
30
|
+
// the mongoose models registered by auth().
|
|
31
|
+
if (mongoose.connection.readyState === 0) {
|
|
32
|
+
await mongoose.connect(MONGO_URL);
|
|
33
|
+
}
|
|
34
|
+
// Drop every collection known to the MongoDB server.
|
|
35
|
+
// We use db.listCollections() rather than mongoose.connection.collections
|
|
36
|
+
// because the latter only tracks collections touched through Mongoose models,
|
|
37
|
+
// which is empty until auth() runs (after this reset).
|
|
38
|
+
const cols = await mongoose.connection.db.listCollections().toArray();
|
|
39
|
+
for (const col of cols) {
|
|
40
|
+
try {
|
|
41
|
+
await mongoose.connection.db.dropCollection(col.name);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// Collection may not exist yet — ignore
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function run() {
|
|
49
|
+
let totalPassed = 0;
|
|
50
|
+
let totalFailed = 0;
|
|
51
|
+
const failures = [];
|
|
52
|
+
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
const filePath = path.join(testDir, file);
|
|
55
|
+
console.log(`\n=== ${file} ===`);
|
|
56
|
+
|
|
57
|
+
// Only clear the test file's own cache — keep helpers and tiny-test cached
|
|
58
|
+
// so Mongoose models registered by auth() stay registered.
|
|
59
|
+
const resolved = require.resolve(filePath);
|
|
60
|
+
delete require.cache[resolved];
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await resetMongoose();
|
|
64
|
+
require(filePath);
|
|
65
|
+
const tinyTest = require('./tiny-test');
|
|
66
|
+
await tinyTest.runPending();
|
|
67
|
+
|
|
68
|
+
const stats = tinyTest.getStats();
|
|
69
|
+
totalPassed += stats.passed;
|
|
70
|
+
totalFailed += stats.failed;
|
|
71
|
+
failures.push(...stats.failures);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(`[FAIL] ${file}: ${err.message}`);
|
|
74
|
+
if (err.stack) console.error(err.stack.split('\n').slice(1, 6).join('\n'));
|
|
75
|
+
totalFailed += 1;
|
|
76
|
+
failures.push({ name: file, err });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(`\nSummary: ${totalPassed} passed, ${totalFailed} failed`);
|
|
81
|
+
if (failures.length > 0) {
|
|
82
|
+
console.log('\nFailures:');
|
|
83
|
+
failures.forEach((f) => console.log(` - ${f.name}: ${f.err.message}`));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (totalFailed > 0) {
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
run().catch((err) => {
|
|
92
|
+
console.error('Fatal error:', err);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny test harness — supports describe()/it() with async assertions.
|
|
3
|
+
* Drop-in replacement for the mocha BDD interface. No dependencies.
|
|
4
|
+
*/
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
|
|
7
|
+
let passed = 0;
|
|
8
|
+
let failed = 0;
|
|
9
|
+
const failures = [];
|
|
10
|
+
let pendingTests = [];
|
|
11
|
+
|
|
12
|
+
function describe(name, fn) {
|
|
13
|
+
console.log(`\n ${name}`);
|
|
14
|
+
fn();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function it(name, fn) {
|
|
18
|
+
pendingTests.push({ name, fn });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function runPending() {
|
|
22
|
+
// Reset DB BEFORE before() so test data created in before() survives.
|
|
23
|
+
// Also ensures MongoDB connection is established (auth() connects async).
|
|
24
|
+
if (global.__resetDb) { await global.__resetDb(); }
|
|
25
|
+
|
|
26
|
+
// Run before hooks — this is where buildApp() registers Mongoose models
|
|
27
|
+
if (global.__before) { await global.__before(); }
|
|
28
|
+
|
|
29
|
+
for (const test of pendingTests) {
|
|
30
|
+
try {
|
|
31
|
+
await test.fn();
|
|
32
|
+
console.log(` ✓ ${test.name}`);
|
|
33
|
+
passed += 1;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.log(` ✗ ${test.name}`);
|
|
36
|
+
console.log(` ${err.message}`);
|
|
37
|
+
failures.push({ name: test.name, err });
|
|
38
|
+
failed += 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (global.__after) { await global.__after(); }
|
|
42
|
+
|
|
43
|
+
// Reset for next file
|
|
44
|
+
pendingTests = [];
|
|
45
|
+
global.__before = null;
|
|
46
|
+
global.__after = null;
|
|
47
|
+
global.__resetDb = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function before(fn) {
|
|
51
|
+
global.__before = fn;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function after(fn) {
|
|
55
|
+
global.__after = fn;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getStats() {
|
|
59
|
+
return { passed, failed, failures };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resetStats() {
|
|
63
|
+
passed = 0;
|
|
64
|
+
failed = 0;
|
|
65
|
+
failures.length = 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { describe, it, before, after, runPending, getStats, resetStats };
|