Innebygd innlogging (AUTH_MODE=app) med neon-login og proxy-bypass

Valgfri app-auth ved siden av proxy-modus (default uendret). Signert
sesjons-cookie (scrypt-passord, HMAC, ingen avhengigheter), gating av API +
WebSocket, og en selvstendig neon-login (public/login.html) for uautentiserte.

Trusted bypass: en proxy-identitet (X-Remote-User / Basic Auth) regnes som
innlogget uten app-passord (TRUST_PROXY_HEADER, default på), så man kan
bootstrappe: logg inn via NPM Basic Auth → sett app-passord i konto-modalen →
skru av Basic Auth. Konto-UI for sett/endre passord + logg ut.

AUTH_MODE leses per createServer-instans (testbart). 34 integrasjonstester
(8 nye for app-auth), alle grønne. README/CLAUDE.md/setup-skript oppdatert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-15 14:27:13 +02:00
parent 7287f33e47
commit f6268440d9
9 changed files with 711 additions and 42 deletions

View file

@ -402,3 +402,109 @@ test('cookie-auth: login setter cookie, ugyldig navn avvises', async () => {
});
assert.equal(res.status, 400);
});
// ---------- App-modus innlogging (AUTH_MODE=app) ----------
let appServer, appUrl, appDir;
function aReq(method, p, body, headers = {}) {
return fetch(appUrl + p, {
method,
headers: { 'Content-Type': 'application/json', ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
function cookieFrom(res) {
const sc = res.headers.get('set-cookie') || '';
const m = sc.match(/netops_session=([^;]*)/);
return m ? `netops_session=${m[1]}` : '';
}
before(async () => {
appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'netops-app-'));
({ server: appServer } = createServer({ dataDir: appDir, authMode: 'app', sessionTtlDays: 7 }));
await new Promise(r => appServer.listen(0, '127.0.0.1', r));
appUrl = `http://127.0.0.1:${appServer.address().port}`;
});
after(() => {
appServer.close();
fs.rmSync(appDir, { recursive: true, force: true });
});
test('app-auth: uten innlogging gir 401 på API med login-flagg', async () => {
const res = await aReq('GET', '/api/state');
assert.equal(res.status, 401);
assert.equal((await res.json()).login, true);
});
test('app-auth: navigasjon uten innlogging serverer login-siden', async () => {
const res = await aReq('GET', '/', undefined, { Accept: 'text/html' });
assert.equal(res.status, 200);
const html = await res.text();
assert.match(html, /NetOps/);
assert.match(html, /api\/auth\/login/); // login-skjemaet
});
test('app-auth: proxy-header gir trusted bypass', async () => {
const res = await aReq('GET', '/api/state', undefined, { 'X-Remote-User': 'jon' });
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.user, 'jon');
assert.equal(data.auth.via, 'proxy');
assert.equal(data.auth.hasPassword, false);
});
test('app-auth: bootstrap — sett passord via bypass, logg så inn med passord', async () => {
// Sett passord mens vi er innlogget via proxy
let res = await aReq('POST', '/api/auth/password', { password: 'hemmelig123' }, { 'X-Remote-User': 'jon' });
assert.equal(res.status, 200);
assert.ok(cookieFrom(res), 'fikk sesjons-cookie ved passord-setting');
// Feil passord avvises
res = await aReq('POST', '/api/auth/login', { user: 'jon', password: 'feil' });
assert.equal(res.status, 401);
// Riktig passord gir sesjons-cookie (uten proxy-header)
res = await aReq('POST', '/api/auth/login', { user: 'jon', password: 'hemmelig123' });
assert.equal(res.status, 200);
const cookie = cookieFrom(res);
assert.ok(cookie);
// Sesjons-cookien gir tilgang uten proxy-header
res = await aReq('GET', '/api/state', undefined, { Cookie: cookie });
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.user, 'jon');
assert.equal(data.auth.via, 'session');
assert.equal(data.auth.hasPassword, true);
});
test('app-auth: for kort passord avvises', async () => {
const res = await aReq('POST', '/api/auth/password', { password: 'kort' }, { 'X-Remote-User': 'kari' });
assert.equal(res.status, 400);
});
test('app-auth: logout tømmer cookien', async () => {
const res = await aReq('POST', '/api/auth/logout');
assert.match(res.headers.get('set-cookie') || '', /netops_session=;|Max-Age=0/);
});
test('app-auth: TRUST_PROXY_HEADER=false ignorerer proxy-header', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'netops-app2-'));
const { server } = createServer({ dataDir: dir, authMode: 'app', trustProxyHeader: false });
await new Promise(r => server.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${server.address().port}`;
try {
const res = await fetch(url + '/api/state', { headers: { 'X-Remote-User': 'jon' } });
assert.equal(res.status, 401); // bypass av
} finally {
server.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('proxy-modus (default): ingen guard, alt åpent', async () => {
// baseUrl-serveren kjører i proxy-modus — bekreft at /api/state er åpent uten cookie
const res = await fetch(baseUrl + '/api/state', { headers: { 'X-Remote-User': 'whoever' } });
assert.equal(res.status, 200);
assert.equal((await res.json()).auth.mode, 'proxy');
});