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

@@ -4,6 +4,7 @@ const SWR_DETAIL_REFRESH_MS = 20000;
const DEFAULT_UI_LANGUAGE = "de"; const DEFAULT_UI_LANGUAGE = "de";
const SUPPORTED_UI_LANGUAGES = ["de", "en"]; const SUPPORTED_UI_LANGUAGES = ["de", "en"];
const LANGUAGE_STORAGE_KEY = "rms-lang"; const LANGUAGE_STORAGE_KEY = "rms-lang";
const DEFAULT_ALLOWED_LOGIN_DOMAINS = ["arcg.at", "oevsv.at"];
let activationWatchTimer = null; let activationWatchTimer = null;
let activationWatchInFlight = false; let activationWatchInFlight = false;
@@ -21,7 +22,8 @@ const state = {
branding: { branding: {
logoLightUrl: null, logoLightUrl: null,
logoDarkUrl: null logoDarkUrl: null
} },
allowedLoginDomains: DEFAULT_ALLOWED_LOGIN_DOMAINS.slice()
}, },
accessToken: localStorage.getItem("rms-access-token") || "", accessToken: localStorage.getItem("rms-access-token") || "",
refreshToken: localStorage.getItem("rms-refresh-token") || "", refreshToken: localStorage.getItem("rms-refresh-token") || "",
@@ -251,6 +253,8 @@ const els = {
maintenanceMessageInput: document.getElementById("maintenanceMessageInput"), maintenanceMessageInput: document.getElementById("maintenanceMessageInput"),
maintenanceEnableBtn: document.getElementById("maintenanceEnableBtn"), maintenanceEnableBtn: document.getElementById("maintenanceEnableBtn"),
maintenanceDisableBtn: document.getElementById("maintenanceDisableBtn"), maintenanceDisableBtn: document.getElementById("maintenanceDisableBtn"),
loginDomainsInput: document.getElementById("loginDomainsInput"),
saveLoginDomainsBtn: document.getElementById("saveLoginDomainsBtn"),
logoLightFile: document.getElementById("logoLightFile"), logoLightFile: document.getElementById("logoLightFile"),
logoDarkFile: document.getElementById("logoDarkFile"), logoDarkFile: document.getElementById("logoDarkFile"),
uploadLogoLightBtn: document.getElementById("uploadLogoLightBtn"), uploadLogoLightBtn: document.getElementById("uploadLogoLightBtn"),
@@ -817,6 +821,11 @@ function bindEvents() {
await setMaintenanceMode(false); await setMaintenanceMode(false);
}); });
} }
if (els.saveLoginDomainsBtn) {
els.saveLoginDomainsBtn.addEventListener("click", async () => {
await saveLoginDomains();
});
}
if (els.uploadLogoLightBtn) { if (els.uploadLogoLightBtn) {
els.uploadLogoLightBtn.addEventListener("click", async () => { els.uploadLogoLightBtn.addEventListener("click", async () => {
await uploadBrandLogo("light", els.logoLightFile); await uploadBrandLogo("light", els.logoLightFile);
@@ -902,6 +911,10 @@ async function refreshFrontendOnResume() {
async function requestAccess() { async function requestAccess() {
clearMessages("auth"); clearMessages("auth");
const email = els.email.value.trim(); const email = els.email.value.trim();
if (!isLoginEmailAllowed(email)) {
renderMessage(els.authMessage, `Nur Club-Mailadressen (${formatAllowedDomainsHint()}) sind zum Anmelden moeglich.`, true);
return;
}
const method = els.authMethodSelect.value; const method = els.authMethodSelect.value;
try { try {
const result = await api("/v1/auth/request-access", { const result = await api("/v1/auth/request-access", {
@@ -971,6 +984,30 @@ async function verifyOtpCode() {
} }
} }
function isLoginEmailAllowed(email) {
const normalized = String(email || "").trim().toLowerCase();
if (!normalized || !normalized.includes("@")) {
return false;
}
const atIndex = normalized.lastIndexOf("@");
if (atIndex < 0) {
return false;
}
const domain = normalized.slice(atIndex + 1);
return getAllowedLoginDomains().includes(domain);
}
function getAllowedLoginDomains() {
const list = state && state.system && Array.isArray(state.system.allowedLoginDomains)
? state.system.allowedLoginDomains
: [];
return list.length > 0 ? list : DEFAULT_ALLOWED_LOGIN_DOMAINS.slice();
}
function formatAllowedDomainsHint() {
return getAllowedLoginDomains().map((domain) => `@${domain}`).join(" oder ");
}
async function handleEmailTokenFromUrl() { async function handleEmailTokenFromUrl() {
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (url.searchParams.get("requestApproval") === "1") { if (url.searchParams.get("requestApproval") === "1") {
@@ -1036,14 +1073,16 @@ async function refreshPublicSystemStatus() {
maintenanceMode: Boolean(result.maintenanceMode), maintenanceMode: Boolean(result.maintenanceMode),
maintenanceMessage: result.maintenanceMessage || "", maintenanceMessage: result.maintenanceMessage || "",
updatedAt: result.updatedAt || null, updatedAt: result.updatedAt || null,
branding: normalizeBranding(result.branding) branding: normalizeBranding(result.branding),
allowedLoginDomains: normalizeLoginDomainList(result.allowedLoginDomains)
}; };
} catch { } catch {
state.system = { state.system = {
maintenanceMode: false, maintenanceMode: false,
maintenanceMessage: "", maintenanceMessage: "",
updatedAt: null, updatedAt: null,
branding: normalizeBranding(null) branding: normalizeBranding(null),
allowedLoginDomains: DEFAULT_ALLOWED_LOGIN_DOMAINS.slice()
}; };
} }
renderMaintenanceBanner(); renderMaintenanceBanner();
@@ -1165,6 +1204,43 @@ function renderMaintenanceBanner() {
if (els.maintenanceMessageInput && !els.maintenanceMessageInput.value) { if (els.maintenanceMessageInput && !els.maintenanceMessageInput.value) {
els.maintenanceMessageInput.value = state.system.maintenanceMessage || ""; els.maintenanceMessageInput.value = state.system.maintenanceMessage || "";
} }
if (els.loginDomainsInput && document.activeElement !== els.loginDomainsInput) {
els.loginDomainsInput.value = getAllowedLoginDomains().join("\n");
}
}
function normalizeLoginDomainList(value) {
const list = Array.isArray(value)
? value
: String(value || "").split(/[\n,;\s]+/);
const unique = [];
const seen = new Set();
for (const entry of list) {
const domain = String(entry || "").trim().toLowerCase();
if (!domain) continue;
if (!/^[a-z0-9.-]+$/.test(domain)) continue;
if (!domain.includes(".")) continue;
if (seen.has(domain)) continue;
seen.add(domain);
unique.push(domain);
}
return unique.length > 0 ? unique : DEFAULT_ALLOWED_LOGIN_DOMAINS.slice();
}
async function saveLoginDomains() {
clearMessages("admin");
const domains = normalizeLoginDomainList(els.loginDomainsInput ? els.loginDomainsInput.value : "");
try {
const result = await api("/v1/admin/login-domains", {
method: "PUT",
body: { domains }
});
state.system.allowedLoginDomains = normalizeLoginDomainList(result && result.domains ? result.domains : domains);
renderMaintenanceBanner();
renderMessage(els.adminMessage, `Login-Domains gespeichert (${formatAllowedDomainsHint()}).`, false, true);
} catch (error) {
renderMessage(els.adminMessage, error.message, true);
}
} }
function renderBranding() { function renderBranding() {

View File

@@ -407,6 +407,19 @@
<button id="maintenanceDisableBtn" type="button" class="ghost-btn">Wartungsmodus deaktivieren</button> <button id="maintenanceDisableBtn" type="button" class="ghost-btn">Wartungsmodus deaktivieren</button>
</div> </div>
<hr class="separator" />
<div class="section-head">
<h3>Login Domains</h3>
<span class="pill">E-Mail Richtlinie</span>
</div>
<label class="field" style="margin-top: 0.6rem">
<span>Erlaubte Login-Domains (CSV oder Zeilenumbruch)</span>
<textarea id="loginDomainsInput" rows="3" placeholder="arcg.at, oevsv.at"></textarea>
</label>
<div class="actions">
<button id="saveLoginDomainsBtn" type="button" class="ghost-btn">Login Domains speichern</button>
</div>
</article> </article>
</section> </section>

View File

@@ -71,6 +71,7 @@ const config = {
autoDisableTxBeforeActivation: String(process.env.AUTO_DISABLE_TX_BEFORE_ACTIVATION || "").trim().toLowerCase(), autoDisableTxBeforeActivation: String(process.env.AUTO_DISABLE_TX_BEFORE_ACTIVATION || "").trim().toLowerCase(),
stationMaxUsageSec: Number(process.env.STATION_MAX_USAGE_SEC || 3600), stationMaxUsageSec: Number(process.env.STATION_MAX_USAGE_SEC || 3600),
primaryEmailDomain: String(process.env.PRIMARY_EMAIL_DOMAIN || "arcg.at").toLowerCase(), primaryEmailDomain: String(process.env.PRIMARY_EMAIL_DOMAIN || "arcg.at").toLowerCase(),
loginAllowedDomains: parseDomainList(process.env.ALLOWED_LOGIN_DOMAINS || "arcg.at,oevsv.at"),
publicBaseUrl: String(process.env.PUBLIC_BASE_URL || ""), publicBaseUrl: String(process.env.PUBLIC_BASE_URL || ""),
approverEmails: new Set( approverEmails: new Set(
String(process.env.APPROVER_EMAILS || "") String(process.env.APPROVER_EMAILS || "")
@@ -162,6 +163,7 @@ const runtime = {
logoLightUrl: null, logoLightUrl: null,
logoDarkUrl: null logoDarkUrl: null
}, },
loginAllowedDomains: config.loginAllowedDomains,
updatedAt: null updatedAt: null
}, },
pluginState: { pluginState: {
@@ -301,6 +303,9 @@ async function routeRequest(req, res) {
maintenanceMode: Boolean(runtime.systemState.maintenanceMode), maintenanceMode: Boolean(runtime.systemState.maintenanceMode),
maintenanceMessage: runtime.systemState.maintenanceMessage, maintenanceMessage: runtime.systemState.maintenanceMessage,
branding: runtime.systemState.branding, branding: runtime.systemState.branding,
allowedLoginDomains: Array.isArray(runtime.systemState.loginAllowedDomains)
? runtime.systemState.loginAllowedDomains
: config.loginAllowedDomains,
updatedAt: runtime.systemState.updatedAt updatedAt: runtime.systemState.updatedAt
}); });
} }
@@ -849,6 +854,31 @@ async function routeRequest(req, res) {
return handleMaintenanceUpdate(res, auth.user, body); return handleMaintenanceUpdate(res, auth.user, body);
} }
if (method === "GET" && url.pathname === "/v1/admin/login-domains") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
return sendJson(res, 200, {
ok: true,
domains: Array.isArray(runtime.systemState.loginAllowedDomains)
? runtime.systemState.loginAllowedDomains
: config.loginAllowedDomains,
updatedAt: runtime.systemState.updatedAt
});
}
if (method === "PUT" && url.pathname === "/v1/admin/login-domains") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
const body = await readJsonBody(req);
return handleLoginDomainsUpdate(res, auth.user, body);
}
if (method === "PUT" && url.pathname === "/v1/admin/branding/logo") { if (method === "PUT" && url.pathname === "/v1/admin/branding/logo") {
const auth = requireAuth(req, res); const auth = requireAuth(req, res);
if (!auth) return; if (!auth) return;
@@ -940,6 +970,9 @@ async function handleRequestAccess(req, res, body) {
if (!isValidEmail(email)) { if (!isValidEmail(email)) {
return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben"); return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben");
} }
if (!isLoginDomainAllowed(email)) {
return sendError(res, 403, "auth.email.domain_forbidden", "Nur Club-Mailadressen (@arcg.at oder @oevsv.at) sind zum Anmelden moeglich");
}
let user = runtime.users.find((entry) => entry.email === email); let user = runtime.users.find((entry) => entry.email === email);
if (runtime.systemState.maintenanceMode) { if (runtime.systemState.maintenanceMode) {
@@ -1573,6 +1606,22 @@ async function handleMaintenanceUpdate(res, actor, body) {
}); });
} }
async function handleLoginDomainsUpdate(res, actor, body) {
const domains = normalizeLoginDomainList(body && body.domains);
if (domains.length === 0) {
return sendError(res, 400, "auth.login_domains.invalid", "Mindestens eine gueltige Domain ist erforderlich");
}
runtime.systemState.loginAllowedDomains = domains;
runtime.systemState.updatedAt = new Date().toISOString();
await saveSystemState();
await appendAudit("admin.login_domains.update", actor, { domains });
return sendJson(res, 200, {
ok: true,
domains,
updatedAt: runtime.systemState.updatedAt
});
}
async function handleAdminBrandingLogoUpdate(res, actor, body) { async function handleAdminBrandingLogoUpdate(res, actor, body) {
const theme = typeof (body && body.theme) === "string" ? body.theme.trim().toLowerCase() : ""; const theme = typeof (body && body.theme) === "string" ? body.theme.trim().toLowerCase() : "";
if (theme !== "light" && theme !== "dark") { if (theme !== "light" && theme !== "dark") {
@@ -1743,6 +1792,7 @@ function parseImageDataUrl(value) {
function normalizeSystemState(state) { function normalizeSystemState(state) {
const input = state && typeof state === "object" ? state : {}; const input = state && typeof state === "object" ? state : {};
const branding = input.branding && typeof input.branding === "object" ? input.branding : {}; const branding = input.branding && typeof input.branding === "object" ? input.branding : {};
const loginAllowedDomains = normalizeLoginDomainList(input.loginAllowedDomains);
return { return {
maintenanceMode: Boolean(input.maintenanceMode), maintenanceMode: Boolean(input.maintenanceMode),
maintenanceMessage: typeof input.maintenanceMessage === "string" && input.maintenanceMessage.trim() maintenanceMessage: typeof input.maintenanceMessage === "string" && input.maintenanceMessage.trim()
@@ -1752,6 +1802,7 @@ function normalizeSystemState(state) {
logoLightUrl: typeof branding.logoLightUrl === "string" && branding.logoLightUrl.trim() ? branding.logoLightUrl : null, logoLightUrl: typeof branding.logoLightUrl === "string" && branding.logoLightUrl.trim() ? branding.logoLightUrl : null,
logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null
}, },
loginAllowedDomains: loginAllowedDomains.length > 0 ? loginAllowedDomains : config.loginAllowedDomains,
updatedAt: input.updatedAt || null updatedAt: input.updatedAt || null
}; };
} }
@@ -2495,10 +2546,41 @@ function domainForEmail(email) {
return at === -1 ? "" : email.slice(at + 1).toLowerCase(); return at === -1 ? "" : email.slice(at + 1).toLowerCase();
} }
function parseDomainList(value) {
return normalizeLoginDomainList(String(value || ""));
}
function normalizeLoginDomainList(value) {
const list = Array.isArray(value)
? value
: String(value || "")
.split(/[\n,;\s]+/);
const unique = [];
const seen = new Set();
for (const entry of list) {
const domain = String(entry || "").trim().toLowerCase();
if (!domain) continue;
if (!/^[a-z0-9.-]+$/.test(domain)) continue;
if (!domain.includes(".")) continue;
if (seen.has(domain)) continue;
seen.add(domain);
unique.push(domain);
}
return unique;
}
function isPrimaryDomainEmail(email) { function isPrimaryDomainEmail(email) {
return domainForEmail(email) === config.primaryEmailDomain; return domainForEmail(email) === config.primaryEmailDomain;
} }
function isLoginDomainAllowed(email) {
const allowed = Array.isArray(runtime.systemState && runtime.systemState.loginAllowedDomains)
? runtime.systemState.loginAllowedDomains
: config.loginAllowedDomains;
const domain = domainForEmail(email);
return allowed.includes(domain);
}
function stationUsageDurationMs() { function stationUsageDurationMs() {
return Math.max(1, Number(config.stationMaxUsageSec || 3600)) * 1000; return Math.max(1, Number(config.stationMaxUsageSec || 3600)) * 1000;
} }

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"); assert.ok(adminVerify.accessToken, "admin access token expected");
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; 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", { const guestStart = await requestJson(baseUrl, "/v1/auth/request-access", {
method: "POST", method: "POST",
body: { email: "guest@example.com" } body: { email: "guest@oevsv.at" }
}); });
const outbox2 = await readOutbox(dataDir); 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"); assert.ok(guestVerifyMail, "guest verify challenge must be written");
let guestVerify; 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"); assert.ok(guestCode, "guest otp code must be parsable");
guestVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { guestVerify = await requestJson(baseUrl, "/v1/auth/verify-email", {
method: "POST", method: "POST",
body: { email: "guest@example.com", code: guestCode } body: { email: "guest@oevsv.at", code: guestCode }
}); });
} else { } else {
const guestVerifyToken = extractTokenFromText(guestVerifyMail.text); 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"); assert.equal(guestVerify.approved, false, "external domain should not auto-approve");
const approvals = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); 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"); assert.ok(pending, "approval request for external user expected");
await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { 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 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"); assert.ok(guest, "guest user must exist");
await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(guest.id)}/auth-methods`, { 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", { const otpStart = await requestJson(baseUrl, "/v1/auth/request-access", {
method: "POST", 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"); assert.equal(otpStart.challengeType, "otp", "OTP challenge should be selected");
const outbox3 = await readOutbox(dataDir); const outbox3 = await readOutbox(dataDir);
const guestOtpMail = [...outbox3] const guestOtpMail = [...outbox3]
.reverse() .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"); assert.ok(guestOtpMail, "guest OTP mail must be written");
const otpMatch = /Code lautet:\s*(\d{6})/.exec(guestOtpMail.text); const otpMatch = /Code lautet:\s*(\d{6})/.exec(guestOtpMail.text);
assert.ok(otpMatch, "otp code must be parsable"); assert.ok(otpMatch, "otp code must be parsable");
@@ -239,7 +252,7 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
const otpVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { const otpVerify = await requestJson(baseUrl, "/v1/auth/verify-email", {
method: "POST", method: "POST",
body: { body: {
email: "guest@example.com", email: "guest@oevsv.at",
code: otpMatch[1] code: otpMatch[1]
} }
}); });
@@ -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) => { test("approver role can process external approvals without admin role", async (t) => {
const rootDir = path.resolve(__dirname, ".."); const rootDir = path.resolve(__dirname, "..");
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-approver-test-")); 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: {} 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"); assert.equal(externalVerify.approved, false, "external user should require approval");
const approvalsForApprover = await requestJson(baseUrl, "/v1/approvals", { headers: approverHeaders }); 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"); assert.ok(pending, "approver should see pending external request");
await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { 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: {} 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"); assert.ok(externalLogin.accessToken, "external user should be able to login after approver decision");
const operatorVerify = await requestAccessAndVerify(baseUrl, dataDir, "operator@arcg.at"); 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 adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
const externalEmail = "blocked@external.example"; const externalEmail = "blocked@oevsv.at";
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
assert.equal(externalVerify.approved, false, "external user should require approval"); 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 adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
const externalEmail = "codes@external.example"; const externalEmail = "codes@oevsv.at";
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
assert.equal(externalVerify.approved, false); 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 adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
const externalEmail = "statuscheck@outside.example"; const externalEmail = "statuscheck@oevsv.at";
const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
assert.equal(verify.approved, false); assert.equal(verify.approved, false);