mbkauthe 5.1.1 → 5.2.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/README.md +15 -6
- package/docs/README.md +2 -1
- package/docs/guides/configuration.md +22 -1
- package/docs/guides/database.md +32 -1
- package/docs/reference/api/authentication.md +1 -1
- package/docs/schema/db.sqlite.sql +155 -0
- package/index.d.ts +26 -2
- package/index.js +1 -1
- package/lib/config/index.js +25 -6
- package/lib/createTable.js +26 -2
- package/lib/db/AuthRepository.js +59 -1
- package/lib/db/BaseRepository.js +13 -0
- package/lib/db/dialects/sqlite.js +25 -0
- package/lib/db/sqlSqliteTranslate.js +62 -0
- package/lib/db/sqlitePool.js +210 -0
- package/lib/middleware/auth.js +2 -2
- package/lib/middleware/index.js +16 -6
- package/lib/pool.js +27 -12
- package/lib/routes/auth.js +3 -2
- package/lib/routes/misc.js +2 -2
- package/lib/routes/oauth.js +2 -2
- package/lib/session/SqliteSessionStore.js +127 -0
- package/lib/tests/integration.spec.js +888 -0
- package/lib/tests/sqlite.spec.js +592 -0
- package/{test.spec.js → lib/tests/test.spec.js} +10 -10
- package/lib/utils/response.js +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { engine } from 'express-handlebars';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { readFile } from 'fs/promises';
|
|
7
|
+
import { jest } from '@jest/globals';
|
|
8
|
+
import speakeasy from 'speakeasy';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
process.env.test = 'dev';
|
|
14
|
+
process.env.env = 'dev';
|
|
15
|
+
|
|
16
|
+
// Inject a self-contained config BEFORE any module import. dotenv does not
|
|
17
|
+
// override pre-set env vars, so this wins over the repo .env. An in-memory
|
|
18
|
+
// SQLite database gives every run a clean, isolated schema.
|
|
19
|
+
process.env.mbkautheVar = JSON.stringify({
|
|
20
|
+
APP_NAME: 'mbkauthe',
|
|
21
|
+
Main_SECRET_TOKEN: 'integration-main-secret-token',
|
|
22
|
+
SESSION_SECRET_KEY: 'integration-session-secret-key',
|
|
23
|
+
IS_DEPLOYED: 'false',
|
|
24
|
+
DOMAIN: 'localhost',
|
|
25
|
+
DB_TYPE: 'sqlite',
|
|
26
|
+
SQLITE_PATH: ':memory:',
|
|
27
|
+
// Enabled globally; it only takes effect for users with a TwoFA row.
|
|
28
|
+
MBKAUTH_TWO_FA_ENABLE: 'true',
|
|
29
|
+
COOKIE_EXPIRE_TIME: 2,
|
|
30
|
+
MAX_SESSIONS_PER_USER: 5
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const { default: router } = await import('../main.js');
|
|
34
|
+
const { packageJson, mbkautheVar, hashPassword, hashApiToken } = await import('../config/index.js');
|
|
35
|
+
const { encryptSessionId } = await import('../config/cookies.js');
|
|
36
|
+
const { dblogin } = await import('../pool.js');
|
|
37
|
+
const { checkRolePermission, strictValidateSession } = await import('../middleware/auth.js');
|
|
38
|
+
const { ErrorCodes } = await import('../utils/errors.js');
|
|
39
|
+
|
|
40
|
+
const SCHEMA_PATH = path.join(__dirname, '../../docs/schema/db.sqlite.sql');
|
|
41
|
+
|
|
42
|
+
const viewsPath = path.join(__dirname, '../../views');
|
|
43
|
+
|
|
44
|
+
const handlebarsHelpers = {
|
|
45
|
+
eq: (a, b) => a === b,
|
|
46
|
+
encodeURIComponent: (str) => encodeURIComponent(str),
|
|
47
|
+
formatTimestamp: (timestamp) => new Date(timestamp).toLocaleString(),
|
|
48
|
+
jsonStringify: (context) => JSON.stringify(context),
|
|
49
|
+
json: (obj) => JSON.stringify(obj, null, 2),
|
|
50
|
+
objectEntries: (obj) => {
|
|
51
|
+
if (!obj || typeof obj !== 'object') return [];
|
|
52
|
+
return Object.entries(obj).map(([key, value]) => ({ key, value }));
|
|
53
|
+
},
|
|
54
|
+
cacheBuster: () => `?v=${packageJson.version}`
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const app = express();
|
|
58
|
+
app.set('views', [viewsPath]);
|
|
59
|
+
app.engine('handlebars', engine({
|
|
60
|
+
defaultLayout: false,
|
|
61
|
+
cache: true,
|
|
62
|
+
partialsDir: [
|
|
63
|
+
viewsPath,
|
|
64
|
+
path.join(viewsPath, 'Error'),
|
|
65
|
+
],
|
|
66
|
+
helpers: handlebarsHelpers
|
|
67
|
+
}));
|
|
68
|
+
app.set('view engine', 'handlebars');
|
|
69
|
+
app.use(router);
|
|
70
|
+
|
|
71
|
+
// ---- console silencing (same pattern as test.spec.js) ----
|
|
72
|
+
const shouldSilenceConsole = (args) => {
|
|
73
|
+
const [firstArg = ''] = args;
|
|
74
|
+
const text = typeof firstArg === 'string' ? firstArg : '';
|
|
75
|
+
return text.includes('[mbkauthe]');
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const originalConsoleLog = console.log;
|
|
79
|
+
const originalConsoleWarn = console.warn;
|
|
80
|
+
const originalConsoleError = console.error;
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
jest.spyOn(console, 'log').mockImplementation((...args) => {
|
|
84
|
+
if (!shouldSilenceConsole(args)) originalConsoleLog(...args);
|
|
85
|
+
});
|
|
86
|
+
jest.spyOn(console, 'warn').mockImplementation((...args) => {
|
|
87
|
+
if (!shouldSilenceConsole(args)) originalConsoleWarn(...args);
|
|
88
|
+
});
|
|
89
|
+
jest.spyOn(console, 'error').mockImplementation((...args) => {
|
|
90
|
+
if (!shouldSilenceConsole(args)) originalConsoleError(...args);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Apply the real schema to the in-memory database.
|
|
94
|
+
const schemaSql = await readFile(SCHEMA_PATH, 'utf8');
|
|
95
|
+
dblogin.execScript(schemaSql);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterAll(async () => {
|
|
99
|
+
jest.restoreAllMocks();
|
|
100
|
+
await dblogin.end().catch(() => {});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ---- test helpers ----
|
|
104
|
+
|
|
105
|
+
const PASSWORD = 'integration-password-123';
|
|
106
|
+
const BROWSER_UA = 'Mozilla/5.0 (integration-test)';
|
|
107
|
+
|
|
108
|
+
// Distinct client IPs per login/flow so per-IP rate limiters never trip.
|
|
109
|
+
// main.js enables trust proxy in test mode, so X-Forwarded-For becomes req.ip.
|
|
110
|
+
let ipCounter = 0;
|
|
111
|
+
const nextIp = () => {
|
|
112
|
+
ipCounter += 1;
|
|
113
|
+
return `10.9.${Math.floor(ipCounter / 200)}.${(ipCounter % 200) + 1}`;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Minimal cookie jar: stores name=value pairs, honors clears. */
|
|
117
|
+
class CookieJar {
|
|
118
|
+
constructor() {
|
|
119
|
+
this.cookies = new Map();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
store(setCookies = []) {
|
|
123
|
+
for (const cookieStr of setCookies || []) {
|
|
124
|
+
const [pair] = cookieStr.split(';');
|
|
125
|
+
const eq = pair.indexOf('=');
|
|
126
|
+
if (eq < 0) continue;
|
|
127
|
+
const name = pair.slice(0, eq).trim();
|
|
128
|
+
const value = pair.slice(eq + 1);
|
|
129
|
+
if (value === '' || /expires=thu, 01 jan 1970/i.test(cookieStr)) {
|
|
130
|
+
this.cookies.delete(name);
|
|
131
|
+
} else {
|
|
132
|
+
this.cookies.set(name, value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
delete(name) {
|
|
138
|
+
this.cookies.delete(name);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
has(name) {
|
|
142
|
+
return this.cookies.has(name);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
header() {
|
|
146
|
+
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function createUser(username, {
|
|
151
|
+
role = 'NormalUser',
|
|
152
|
+
active = 1,
|
|
153
|
+
allowedApps = ['Portal', 'mbkauthe'],
|
|
154
|
+
fullName = null,
|
|
155
|
+
twoFASecret = null,
|
|
156
|
+
twoFAStatus = 0
|
|
157
|
+
} = {}) {
|
|
158
|
+
await dblogin.query(
|
|
159
|
+
`INSERT INTO "Users" ("UserName", "PasswordEnc", "Role", "Active", "AllowedApps", "FullName")
|
|
160
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
161
|
+
[username, hashPassword(PASSWORD, username), role, active, JSON.stringify(allowedApps), fullName || `Full ${username}`]
|
|
162
|
+
);
|
|
163
|
+
if (twoFASecret) {
|
|
164
|
+
await dblogin.query(
|
|
165
|
+
`INSERT INTO "TwoFA" ("UserName", "TwoFAStatus", "TwoFASecret") VALUES ($1, $2, $3)`,
|
|
166
|
+
[username, twoFAStatus, twoFASecret]
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** POST /mbkauthe/api/login and absorb the returned cookies into the jar. */
|
|
172
|
+
async function login(username, { jar = new CookieJar(), ip = nextIp(), ua = BROWSER_UA, password = PASSWORD, redirect } = {}) {
|
|
173
|
+
const req = request(app)
|
|
174
|
+
.post('/mbkauthe/api/login')
|
|
175
|
+
.set('X-Forwarded-For', ip)
|
|
176
|
+
.set('User-Agent', ua);
|
|
177
|
+
if (jar.header()) req.set('Cookie', jar.header());
|
|
178
|
+
const res = await req.send({ username, password, ...(redirect ? { redirect } : {}) });
|
|
179
|
+
jar.store(res.headers['set-cookie']);
|
|
180
|
+
return { res, jar, ip, ua };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function jarGet(pathname, jar, { ip = nextIp(), ua = BROWSER_UA, accept = 'text/html' } = {}) {
|
|
184
|
+
const req = request(app)
|
|
185
|
+
.get(pathname)
|
|
186
|
+
.set('X-Forwarded-For', ip)
|
|
187
|
+
.set('User-Agent', ua)
|
|
188
|
+
.set('Accept', accept)
|
|
189
|
+
.redirects(0);
|
|
190
|
+
if (jar && jar.header()) req.set('Cookie', jar.header());
|
|
191
|
+
return req;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function jarPost(pathname, jar, { ip = nextIp(), ua = BROWSER_UA } = {}) {
|
|
195
|
+
const req = request(app)
|
|
196
|
+
.post(pathname)
|
|
197
|
+
.set('X-Forwarded-For', ip)
|
|
198
|
+
.set('User-Agent', ua);
|
|
199
|
+
if (jar && jar.header()) req.set('Cookie', jar.header());
|
|
200
|
+
return req;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** JSON-flavoured GET (curl UA) so auth failures come back as JSON errors. */
|
|
204
|
+
function jsonGet(pathname, jar, opts = {}) {
|
|
205
|
+
return jarGet(pathname, jar, { ...opts, ua: 'curl/8.0.1', accept: '*/*' });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function getAppSessionId(username) {
|
|
209
|
+
const result = await dblogin.query(
|
|
210
|
+
'SELECT id FROM "Sessions" WHERE "UserName" = $1 ORDER BY created_at DESC',
|
|
211
|
+
[username]
|
|
212
|
+
);
|
|
213
|
+
return result.rows[0]?.id || null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function countAppSessions(username) {
|
|
217
|
+
const result = await dblogin.query(
|
|
218
|
+
'SELECT COUNT(*) AS count FROM "Sessions" WHERE "UserName" = $1',
|
|
219
|
+
[username]
|
|
220
|
+
);
|
|
221
|
+
return Number(result.rows[0].count);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const CSRF_RE = /name="_csrf"[^>]*value="([^"]+)"/i;
|
|
225
|
+
|
|
226
|
+
/** A 6-digit code guaranteed NOT to validate for the secret right now. */
|
|
227
|
+
function wrongTotpToken(secret) {
|
|
228
|
+
const now = Math.floor(Date.now() / 1000);
|
|
229
|
+
const valid = new Set([-1, 0, 1].map((w) =>
|
|
230
|
+
speakeasy.totp({ secret, encoding: 'base32', time: now + w * 30 })
|
|
231
|
+
));
|
|
232
|
+
for (const candidate of ['000000', '111111', '222222', '333333']) {
|
|
233
|
+
if (!valid.has(candidate)) return candidate;
|
|
234
|
+
}
|
|
235
|
+
throw new Error('unreachable');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// =====================================================================
|
|
239
|
+
// 1. Authenticated login flow (login -> protected route -> logout)
|
|
240
|
+
// =====================================================================
|
|
241
|
+
describe('Login API with seeded users', () => {
|
|
242
|
+
test('valid credentials log in and create an app session', async () => {
|
|
243
|
+
await createUser('flow.valid');
|
|
244
|
+
const { res } = await login('flow.valid');
|
|
245
|
+
|
|
246
|
+
expect(res.status).toBe(200);
|
|
247
|
+
expect(res.body).toMatchObject({ success: true, message: 'Login successful' });
|
|
248
|
+
expect(res.body.twoFactorRequired).toBeUndefined();
|
|
249
|
+
|
|
250
|
+
const setCookies = (res.headers['set-cookie'] || []).join('\n');
|
|
251
|
+
expect(setCookies).toContain('mbkauthe.sid=');
|
|
252
|
+
expect(setCookies).toContain('sessionId=');
|
|
253
|
+
expect(setCookies).toContain('fullName=');
|
|
254
|
+
|
|
255
|
+
expect(await countAppSessions('flow.valid')).toBe(1);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('wrong password is rejected with INCORRECT_PASSWORD', async () => {
|
|
259
|
+
await createUser('flow.wrongpw');
|
|
260
|
+
const { res } = await login('flow.wrongpw', { password: 'definitely-wrong-pw' });
|
|
261
|
+
|
|
262
|
+
expect(res.status).toBe(401);
|
|
263
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.INCORRECT_PASSWORD);
|
|
264
|
+
expect(await countAppSessions('flow.wrongpw')).toBe(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('unknown user is rejected with INVALID_CREDENTIALS', async () => {
|
|
268
|
+
const { res } = await login('flow.does.not.exist');
|
|
269
|
+
|
|
270
|
+
expect(res.status).toBe(401);
|
|
271
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.INVALID_CREDENTIALS);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('inactive account is rejected with ACCOUNT_INACTIVE', async () => {
|
|
275
|
+
await createUser('flow.inactive', { active: 0 });
|
|
276
|
+
const { res } = await login('flow.inactive');
|
|
277
|
+
|
|
278
|
+
expect(res.status).toBe(403);
|
|
279
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.ACCOUNT_INACTIVE);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('user without this app in AllowedApps is rejected with APP_NOT_AUTHORIZED', async () => {
|
|
283
|
+
await createUser('flow.noapp', { allowedApps: ['SomeOtherApp'] });
|
|
284
|
+
const { res } = await login('flow.noapp');
|
|
285
|
+
|
|
286
|
+
expect(res.status).toBe(403);
|
|
287
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.APP_NOT_AUTHORIZED);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('safe redirect is echoed back; open redirect is dropped', async () => {
|
|
291
|
+
await createUser('flow.redirect');
|
|
292
|
+
const safe = await login('flow.redirect', { redirect: '/app/dashboard' });
|
|
293
|
+
expect(safe.res.status).toBe(200);
|
|
294
|
+
expect(safe.res.body.redirectUrl).toBe('/app/dashboard');
|
|
295
|
+
|
|
296
|
+
const unsafe = await login('flow.redirect', { redirect: '//evil.example.com' });
|
|
297
|
+
expect(unsafe.res.status).toBe(200);
|
|
298
|
+
expect(unsafe.res.body.redirectUrl).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('logged-in session reaches the protected /test page and can log out', async () => {
|
|
302
|
+
await createUser('flow.session');
|
|
303
|
+
const { jar, ua } = await login('flow.session');
|
|
304
|
+
|
|
305
|
+
// Protected page renders with the user's data
|
|
306
|
+
const page = await jarGet('/mbkauthe/test', jar, { ua });
|
|
307
|
+
expect(page.status).toBe(200);
|
|
308
|
+
expect(page.text).toContain('flow.session');
|
|
309
|
+
|
|
310
|
+
// Protected POST works too
|
|
311
|
+
const post = await jarPost('/mbkauthe/test', jar, { ua });
|
|
312
|
+
expect(post.status).toBe(200);
|
|
313
|
+
expect(post.body).toMatchObject({ success: true });
|
|
314
|
+
|
|
315
|
+
// checkSession reports valid with an expiry
|
|
316
|
+
const check = await jsonGet('/mbkauthe/api/checkSession', jar);
|
|
317
|
+
expect(check.status).toBe(200);
|
|
318
|
+
expect(check.body.sessionValid).toBe(true);
|
|
319
|
+
expect(check.body.expiry).toEqual(expect.any(String));
|
|
320
|
+
|
|
321
|
+
// Logout deletes the app session and clears cookies
|
|
322
|
+
const logout = await jarPost('/mbkauthe/api/logout', jar, { ua });
|
|
323
|
+
expect(logout.status).toBe(200);
|
|
324
|
+
expect(logout.body).toMatchObject({ success: true });
|
|
325
|
+
jar.store(logout.headers['set-cookie']);
|
|
326
|
+
|
|
327
|
+
expect(await countAppSessions('flow.session')).toBe(0);
|
|
328
|
+
|
|
329
|
+
// Session is gone: protected route now redirects to login
|
|
330
|
+
const after = await jarGet('/mbkauthe/test', jar, { ua });
|
|
331
|
+
expect(after.status).toBe(302);
|
|
332
|
+
expect(after.headers.location).toContain('/mbkauthe/login');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('POST /api/checkSession and /api/verifySession accept a real session id (raw and encrypted)', async () => {
|
|
336
|
+
await createUser('flow.verify');
|
|
337
|
+
const { jar } = await login('flow.verify');
|
|
338
|
+
const sessionId = await getAppSessionId('flow.verify');
|
|
339
|
+
expect(sessionId).toBeTruthy();
|
|
340
|
+
|
|
341
|
+
const rawCheck = await jarPost('/mbkauthe/api/checkSession', jar).send({ sessionId });
|
|
342
|
+
expect(rawCheck.status).toBe(200);
|
|
343
|
+
expect(rawCheck.body.sessionValid).toBe(true);
|
|
344
|
+
expect(rawCheck.body.expiry).toEqual(expect.any(String));
|
|
345
|
+
|
|
346
|
+
const encrypted = encryptSessionId(sessionId);
|
|
347
|
+
const encVerify = await jarPost('/mbkauthe/api/verifySession', jar)
|
|
348
|
+
.send({ sessionId: encrypted, isEncrypt: true });
|
|
349
|
+
expect(encVerify.status).toBe(200);
|
|
350
|
+
expect(encVerify.body.valid).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('session restoration middleware rebuilds the session from the encrypted sessionId cookie', async () => {
|
|
354
|
+
await createUser('flow.restore');
|
|
355
|
+
const { jar, ua } = await login('flow.restore');
|
|
356
|
+
|
|
357
|
+
// Drop the express-session cookie, keep the encrypted sessionId cookie.
|
|
358
|
+
jar.delete('mbkauthe.sid');
|
|
359
|
+
expect(jar.has('sessionId')).toBe(true);
|
|
360
|
+
|
|
361
|
+
const page = await jarGet('/mbkauthe/test', jar, { ua });
|
|
362
|
+
expect(page.status).toBe(200);
|
|
363
|
+
expect(page.text).toContain('flow.restore');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('login enforces MAX_SESSIONS_PER_USER by pruning oldest sessions', async () => {
|
|
367
|
+
await createUser('flow.maxsessions');
|
|
368
|
+
for (let i = 0; i < 6; i += 1) {
|
|
369
|
+
const { res } = await login('flow.maxsessions');
|
|
370
|
+
expect(res.status).toBe(200);
|
|
371
|
+
}
|
|
372
|
+
expect(await countAppSessions('flow.maxsessions')).toBe(mbkautheVar.MAX_SESSIONS_PER_USER);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// =====================================================================
|
|
377
|
+
// 2. Session-validation edge cases (specific error codes)
|
|
378
|
+
// =====================================================================
|
|
379
|
+
describe('Session validation edge cases', () => {
|
|
380
|
+
test('expired app session returns 401 SESSION_EXPIRED', async () => {
|
|
381
|
+
await createUser('edge.expired');
|
|
382
|
+
const { jar } = await login('edge.expired');
|
|
383
|
+
|
|
384
|
+
await dblogin.query(
|
|
385
|
+
`UPDATE "Sessions" SET expires_at = '2020-01-01 00:00:00' WHERE "UserName" = $1`,
|
|
386
|
+
['edge.expired']
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const res = await jsonGet('/mbkauthe/test', jar);
|
|
390
|
+
expect(res.status).toBe(401);
|
|
391
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.SESSION_EXPIRED);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('deleted app session returns 401 SESSION_INVALID for API clients', async () => {
|
|
395
|
+
await createUser('edge.deleted');
|
|
396
|
+
const { jar } = await login('edge.deleted');
|
|
397
|
+
|
|
398
|
+
await dblogin.query('DELETE FROM "Sessions" WHERE "UserName" = $1', ['edge.deleted']);
|
|
399
|
+
|
|
400
|
+
const res = await jsonGet('/mbkauthe/test', jar);
|
|
401
|
+
expect(res.status).toBe(401);
|
|
402
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.SESSION_INVALID);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('account deactivated mid-session returns 401 ACCOUNT_INACTIVE', async () => {
|
|
406
|
+
await createUser('edge.deactivated');
|
|
407
|
+
const { jar } = await login('edge.deactivated');
|
|
408
|
+
|
|
409
|
+
await dblogin.query('UPDATE "Users" SET "Active" = 0 WHERE "UserName" = $1', ['edge.deactivated']);
|
|
410
|
+
|
|
411
|
+
const res = await jsonGet('/mbkauthe/test', jar);
|
|
412
|
+
expect(res.status).toBe(401);
|
|
413
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.ACCOUNT_INACTIVE);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('app access revoked mid-session returns 401 APP_NOT_AUTHORIZED', async () => {
|
|
417
|
+
await createUser('edge.revoked');
|
|
418
|
+
const { jar } = await login('edge.revoked');
|
|
419
|
+
|
|
420
|
+
await dblogin.query(
|
|
421
|
+
`UPDATE "Users" SET "AllowedApps" = '["SomeOtherApp"]' WHERE "UserName" = $1`,
|
|
422
|
+
['edge.revoked']
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
const res = await jsonGet('/mbkauthe/test', jar);
|
|
426
|
+
expect(res.status).toBe(401);
|
|
427
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.APP_NOT_AUTHORIZED);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('non-UUID sessionId inside the stored session returns 401 SESSION_EXPIRED', async () => {
|
|
431
|
+
await createUser('edge.corrupt');
|
|
432
|
+
const { jar } = await login('edge.corrupt');
|
|
433
|
+
|
|
434
|
+
// Corrupt the sessionId inside the express-session store row.
|
|
435
|
+
const stored = await dblogin.query('SELECT sid, sess FROM "session"');
|
|
436
|
+
const row = stored.rows.find((r) => String(r.sess).includes('"username":"edge.corrupt"'));
|
|
437
|
+
expect(row).toBeDefined();
|
|
438
|
+
const sess = JSON.parse(row.sess);
|
|
439
|
+
sess.user.sessionId = 'not-a-uuid';
|
|
440
|
+
await dblogin.query('UPDATE "session" SET sess = $1 WHERE sid = $2', [JSON.stringify(sess), row.sid]);
|
|
441
|
+
|
|
442
|
+
const res = await jsonGet('/mbkauthe/test', jar);
|
|
443
|
+
expect(res.status).toBe(401);
|
|
444
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.SESSION_EXPIRED);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('expired session for a browser client redirects to login with reason', async () => {
|
|
448
|
+
await createUser('edge.browser');
|
|
449
|
+
const { jar, ua } = await login('edge.browser');
|
|
450
|
+
|
|
451
|
+
await dblogin.query('DELETE FROM "Sessions" WHERE "UserName" = $1', ['edge.browser']);
|
|
452
|
+
|
|
453
|
+
const res = await jarGet('/mbkauthe/test', jar, { ua });
|
|
454
|
+
// HTML clients get the rendered "Session Expired" error page (401)
|
|
455
|
+
expect(res.status).toBe(401);
|
|
456
|
+
expect(res.text).toContain('Session Expired');
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// =====================================================================
|
|
461
|
+
// 3. API token authentication
|
|
462
|
+
// =====================================================================
|
|
463
|
+
describe('API token authentication', () => {
|
|
464
|
+
async function createApiToken(username, { scope = 'read-only', allowedApps = null, name = 'test-token' } = {}) {
|
|
465
|
+
const token = `mbk_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
466
|
+
await dblogin.query(
|
|
467
|
+
`INSERT INTO "ApiTokens" ("UserName", "Name", "TokenHash", "Prefix", "Permissions")
|
|
468
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
469
|
+
[username, name, hashApiToken(token), 'mbk_', JSON.stringify({ scope, allowedApps })]
|
|
470
|
+
);
|
|
471
|
+
return token;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function bearerGet(pathname, token, { ip = nextIp() } = {}) {
|
|
475
|
+
return request(app)
|
|
476
|
+
.get(pathname)
|
|
477
|
+
.set('X-Forwarded-For', ip)
|
|
478
|
+
.set('Authorization', `Bearer ${token}`)
|
|
479
|
+
.redirects(0);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function bearerPost(pathname, token, { ip = nextIp() } = {}) {
|
|
483
|
+
return request(app)
|
|
484
|
+
.post(pathname)
|
|
485
|
+
.set('X-Forwarded-For', ip)
|
|
486
|
+
.set('Authorization', `Bearer ${token}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
test('valid read-only token authenticates GET requests', async () => {
|
|
490
|
+
await createUser('token.reader');
|
|
491
|
+
const token = await createApiToken('token.reader');
|
|
492
|
+
|
|
493
|
+
const res = await bearerGet('/mbkauthe/test', token);
|
|
494
|
+
expect(res.status).toBe(200);
|
|
495
|
+
expect(res.text).toContain('token.reader');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('read-only token is rejected on POST with TOKEN_SCOPE_INSUFFICIENT', async () => {
|
|
499
|
+
await createUser('token.readonly');
|
|
500
|
+
const token = await createApiToken('token.readonly');
|
|
501
|
+
|
|
502
|
+
const res = await bearerPost('/mbkauthe/test', token).send({});
|
|
503
|
+
expect(res.status).toBe(403);
|
|
504
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.TOKEN_SCOPE_INSUFFICIENT);
|
|
505
|
+
expect(res.body.tokenScope).toBe('read-only');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test('write-scope token is allowed on POST', async () => {
|
|
509
|
+
await createUser('token.writer');
|
|
510
|
+
const token = await createApiToken('token.writer', { scope: 'write' });
|
|
511
|
+
|
|
512
|
+
const res = await bearerPost('/mbkauthe/test', token).send({});
|
|
513
|
+
expect(res.status).toBe(200);
|
|
514
|
+
expect(res.body).toMatchObject({ success: true });
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test('expired token returns 401 API_TOKEN_EXPIRED', async () => {
|
|
518
|
+
await createUser('token.expired');
|
|
519
|
+
const token = await createApiToken('token.expired');
|
|
520
|
+
// CHECK constraint requires ExpiresAt > CreatedAt, so backdate both.
|
|
521
|
+
await dblogin.query(
|
|
522
|
+
`UPDATE "ApiTokens" SET "CreatedAt" = '2020-01-01 00:00:00', "ExpiresAt" = '2020-01-02 00:00:00'
|
|
523
|
+
WHERE "UserName" = $1`,
|
|
524
|
+
['token.expired']
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
const res = await bearerGet('/mbkauthe/test', token);
|
|
528
|
+
expect(res.status).toBe(401);
|
|
529
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.API_TOKEN_EXPIRED);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test('unknown mbk_ token returns 401 INVALID_AUTH_TOKEN', async () => {
|
|
533
|
+
const res = await bearerGet('/mbkauthe/test', 'mbk_this_token_does_not_exist');
|
|
534
|
+
expect(res.status).toBe(401);
|
|
535
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.INVALID_AUTH_TOKEN);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test('non-mbk bearer value returns 401 INVALID_AUTH_TOKEN', async () => {
|
|
539
|
+
const res = await bearerGet('/mbkauthe/test', 'some-random-string');
|
|
540
|
+
expect(res.status).toBe(401);
|
|
541
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.INVALID_AUTH_TOKEN);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('token of an inactive user returns 401 ACCOUNT_INACTIVE', async () => {
|
|
545
|
+
await createUser('token.inactive', { active: 0 });
|
|
546
|
+
const token = await createApiToken('token.inactive');
|
|
547
|
+
|
|
548
|
+
const res = await bearerGet('/mbkauthe/test', token);
|
|
549
|
+
expect(res.status).toBe(401);
|
|
550
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.ACCOUNT_INACTIVE);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test('wildcard token works only when the user has the app', async () => {
|
|
554
|
+
await createUser('token.wild.ok');
|
|
555
|
+
const okToken = await createApiToken('token.wild.ok', { allowedApps: ['*'] });
|
|
556
|
+
const okRes = await bearerGet('/mbkauthe/test', okToken);
|
|
557
|
+
expect(okRes.status).toBe(200);
|
|
558
|
+
|
|
559
|
+
await createUser('token.wild.noapp', { allowedApps: ['SomeOtherApp'] });
|
|
560
|
+
const badToken = await createApiToken('token.wild.noapp', { allowedApps: ['*'] });
|
|
561
|
+
const badRes = await bearerGet('/mbkauthe/test', badToken);
|
|
562
|
+
expect(badRes.status).toBe(401);
|
|
563
|
+
expect(badRes.body).toHaveProperty('errorCode', ErrorCodes.APP_NOT_AUTHORIZED);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test('token allowedApps overrides user apps and blocks unlisted apps', async () => {
|
|
567
|
+
await createUser('token.scoped');
|
|
568
|
+
const token = await createApiToken('token.scoped', { allowedApps: ['some-other-app'] });
|
|
569
|
+
|
|
570
|
+
const res = await bearerGet('/mbkauthe/test', token);
|
|
571
|
+
expect(res.status).toBe(401);
|
|
572
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.APP_NOT_AUTHORIZED);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('strictValidateSession rejects any bearer authentication', async () => {
|
|
576
|
+
const strictApp = express();
|
|
577
|
+
strictApp.get('/strict', strictValidateSession, (req, res) => res.json({ ok: true }));
|
|
578
|
+
|
|
579
|
+
const res = await request(strictApp)
|
|
580
|
+
.get('/strict')
|
|
581
|
+
.set('Authorization', 'Bearer mbk_anything');
|
|
582
|
+
|
|
583
|
+
expect(res.status).toBe(401);
|
|
584
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.INVALID_AUTH_TOKEN);
|
|
585
|
+
expect(res.body.message).toContain('not allowed');
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// =====================================================================
|
|
590
|
+
// 4. Role permission middleware
|
|
591
|
+
// =====================================================================
|
|
592
|
+
describe('Role permission middleware', () => {
|
|
593
|
+
test('SuperAdmin passes the SuperAdmin-only route', async () => {
|
|
594
|
+
await createUser('role.admin', { role: 'SuperAdmin' });
|
|
595
|
+
const { jar } = await login('role.admin');
|
|
596
|
+
|
|
597
|
+
const res = await jsonGet('/mbkauthe/validate-superadmin', jar);
|
|
598
|
+
expect(res.status).toBe(200);
|
|
599
|
+
expect(res.body).toMatchObject({ success: true });
|
|
600
|
+
expect(res.body.user.username).toBe('role.admin');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('NormalUser gets 403 INSUFFICIENT_PERMISSIONS as JSON', async () => {
|
|
604
|
+
await createUser('role.normal');
|
|
605
|
+
const { jar } = await login('role.normal');
|
|
606
|
+
|
|
607
|
+
const res = await jsonGet('/mbkauthe/validate-superadmin', jar);
|
|
608
|
+
expect(res.status).toBe(403);
|
|
609
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.INSUFFICIENT_PERMISSIONS);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('NormalUser gets a rendered 403 page as a browser client', async () => {
|
|
613
|
+
await createUser('role.html');
|
|
614
|
+
const { jar, ua } = await login('role.html');
|
|
615
|
+
|
|
616
|
+
const res = await jarGet('/mbkauthe/validate-superadmin', jar, { ua });
|
|
617
|
+
expect(res.status).toBe(403);
|
|
618
|
+
expect(res.headers['content-type']).toContain('text/html');
|
|
619
|
+
expect(res.text).toContain('Access Denied');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('unauthenticated request to a role-guarded route returns 401 SESSION_NOT_FOUND', async () => {
|
|
623
|
+
const res = await jsonGet('/mbkauthe/validate-superadmin', null);
|
|
624
|
+
expect(res.status).toBe(401);
|
|
625
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.SESSION_NOT_FOUND);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// Direct middleware matrix for role shapes no built-in route exercises.
|
|
629
|
+
describe('checkRolePermission matrix', () => {
|
|
630
|
+
const buildRoleApp = () => {
|
|
631
|
+
const roleApp = express();
|
|
632
|
+
roleApp.use((req, res, next) => {
|
|
633
|
+
const role = req.headers['x-role'];
|
|
634
|
+
req.session = role ? { user: { username: 'matrix.user', role } } : {};
|
|
635
|
+
next();
|
|
636
|
+
});
|
|
637
|
+
roleApp.get('/any', checkRolePermission('Any'), (req, res) => res.json({ ok: true }));
|
|
638
|
+
roleApp.get('/guests-blocked', checkRolePermission('Any', 'Guest'), (req, res) => res.json({ ok: true }));
|
|
639
|
+
roleApp.get('/multi', checkRolePermission(['Manager', 'Editor']), (req, res) => res.json({ ok: true }));
|
|
640
|
+
return roleApp;
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const roleGet = (pathname, role) => {
|
|
644
|
+
const req = request(buildRoleApp()).get(pathname)
|
|
645
|
+
.set('User-Agent', 'curl/8.0.1')
|
|
646
|
+
.set('Accept', '*/*');
|
|
647
|
+
if (role) req.set('X-Role', role);
|
|
648
|
+
return req;
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
test('"Any" admits every authenticated role', async () => {
|
|
652
|
+
const res = await roleGet('/any', 'NormalUser');
|
|
653
|
+
expect(res.status).toBe(200);
|
|
654
|
+
expect(res.body).toMatchObject({ ok: true });
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test('notAllowed role is rejected with ROLE_NOT_ALLOWED even under "Any"', async () => {
|
|
658
|
+
const res = await roleGet('/guests-blocked', 'Guest');
|
|
659
|
+
expect(res.status).toBe(403);
|
|
660
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.ROLE_NOT_ALLOWED);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test('role arrays admit listed roles and reject others', async () => {
|
|
664
|
+
const ok = await roleGet('/multi', 'Editor');
|
|
665
|
+
expect(ok.status).toBe(200);
|
|
666
|
+
|
|
667
|
+
const denied = await roleGet('/multi', 'NormalUser');
|
|
668
|
+
expect(denied.status).toBe(403);
|
|
669
|
+
expect(denied.body).toHaveProperty('errorCode', ErrorCodes.INSUFFICIENT_PERMISSIONS);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test('SuperAdmin bypasses both notAllowed and role lists', async () => {
|
|
673
|
+
const blocked = await roleGet('/guests-blocked', 'SuperAdmin');
|
|
674
|
+
expect(blocked.status).toBe(200);
|
|
675
|
+
|
|
676
|
+
const multi = await roleGet('/multi', 'SuperAdmin');
|
|
677
|
+
expect(multi.status).toBe(200);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test('missing user is rejected with SESSION_NOT_FOUND', async () => {
|
|
681
|
+
const res = await roleGet('/any', null);
|
|
682
|
+
expect(res.status).toBe(401);
|
|
683
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.SESSION_NOT_FOUND);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// =====================================================================
|
|
689
|
+
// 5. 2FA success path (+ trusted device skip)
|
|
690
|
+
// =====================================================================
|
|
691
|
+
describe('Two-factor authentication', () => {
|
|
692
|
+
test('full 2FA login flow with a valid TOTP token and trusted-device skip on re-login', async () => {
|
|
693
|
+
const secret = speakeasy.generateSecret({ length: 20 }).base32;
|
|
694
|
+
await createUser('twofa.full', { twoFASecret: secret, twoFAStatus: 1 });
|
|
695
|
+
|
|
696
|
+
const ip = nextIp();
|
|
697
|
+
const { res: loginRes, jar, ua } = await login('twofa.full', { ip });
|
|
698
|
+
expect(loginRes.status).toBe(200);
|
|
699
|
+
expect(loginRes.body).toMatchObject({ success: true, twoFactorRequired: true });
|
|
700
|
+
// No app session yet — only after 2FA completes.
|
|
701
|
+
expect(await countAppSessions('twofa.full')).toBe(0);
|
|
702
|
+
|
|
703
|
+
// The 2FA page renders for the pre-auth session and provides the CSRF token.
|
|
704
|
+
const page = await jarGet('/mbkauthe/2fa', jar, { ip, ua });
|
|
705
|
+
expect(page.status).toBe(200);
|
|
706
|
+
jar.store(page.headers['set-cookie']);
|
|
707
|
+
const csrfToken = page.text.match(CSRF_RE)?.[1];
|
|
708
|
+
expect(csrfToken).toBeTruthy();
|
|
709
|
+
|
|
710
|
+
const token = speakeasy.totp({ secret, encoding: 'base32' });
|
|
711
|
+
const verify = await jarPost('/mbkauthe/api/verify-2fa', jar, { ip, ua })
|
|
712
|
+
.send({ token, _csrf: csrfToken, trustDevice: true });
|
|
713
|
+
expect(verify.status).toBe(200);
|
|
714
|
+
expect(verify.body).toMatchObject({ success: true });
|
|
715
|
+
jar.store(verify.headers['set-cookie']);
|
|
716
|
+
|
|
717
|
+
expect(await countAppSessions('twofa.full')).toBe(1);
|
|
718
|
+
expect(jar.has('device_token')).toBe(true);
|
|
719
|
+
|
|
720
|
+
// Session works on protected routes
|
|
721
|
+
const protectedPage = await jarGet('/mbkauthe/test', jar, { ip, ua });
|
|
722
|
+
expect(protectedPage.status).toBe(200);
|
|
723
|
+
expect(protectedPage.text).toContain('twofa.full');
|
|
724
|
+
|
|
725
|
+
// Re-login on the trusted device skips 2FA entirely.
|
|
726
|
+
const relogin = await login('twofa.full', { jar, ip, ua });
|
|
727
|
+
expect(relogin.res.status).toBe(200);
|
|
728
|
+
expect(relogin.res.body).toMatchObject({ success: true });
|
|
729
|
+
expect(relogin.res.body.twoFactorRequired).toBeUndefined();
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test('invalid TOTP token is rejected with TWO_FA_INVALID_TOKEN and no session is created', async () => {
|
|
733
|
+
const secret = speakeasy.generateSecret({ length: 20 }).base32;
|
|
734
|
+
await createUser('twofa.badtoken', { twoFASecret: secret, twoFAStatus: 1 });
|
|
735
|
+
|
|
736
|
+
const ip = nextIp();
|
|
737
|
+
const { res: loginRes, jar, ua } = await login('twofa.badtoken', { ip });
|
|
738
|
+
expect(loginRes.body.twoFactorRequired).toBe(true);
|
|
739
|
+
|
|
740
|
+
const page = await jarGet('/mbkauthe/2fa', jar, { ip, ua });
|
|
741
|
+
jar.store(page.headers['set-cookie']);
|
|
742
|
+
const csrfToken = page.text.match(CSRF_RE)?.[1];
|
|
743
|
+
|
|
744
|
+
const verify = await jarPost('/mbkauthe/api/verify-2fa', jar, { ip, ua })
|
|
745
|
+
.send({ token: wrongTotpToken(secret), _csrf: csrfToken });
|
|
746
|
+
expect(verify.status).toBe(401);
|
|
747
|
+
expect(verify.body).toHaveProperty('errorCode', ErrorCodes.TWO_FA_INVALID_TOKEN);
|
|
748
|
+
expect(await countAppSessions('twofa.badtoken')).toBe(0);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test('user with 2FA disabled logs in directly even though 2FA is enabled globally', async () => {
|
|
752
|
+
const secret = speakeasy.generateSecret({ length: 20 }).base32;
|
|
753
|
+
await createUser('twofa.disabled', { twoFASecret: secret, twoFAStatus: 0 });
|
|
754
|
+
|
|
755
|
+
const { res } = await login('twofa.disabled');
|
|
756
|
+
expect(res.status).toBe(200);
|
|
757
|
+
expect(res.body).toMatchObject({ success: true });
|
|
758
|
+
expect(res.body.twoFactorRequired).toBeUndefined();
|
|
759
|
+
expect(await countAppSessions('twofa.disabled')).toBe(1);
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// =====================================================================
|
|
764
|
+
// 6. OAuth callback state validation
|
|
765
|
+
// =====================================================================
|
|
766
|
+
describe('OAuth callback state validation', () => {
|
|
767
|
+
test.each([
|
|
768
|
+
['github', 'missing state', ''],
|
|
769
|
+
['github', 'mismatched state', '?state=forged-state-value'],
|
|
770
|
+
['google', 'mismatched state', '?state=forged-state-value'],
|
|
771
|
+
])('%s callback with %s is rejected with 403', async (provider, _label, query) => {
|
|
772
|
+
const res = await request(app)
|
|
773
|
+
.get(`/mbkauthe/api/${provider}/login/callback${query}`)
|
|
774
|
+
.set('X-Forwarded-For', nextIp())
|
|
775
|
+
.set('User-Agent', BROWSER_UA)
|
|
776
|
+
.redirects(0);
|
|
777
|
+
|
|
778
|
+
expect(res.status).toBe(403);
|
|
779
|
+
expect(res.text).toContain('Invalid Request');
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test('OAuth initiation is refused when the provider is disabled', async () => {
|
|
783
|
+
const res = await request(app)
|
|
784
|
+
.get('/mbkauthe/api/github/login')
|
|
785
|
+
.set('X-Forwarded-For', nextIp())
|
|
786
|
+
.set('User-Agent', BROWSER_UA)
|
|
787
|
+
.redirects(0);
|
|
788
|
+
|
|
789
|
+
expect(res.status).toBe(403);
|
|
790
|
+
expect(res.text).toContain('GitHub Login Disabled');
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// =====================================================================
|
|
795
|
+
// Multi-account cookies + admin session termination
|
|
796
|
+
// =====================================================================
|
|
797
|
+
describe('Multi-account session management', () => {
|
|
798
|
+
test('account-sessions lists the remembered account for the same device fingerprint', async () => {
|
|
799
|
+
await createUser('multi.list');
|
|
800
|
+
const { jar, ua } = await login('multi.list');
|
|
801
|
+
|
|
802
|
+
// Same UA as login — the account-list cookie is fingerprinted by user-agent.
|
|
803
|
+
const res = await jarGet('/mbkauthe/api/account-sessions', jar, { ua, accept: 'application/json' });
|
|
804
|
+
expect(res.status).toBe(200);
|
|
805
|
+
expect(res.body.accounts).toHaveLength(1);
|
|
806
|
+
expect(res.body.accounts[0]).toMatchObject({ username: 'multi.list', isCurrent: true });
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
test('a different user-agent invalidates the account-list fingerprint', async () => {
|
|
810
|
+
await createUser('multi.fingerprint');
|
|
811
|
+
const { jar } = await login('multi.fingerprint', { ua: 'Mozilla/5.0 (device-one)' });
|
|
812
|
+
|
|
813
|
+
const res = await jsonGet('/mbkauthe/api/account-sessions', jar, { ua: 'Mozilla/5.0 (device-two)' });
|
|
814
|
+
expect(res.status).toBe(200);
|
|
815
|
+
expect(res.body.accounts).toHaveLength(0);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test('switch-session activates a remembered account', async () => {
|
|
819
|
+
await createUser('multi.switch');
|
|
820
|
+
const { jar, ua } = await login('multi.switch');
|
|
821
|
+
const sessionId = await getAppSessionId('multi.switch');
|
|
822
|
+
|
|
823
|
+
const res = await jarPost('/mbkauthe/api/switch-session', jar, { ua })
|
|
824
|
+
.send({ sessionId, redirect: '/after-switch' });
|
|
825
|
+
expect(res.status).toBe(200);
|
|
826
|
+
expect(res.body).toMatchObject({
|
|
827
|
+
success: true,
|
|
828
|
+
username: 'multi.switch',
|
|
829
|
+
redirect: '/after-switch'
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test('switch-session refuses a session not remembered on this device', async () => {
|
|
834
|
+
await createUser('multi.foreign');
|
|
835
|
+
const { jar, ua } = await login('multi.foreign');
|
|
836
|
+
|
|
837
|
+
const res = await jarPost('/mbkauthe/api/switch-session', jar, { ua })
|
|
838
|
+
.send({ sessionId: '00000000-0000-4000-8000-000000000000' });
|
|
839
|
+
expect(res.status).toBe(403);
|
|
840
|
+
expect(res.body).toHaveProperty('errorCode', ErrorCodes.SESSION_NOT_FOUND);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test('logout-all removes every remembered session for the device', async () => {
|
|
844
|
+
await createUser('multi.all.one');
|
|
845
|
+
await createUser('multi.all.two');
|
|
846
|
+
const ua = 'Mozilla/5.0 (logout-all-device)';
|
|
847
|
+
const { jar } = await login('multi.all.one', { ua });
|
|
848
|
+
await login('multi.all.two', { jar, ua });
|
|
849
|
+
|
|
850
|
+
expect(await countAppSessions('multi.all.one')).toBe(1);
|
|
851
|
+
expect(await countAppSessions('multi.all.two')).toBe(1);
|
|
852
|
+
|
|
853
|
+
const res = await jarPost('/mbkauthe/api/logout-all', jar, { ua }).send({});
|
|
854
|
+
expect(res.status).toBe(200);
|
|
855
|
+
expect(res.body).toMatchObject({ success: true });
|
|
856
|
+
|
|
857
|
+
expect(await countAppSessions('multi.all.one')).toBe(0);
|
|
858
|
+
expect(await countAppSessions('multi.all.two')).toBe(0);
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
describe('Admin session termination', () => {
|
|
863
|
+
test('terminateAllSessions requires the main secret token', async () => {
|
|
864
|
+
const res = await request(app)
|
|
865
|
+
.post('/mbkauthe/api/terminateAllSessions')
|
|
866
|
+
.set('X-Forwarded-For', nextIp())
|
|
867
|
+
.set('Authorization', 'Bearer wrong-secret')
|
|
868
|
+
.send({});
|
|
869
|
+
|
|
870
|
+
expect(res.status).toBe(401);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test('terminateAllSessions wipes every app session with the correct token', async () => {
|
|
874
|
+
await createUser('admin.wipe');
|
|
875
|
+
await login('admin.wipe');
|
|
876
|
+
expect(await countAppSessions('admin.wipe')).toBe(1);
|
|
877
|
+
|
|
878
|
+
const res = await request(app)
|
|
879
|
+
.post('/mbkauthe/api/terminateAllSessions')
|
|
880
|
+
.set('X-Forwarded-For', nextIp())
|
|
881
|
+
.set('Authorization', `Bearer ${mbkautheVar.Main_SECRET_TOKEN}`)
|
|
882
|
+
.send({});
|
|
883
|
+
|
|
884
|
+
expect(res.status).toBe(200);
|
|
885
|
+
expect(res.body).toMatchObject({ success: true });
|
|
886
|
+
expect(await countAppSessions('admin.wipe')).toBe(0);
|
|
887
|
+
});
|
|
888
|
+
});
|