Release v2.20.1: migrate authentication to Better Auth
This commit is contained in:
1 parent
e9a53f577c
commit
fba4f99ff7
92 files changed
+5085
-1943
No files matched your search
+99
-209
@@ -8,13 +8,91 @@ const pages = require('../src/web/pages');
|
||||
const renderLoginPage = require('../src/web/routes/auth/login');
|
||||
const { createSessionService, normalizeReturnToPath } = require('../src/web/lib/auth/session');
|
||||
|
||||
test('two-factor login enables device trust, submits it, and resets it on back', async () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const nodes = {};
|
||||
const requests = [];
|
||||
let initialMessageRemoved = false;
|
||||
let redirectedTo;
|
||||
let submit;
|
||||
const form = {
|
||||
action: '/api/auth/sign-in/username',
|
||||
elements: {
|
||||
username: { value: 'demo' },
|
||||
password: { value: 'password' },
|
||||
remember_me: { checked: false },
|
||||
trust_device: { disabled: true, checked: false },
|
||||
two_factor_code: { disabled: true, value: '', focus() {} }
|
||||
},
|
||||
querySelector(selector) {
|
||||
return nodes[selector] ||= { addEventListener(event, handler) { this[event] = handler; } };
|
||||
},
|
||||
addEventListener(event, handler) { submit = handler; }
|
||||
};
|
||||
const scriptPath = path.join(__dirname, '../src/web/public/js/auth/login.js');
|
||||
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), {
|
||||
document: {
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-better-auth-login]') return form;
|
||||
if (selector === '[data-login-initial-message]' && !initialMessageRemoved) {
|
||||
return { remove() { initialMessageRemoved = true; } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
window: { location: { search: '', assign(value) { redirectedTo = value; } } },
|
||||
URLSearchParams,
|
||||
async fetch(endpoint, options) {
|
||||
assert.equal(initialMessageRemoved, true);
|
||||
requests.push({ endpoint, payload: JSON.parse(options.body) });
|
||||
return { ok: true, async json() { return requests.length === 1 ? { twoFactorRedirect: true, twoFactorMethods: ['totp'] } : {}; } };
|
||||
}
|
||||
});
|
||||
|
||||
await submit({ preventDefault() {} });
|
||||
assert.equal(initialMessageRemoved, true);
|
||||
assert.equal(form.elements.trust_device.disabled, false);
|
||||
assert.equal(nodes['[data-login-feedback]'].hidden, true);
|
||||
assert.equal(redirectedTo, undefined);
|
||||
form.elements.trust_device.checked = true;
|
||||
form.elements.two_factor_code.value = '123456';
|
||||
await submit({ preventDefault() {} });
|
||||
assert.equal(requests[1].endpoint, '/api/auth/two-factor/verify-totp');
|
||||
assert.equal(requests[1].payload.trustDevice, true);
|
||||
assert.equal(redirectedTo, '/');
|
||||
nodes['[data-two-factor-back]'].click();
|
||||
assert.equal(form.elements.trust_device.disabled, true);
|
||||
assert.equal(form.elements.trust_device.checked, false);
|
||||
});
|
||||
|
||||
test('login page preserves the return target in a hidden field', () => {
|
||||
const html = renderLoginPage('', '/slides/6/edit', 'demo');
|
||||
|
||||
assert.match(html, /name="returnTo"/);
|
||||
assert.match(html, /value="\/slides\/6\/edit"/);
|
||||
assert.match(html, /name="remember_me"/);
|
||||
assert.match(html, /action="\/api\/auth\/sign-in\/username"/);
|
||||
assert.match(html, /js\/auth\/login\.js/);
|
||||
assert.match(html, /name="username"[^>]*value="demo"/);
|
||||
assert.match(html, /data-login-two-factor/);
|
||||
assert.match(html, /name="two_factor_code"/);
|
||||
assert.match(html, /data-two-factor-toggle-method/);
|
||||
assert.doesNotMatch(html, /Sign in to start your session/);
|
||||
assert.match(html, /<label for="username" class="visually-hidden">Username or email<\/label>/);
|
||||
assert.match(html, /<label for="password" class="visually-hidden">Password<\/label>/);
|
||||
assert.match(html, /class="btn btn-secondary w-100" data-passkey-login/);
|
||||
assert.doesNotMatch(html, /class="btn btn-outline-secondary w-100" data-passkey-login/);
|
||||
assert.match(html, /data-passkey-login/);
|
||||
assert.match(html, /js\/auth\/passkey-client\.js/);
|
||||
});
|
||||
|
||||
test('login messages render inline without toast markup or scripts', () => {
|
||||
const html = renderLoginPage('You have been signed out.');
|
||||
|
||||
assert.match(html, /<div class="alert alert-warning py-2" data-login-initial-message>You have been signed out\.<\/div>/);
|
||||
assert.doesNotMatch(html, /app-toast|js\/shared\/ui\/toast\.js/);
|
||||
});
|
||||
|
||||
test('requireAuth preserves the original request path when redirecting to login', () => {
|
||||
@@ -46,7 +124,7 @@ test('requireAuth preserves the original request path when redirecting to login'
|
||||
assert.equal(res.redirectedTo, '/login?returnTo=%2Fslides%2F6%2Fedit%3Ftab%3Dcontent');
|
||||
});
|
||||
|
||||
test('session loader keeps the administrator identity while loading preview-user permissions', async () => {
|
||||
test('session loader keeps the administrator identity and aggregates role two-factor requirements', async () => {
|
||||
const service = createSessionService({
|
||||
sessionCookieName: 'pulse_session',
|
||||
sessionMaxAgeMs: 60 * 60 * 1000,
|
||||
@@ -71,7 +149,7 @@ test('session loader keeps the administrator identity while loading preview-user
|
||||
}]];
|
||||
}
|
||||
if (sql.includes('SELECT r.role_key')) {
|
||||
return [[{ role_key: Number(params[0]) === 7 ? 'super-admin' : 'operator' }]];
|
||||
return [[{ role_key: Number(params[0]) === 7 ? 'super-admin' : 'operator', require_two_factor: 0 }, { role_key: 'secure', require_two_factor: Number(params[0]) === 7 ? 1 : 0 }]];
|
||||
}
|
||||
if (sql.includes('SELECT p.permission_key')) {
|
||||
return [[
|
||||
@@ -91,6 +169,8 @@ test('session loader keeps the administrator identity while loading preview-user
|
||||
assert.deepEqual(currentUser.permissionKeys, ['screens.read']);
|
||||
assert.equal(currentUser.isViewingAs, true);
|
||||
assert.equal(currentUser.authenticatedUser.id, 7);
|
||||
assert.equal(currentUser.requiresTwoFactor, false);
|
||||
assert.equal(currentUser.authenticatedUser.requiresTwoFactor, true);
|
||||
assert.deepEqual(currentUser.authenticatedUser.permissionKeys, ['impersonate.allow']);
|
||||
});
|
||||
|
||||
@@ -151,11 +231,12 @@ test('view-as session helpers bind changes to the authenticated session', async
|
||||
}
|
||||
};
|
||||
const req = { headers: { cookie: 'pulse_session=token' } };
|
||||
req.betterAuthSession = { session: { token: 'token' } };
|
||||
|
||||
assert.equal(await service.startViewAsUser(pool, req, 7, 19), true);
|
||||
assert.equal(await service.stopViewAsUser(pool, req, 7), true);
|
||||
assert.deepEqual(queries[0][1], [19, 7, 'hash:token', 7]);
|
||||
assert.deepEqual(queries[1][1], [7, 'hash:token', 7]);
|
||||
assert.deepEqual(queries[0][1], [19, 7, 'token', 7]);
|
||||
assert.deepEqual(queries[1][1], [7, 'token', 7]);
|
||||
});
|
||||
|
||||
test('logout audit records the authenticated administrator during a preview', async () => {
|
||||
@@ -231,7 +312,7 @@ test('no-access page explains the missing permissions and offers account and sig
|
||||
assert.match(html, /class="btn btn-primary" href="\/logout"/);
|
||||
});
|
||||
|
||||
test('successful login returns to the requested link instead of the dashboard', async () => {
|
||||
test('custom login POST is removed in favor of Better Auth', () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
@@ -243,218 +324,27 @@ test('successful login returns to the requested link instead of the dashboard',
|
||||
};
|
||||
|
||||
registerAuthRoutes(app, {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM a_users WHERE username = ? LIMIT 1')) {
|
||||
return [[{ id: 7, name: 'Demo User', username: 'demo', password_hash: 'hash', password_salt: 'salt', password_iterations: 1 }]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
renderLoginPage(message, returnTo) {
|
||||
return { message, returnTo };
|
||||
}
|
||||
},
|
||||
createUserSession: async () => 'session-token',
|
||||
getSessionMaxAgeMs: async () => 7 * 24 * 60 * 60 * 1000,
|
||||
setSessionCookie(res, token, maxAgeMs) {
|
||||
res.sessionToken = token;
|
||||
res.sessionMaxAgeMs = maxAgeMs;
|
||||
},
|
||||
pool: { async query() { return [[]]; } },
|
||||
pages: {},
|
||||
clearSessionCookie() {},
|
||||
parseCookies() {
|
||||
return {};
|
||||
},
|
||||
consumeAuthMessageCookie() {
|
||||
return '';
|
||||
},
|
||||
parseCookies() { return {}; },
|
||||
consumeAuthMessageCookie() { return ''; },
|
||||
setAuthMessageCookie() {},
|
||||
hashSessionToken(value) {
|
||||
return value;
|
||||
},
|
||||
verifyPassword() {
|
||||
return true;
|
||||
},
|
||||
hashSessionToken(value) { return value; },
|
||||
sessionCookieName: 'pulse_session'
|
||||
});
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
returnTo: '/slides/6/edit?tab=content'
|
||||
},
|
||||
query: {
|
||||
returnTo: '/slides/6/edit?tab=content'
|
||||
}
|
||||
};
|
||||
const res = {
|
||||
redirect(url) {
|
||||
this.redirectedTo = url;
|
||||
},
|
||||
status() {
|
||||
return this;
|
||||
},
|
||||
send(value) {
|
||||
this.sentValue = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await handlers['POST /login'][0](req, res, () => {});
|
||||
|
||||
assert.equal(res.sessionToken, 'session-token');
|
||||
assert.equal(res.sessionMaxAgeMs, null);
|
||||
assert.equal(res.redirectedTo, '/slides/6/edit?tab=content');
|
||||
|
||||
const rememberedRequest = {
|
||||
body: {
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
remember_me: '1'
|
||||
},
|
||||
query: {}
|
||||
};
|
||||
const rememberedResponse = {
|
||||
redirect(url) {
|
||||
this.redirectedTo = url;
|
||||
},
|
||||
status() {
|
||||
return this;
|
||||
},
|
||||
send(value) {
|
||||
this.sentValue = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await handlers['POST /login'][0](rememberedRequest, rememberedResponse, () => {});
|
||||
|
||||
assert.equal(rememberedResponse.sessionMaxAgeMs, 7 * 24 * 60 * 60 * 1000);
|
||||
assert.equal(rememberedResponse.redirectedTo, '/');
|
||||
assert.equal(handlers['POST /login'], undefined);
|
||||
});
|
||||
|
||||
test('accept invite page renders a valid invitation with password requirements', async () => {
|
||||
const handlers = {};
|
||||
let rendered = null;
|
||||
test('invitation acceptance endpoints are not registered', () => {
|
||||
const routes = [];
|
||||
const app = {
|
||||
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
|
||||
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
|
||||
get(path) { routes.push('GET ' + path); },
|
||||
post(path) { routes.push('POST ' + path); }
|
||||
};
|
||||
registerAuthRoutes(app, {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (sql.includes('a_user_invitations')) return [[{ email: 'person@example.com', name: 'Person' }]];
|
||||
return [[{ setting_key: 'security.password_min_length', setting_value: '12' }]];
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
renderAcceptInvitePage(...args) { rendered = args; return 'invite form'; }
|
||||
},
|
||||
hashSessionToken: (value) => 'hash:' + value
|
||||
});
|
||||
registerAuthRoutes(app, {});
|
||||
|
||||
const res = { send(value) { this.body = value; }, status() { return this; } };
|
||||
await handlers['GET /accept-invite'][0]({ query: { token: 'invite-token' } }, res, (error) => { throw error; });
|
||||
|
||||
assert.equal(res.body, 'invite form');
|
||||
assert.equal(rendered[0], '');
|
||||
assert.equal(rendered[1], 'invite-token');
|
||||
assert.equal(rendered[2], 'person@example.com');
|
||||
assert.equal(rendered[3], 'Person');
|
||||
assert.equal(rendered[4].minimumLength, 12);
|
||||
assert.equal(rendered[5], true);
|
||||
assert.equal(routes.includes('GET /accept-invite'), false);
|
||||
assert.equal(routes.includes('POST /accept-invite'), false);
|
||||
});
|
||||
|
||||
test('accept invite creates the account, assigns roles, and consumes the invitation', async () => {
|
||||
const handlers = {};
|
||||
const queries = [];
|
||||
let committed = false;
|
||||
let syncedRoles = null;
|
||||
const app = {
|
||||
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
|
||||
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
|
||||
};
|
||||
const connection = {
|
||||
async query(sql, args) {
|
||||
queries.push({ sql, args });
|
||||
if (sql.includes('a_user_invitations')) return [[{ id: 8, email: 'person@example.com', name: 'Invited name', role_ids_json: '[2, 3]' }]];
|
||||
if (sql.includes('SELECT id FROM a_users')) return [[]];
|
||||
if (sql.includes('INSERT INTO a_users')) return [{ insertId: 21 }];
|
||||
return [[]];
|
||||
},
|
||||
async beginTransaction() {},
|
||||
async commit() { committed = true; },
|
||||
async rollback() {},
|
||||
release() {}
|
||||
};
|
||||
registerAuthRoutes(app, {
|
||||
pool: {
|
||||
async query() { return [[]]; },
|
||||
async getConnection() { return connection; }
|
||||
},
|
||||
pages: {
|
||||
renderAcceptInvitePage() { return ''; }
|
||||
},
|
||||
getConnection: async () => connection,
|
||||
hashSessionToken: (value) => 'hash:' + value,
|
||||
hashPassword: () => ({ hash: 'hash', salt: 'salt', iterations: 1000 }),
|
||||
validatePasswordStrength: () => '',
|
||||
rbacData: {
|
||||
async syncUserRoles(_connection, userId, roleIds) { syncedRoles = { userId, roleIds }; }
|
||||
}
|
||||
});
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
token: 'invite-token',
|
||||
username: 'person',
|
||||
name: 'Person',
|
||||
password: 'ValidPass!1',
|
||||
confirm_password: 'ValidPass!1'
|
||||
}
|
||||
};
|
||||
const res = {
|
||||
redirect(url) { this.redirectedTo = url; },
|
||||
status() { return this; },
|
||||
send() { return this; }
|
||||
};
|
||||
await handlers['POST /accept-invite'][0](req, res, (error) => { throw error; });
|
||||
|
||||
const insert = queries.find((query) => query.sql.includes('INSERT INTO a_users'));
|
||||
const consumed = queries.find((query) => query.sql.includes('UPDATE a_user_invitations'));
|
||||
assert.deepEqual(insert.args, ['Person', 'person', 'person@example.com', 'hash', 'salt', 1000]);
|
||||
assert.deepEqual(syncedRoles, { userId: 21, roleIds: [2, 3] });
|
||||
assert.deepEqual(consumed.args, [8]);
|
||||
assert.equal(committed, true);
|
||||
assert.equal(res.redirectedTo, '/login?message=Account%20created.%20You%20can%20now%20sign%20in.');
|
||||
});
|
||||
|
||||
test('accept invite rejects an invalid token without creating an account', async () => {
|
||||
const handlers = {};
|
||||
let renderedMessage = '';
|
||||
const app = {
|
||||
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
|
||||
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
|
||||
};
|
||||
const connection = {
|
||||
async query(sql) {
|
||||
if (sql.includes('a_user_invitations')) return [[]];
|
||||
return [[]];
|
||||
},
|
||||
async rollback() {},
|
||||
release() {}
|
||||
};
|
||||
registerAuthRoutes(app, {
|
||||
pool: { async query() { return [[]]; } },
|
||||
pages: { renderAcceptInvitePage(message) { renderedMessage = message; return ''; } },
|
||||
getConnection: async () => connection,
|
||||
hashSessionToken: (value) => 'hash:' + value
|
||||
});
|
||||
|
||||
const res = { status() { return this; }, send() {} };
|
||||
await handlers['POST /accept-invite'][0]({ body: { token: 'expired-token' } }, res, (error) => { throw error; });
|
||||
|
||||
assert.equal(renderedMessage, 'This invitation is invalid or has expired.');
|
||||
});
|
||||
Reference in new issue
Block a user