add configurable club login domains in RMS software

This commit is contained in:
2026-04-02 22:06:19 +02:00
parent 1cc2014034
commit 66b08693b9
4 changed files with 278 additions and 20 deletions

View File

@@ -173,12 +173,25 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
assert.ok(adminVerify.accessToken, "admin access token expected");
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
let forbiddenDomainError = null;
try {
await requestJson(baseUrl, "/v1/auth/request-access", {
method: "POST",
body: { email: "outsider@gmail.com" }
});
} catch (error) {
forbiddenDomainError = error;
}
assert.ok(forbiddenDomainError, "non-club domains should be rejected immediately");
assert.equal(forbiddenDomainError.status, 403);
assert.equal(forbiddenDomainError.payload && forbiddenDomainError.payload.error && forbiddenDomainError.payload.error.code, "auth.email.domain_forbidden");
const guestStart = await requestJson(baseUrl, "/v1/auth/request-access", {
method: "POST",
body: { email: "guest@example.com" }
body: { email: "guest@oevsv.at" }
});
const outbox2 = await readOutbox(dataDir);
const guestVerifyMail = [...outbox2].reverse().find((entry) => entry.to === "guest@example.com");
const guestVerifyMail = [...outbox2].reverse().find((entry) => entry.to === "guest@oevsv.at");
assert.ok(guestVerifyMail, "guest verify challenge must be written");
let guestVerify;
@@ -187,7 +200,7 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
assert.ok(guestCode, "guest otp code must be parsable");
guestVerify = await requestJson(baseUrl, "/v1/auth/verify-email", {
method: "POST",
body: { email: "guest@example.com", code: guestCode }
body: { email: "guest@oevsv.at", code: guestCode }
});
} else {
const guestVerifyToken = extractTokenFromText(guestVerifyMail.text);
@@ -200,7 +213,7 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
assert.equal(guestVerify.approved, false, "external domain should not auto-approve");
const approvals = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders });
const pending = (approvals.approvals || []).find((entry) => entry.email === "guest@example.com" && entry.status === "pending");
const pending = (approvals.approvals || []).find((entry) => entry.email === "guest@oevsv.at" && entry.status === "pending");
assert.ok(pending, "approval request for external user expected");
await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, {
@@ -210,7 +223,7 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
});
const users = await requestJson(baseUrl, "/v1/admin/users", { headers: adminHeaders });
const guest = (users.users || []).find((entry) => entry.email === "guest@example.com");
const guest = (users.users || []).find((entry) => entry.email === "guest@oevsv.at");
assert.ok(guest, "guest user must exist");
await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(guest.id)}/auth-methods`, {
@@ -224,14 +237,14 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
const otpStart = await requestJson(baseUrl, "/v1/auth/request-access", {
method: "POST",
body: { email: "guest@example.com", method: "otp-email" }
body: { email: "guest@oevsv.at", method: "otp-email" }
});
assert.equal(otpStart.challengeType, "otp", "OTP challenge should be selected");
const outbox3 = await readOutbox(dataDir);
const guestOtpMail = [...outbox3]
.reverse()
.find((entry) => entry.to === "guest@example.com" && /Code lautet:\s*\d{6}/.test(entry.text));
.find((entry) => entry.to === "guest@oevsv.at" && /Code lautet:\s*\d{6}/.test(entry.text));
assert.ok(guestOtpMail, "guest OTP mail must be written");
const otpMatch = /Code lautet:\s*(\d{6})/.exec(guestOtpMail.text);
assert.ok(otpMatch, "otp code must be parsable");
@@ -239,10 +252,10 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
const otpVerify = await requestJson(baseUrl, "/v1/auth/verify-email", {
method: "POST",
body: {
email: "guest@example.com",
code: otpMatch[1]
}
});
email: "guest@oevsv.at",
code: otpMatch[1]
}
});
assert.ok(otpVerify.accessToken, "guest OTP login should return token");
if (server.exitCode !== null && server.exitCode !== 0) {
@@ -346,6 +359,80 @@ test("maintenance mode keeps admin active and blocks non-admin login", async (t)
}
});
test("admin can configure allowed login domains", async (t) => {
const rootDir = path.resolve(__dirname, "..");
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-auth-domains-"));
const port = randomPort();
const baseUrl = `http://127.0.0.1:${port}`;
const server = spawn(process.execPath, ["server/index.js"], {
cwd: rootDir,
env: {
...process.env,
PORT: String(port),
DATA_DIR: dataDir,
ADMIN_EMAILS: "admin@arcg.at",
PRIMARY_EMAIL_DOMAIN: "arcg.at",
PUBLIC_BASE_URL: baseUrl
},
stdio: ["ignore", "pipe", "pipe"]
});
let serverStdErr = "";
server.stderr.on("data", (chunk) => {
serverStdErr += String(chunk);
});
t.after(async () => {
if (!server.killed) {
server.kill("SIGTERM");
}
await fs.rm(dataDir, { recursive: true, force: true });
});
await waitForServer(baseUrl);
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
const initial = await requestJson(baseUrl, "/v1/admin/login-domains", { headers: adminHeaders });
assert.equal(initial.ok, true);
assert.ok(Array.isArray(initial.domains));
assert.ok(initial.domains.includes("arcg.at"));
assert.ok(initial.domains.includes("oevsv.at"));
const update = await requestJson(baseUrl, "/v1/admin/login-domains", {
method: "PUT",
headers: adminHeaders,
body: { domains: ["arcg.at"] }
});
assert.equal(update.ok, true);
assert.deepEqual(update.domains, ["arcg.at"]);
let forbidden = null;
try {
await requestJson(baseUrl, "/v1/auth/request-access", {
method: "POST",
body: { email: "guest@oevsv.at" }
});
} catch (error) {
forbidden = error;
}
assert.ok(forbidden, "oevsv domain should be blocked after policy update");
assert.equal(forbidden.status, 403);
assert.equal(forbidden.payload && forbidden.payload.error && forbidden.payload.error.code, "auth.email.domain_forbidden");
const allowed = await requestJson(baseUrl, "/v1/auth/request-access", {
method: "POST",
body: { email: "member@arcg.at" }
});
assert.equal(allowed.ok, true);
if (server.exitCode !== null && server.exitCode !== 0) {
throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`);
}
});
test("approver role can process external approvals without admin role", async (t) => {
const rootDir = path.resolve(__dirname, "..");
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-approver-test-"));
@@ -417,11 +504,11 @@ test("approver role can process external approvals without admin role", async (t
body: {}
});
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, "remote@outside.example");
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, "remote@oevsv.at");
assert.equal(externalVerify.approved, false, "external user should require approval");
const approvalsForApprover = await requestJson(baseUrl, "/v1/approvals", { headers: approverHeaders });
const pending = (approvalsForApprover.approvals || []).find((entry) => entry.email === "remote@outside.example" && entry.status === "pending");
const pending = (approvalsForApprover.approvals || []).find((entry) => entry.email === "remote@oevsv.at" && entry.status === "pending");
assert.ok(pending, "approver should see pending external request");
await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, {
@@ -430,7 +517,7 @@ test("approver role can process external approvals without admin role", async (t
body: {}
});
const externalLogin = await requestAccessAndVerify(baseUrl, dataDir, "remote@outside.example");
const externalLogin = await requestAccessAndVerify(baseUrl, dataDir, "remote@oevsv.at");
assert.ok(externalLogin.accessToken, "external user should be able to login after approver decision");
const operatorVerify = await requestAccessAndVerify(baseUrl, dataDir, "operator@arcg.at");
@@ -482,7 +569,7 @@ test("rejected external user can request approval again via request-approval flo
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
const externalEmail = "blocked@external.example";
const externalEmail = "blocked@oevsv.at";
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
assert.equal(externalVerify.approved, false, "external user should require approval");
@@ -564,7 +651,7 @@ test("auth error codes remain stable for denied/not-approved/maintenance", async
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
const externalEmail = "codes@external.example";
const externalEmail = "codes@oevsv.at";
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
assert.equal(externalVerify.approved, false);
@@ -1013,7 +1100,7 @@ test("approvals list exposes account status and supports repeated decisions", as
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
const externalEmail = "statuscheck@outside.example";
const externalEmail = "statuscheck@oevsv.at";
const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
assert.equal(verify.approved, false);