Add AUTH_DEV_AUTOLOGIN to switch off the login on development instances

With AUTH_DEV_AUTOLOGIN=<username>, every /api request without a valid
session runs as that existing, active user and skips the forced password
change. Unset keeps the normal login; unknown or deactivated users still
get 401, real sessions take precedence, the CSRF application header stays
mandatory and the API logs a warning at startup. The switch is not
limited to localhost because the development instance is reached from
other machines and through a reverse proxy (THREATMODEL D2/R15). It must
never be set on an instance with real project data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 09:03:29 +01:00
parent 4677b1685c
commit 084002bea9
11 changed files with 211 additions and 8 deletions

View file

@ -17,8 +17,11 @@ import { hashPassword, verifyPassword } from "../src/server/auth/password.js";
import { SessionStore } from "../src/server/auth/sessions.js";
import { LoginThrottle } from "../src/server/auth/login-throttle.js";
import { readCookie } from "../src/server/auth/cookie.js";
import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.js";
import type { AuthConfig } from "../src/server/auth/auth-config.js";
import {
createAuthGuard,
requireSameOriginForWrites,
} from "../src/server/auth/auth.middleware.js";
import { readAuthConfig, type AuthConfig } from "../src/server/auth/auth-config.js";
import { createUserSchema } from "../src/shared/validation/user.schemas.js";
import { loginSchema } from "../src/shared/validation/auth.schemas.js";
import {
@ -37,6 +40,7 @@ function createUserService() {
sessionMaxAgeMs: 7 * 24 * HOUR,
cookieSecure: false,
bootstrap: { username: "admin", password: "bootstrap-password-123" },
devAutoLogin: null,
};
return {
repository,
@ -202,6 +206,122 @@ describe("same-origin guard for writes", () => {
});
});
describe("development auto-login", () => {
const baseConfig = {
sessionTtlMs: 12 * HOUR,
sessionMaxAgeMs: 7 * 24 * HOUR,
cookieSecure: false,
bootstrap: null,
};
// Runs the /api auth guard for one request without a session cookie.
async function guard(
users: UserRepository,
devAutoLogin: string | null,
sessions = new SessionStore(HOUR, 24 * HOUR),
cookie?: string
) {
const req = {
path: "/projects",
headers: cookie ? { cookie } : {},
} as unknown as Request;
let status: number | undefined;
const res = {
status(code: number) {
status = code;
return this;
},
json() {
return this;
},
cookie() {
return this;
},
} as unknown as Response;
const passed = await new Promise<boolean>((resolve, reject) => {
const middleware = createAuthGuard(
{ ...baseConfig, devAutoLogin },
sessions,
users
);
middleware(req, res, (error?: unknown) =>
error ? reject(error) : resolve(true)
);
setTimeout(() => resolve(false), 200);
});
return { passed, status, authUser: req.authUser };
}
it("is off unless AUTH_DEV_AUTOLOGIN names a user", () => {
assert.equal(readAuthConfig({}).devAutoLogin, null);
assert.equal(readAuthConfig({ AUTH_DEV_AUTOLOGIN: " " }).devAutoLogin, null);
assert.equal(readAuthConfig({ AUTH_DEV_AUTOLOGIN: " Julian " }).devAutoLogin, "julian");
});
it("signs every request without a session in as the configured active user", async () => {
const { repository, service } = createUserService();
const user = await service.createUser({
username: "julian",
name: "Julian",
role: "admin",
initialPassword: "start-passwort",
});
assert.ok(user);
const result = await guard(repository, "julian");
assert.equal(result.passed, true);
assert.equal(result.authUser?.id, user.id);
assert.equal(result.authUser?.role, "admin");
// A development instance must not stop at the forced password change.
assert.equal(result.authUser?.mustChangePassword, false);
});
it("still requires a login without the switch, for unknown and for deactivated users", async () => {
const { repository, service } = createUserService();
const user = await service.createUser({
username: "julian",
name: "Julian",
role: "admin",
initialPassword: "start-passwort",
});
assert.ok(user);
assert.deepEqual(
{ passed: (await guard(repository, null)).passed, status: (await guard(repository, null)).status },
{ passed: false, status: 401 }
);
assert.equal((await guard(repository, "niemand")).status, 401);
await service.createUser({
username: "zweiter",
name: "Zweiter",
role: "admin",
initialPassword: "start-passwort",
});
await service.updateRoleAndActive(user.id, { active: false });
assert.equal((await guard(repository, "julian")).status, 401);
});
it("keeps a real session in front of the auto-login user", async () => {
const { repository, service } = createUserService();
await service.createUser({
username: "julian",
name: "Julian",
role: "admin",
initialPassword: "start-passwort",
});
const other = await service.createUser({
username: "max",
name: "Max",
role: "user",
initialPassword: "start-passwort",
});
assert.ok(other);
const sessions = new SessionStore(HOUR, 24 * HOUR);
const sessionId = sessions.create(other.id).id;
const result = await guard(repository, "julian", sessions, `lb_session=${sessionId}`);
assert.equal(result.authUser?.username, "max");
});
});
describe("input validation", () => {
it("rejects look-alike and whitespace usernames and normalizes case", () => {
const base = { name: "Test", role: "user", initialPassword: "start-passwort" };