fix(cms): Safari-compatible authentication
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Safari-specific deviations fixed: 1. Safari shows its native auth dialog on fetch() calls answered with a 401 + WWW-Authenticate challenge (e.g. save with an expired session). All CMS 401 responses now omit WWW-Authenticate; browsers use the styled /login page instead. 2. Safari caches Basic credentials and resends them automatically, which made logout ineffective (a navigation after logout went straight back into the editor). Browser navigations (GET + text/html) now authenticate ONLY via the session cookie; Basic Auth remains valid for non-browser clients (curl, API). 3. /login and redirects send Cache-Control: no-store so Safari does not cache the login page or the 302. Closes MITHOME-62
This commit is contained in:
+19
-7
@@ -95,7 +95,16 @@ function hasValidCredentials(req) {
|
|||||||
return validateLogin(login, password, CMS_USER, CMS_PASS);
|
return validateLogin(login, password, CMS_USER, CMS_PASS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBrowserNavigation(req) {
|
||||||
|
return req.method === 'GET' && String(req.headers.accept || '').includes('text/html');
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHY: Safari (and other browsers) cache Basic Auth credentials and resend them
|
||||||
|
// automatically, which would let an already-logged-out browser straight back in.
|
||||||
|
// Browser navigations therefore authenticate ONLY via the session cookie, so
|
||||||
|
// logout is final. Non-browser requests (curl, API clients) keep Basic Auth.
|
||||||
function isAuthenticated(req) {
|
function isAuthenticated(req) {
|
||||||
|
if (isBrowserNavigation(req)) return hasValidSession(req);
|
||||||
return hasValidCredentials(req) || hasValidSession(req);
|
return hasValidCredentials(req) || hasValidSession(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +148,9 @@ const server = http.createServer(async (req, res) => {
|
|||||||
// which overwrites the cached pair; the next navigation prompts for login again.
|
// which overwrites the cached pair; the next navigation prompts for login again.
|
||||||
// Deliberately exempt from the auth rate limiter so logging out never locks the user out.
|
// Deliberately exempt from the auth rate limiter so logging out never locks the user out.
|
||||||
if (u.pathname === '/logout' && req.method === 'GET') {
|
if (u.pathname === '/logout' && req.method === 'GET') {
|
||||||
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' });
|
// Legacy cache-buster endpoint; no WWW-Authenticate — Safari would show its
|
||||||
|
// native auth dialog on any fetch hitting this challenge.
|
||||||
|
res.writeHead(401, { 'Cache-Control': 'no-store' });
|
||||||
res.end('Logged out');
|
res.end('Logged out');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,7 +169,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
// Public: styled login page (shown after logout and for unauthenticated browser visits).
|
// Public: styled login page (shown after logout and for unauthenticated browser visits).
|
||||||
if (req.method === 'GET' && u.pathname === '/login') {
|
if (req.method === 'GET' && u.pathname === '/login') {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
res.end(LOGIN_PAGE());
|
res.end(LOGIN_PAGE());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -208,14 +219,15 @@ const server = http.createServer(async (req, res) => {
|
|||||||
res.end('Too many authentication attempts');
|
res.end('Too many authentication attempts');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Browser navigations land on the styled login page; API/curl keeps the 401 challenge.
|
// Browser navigations land on the styled login page; API/curl gets a plain 401.
|
||||||
const acceptsHtml = String(req.headers.accept || '').includes('text/html');
|
// WHY no WWW-Authenticate: Safari pops its native auth dialog on fetch() calls
|
||||||
if (acceptsHtml && req.method === 'GET') {
|
// that receive a Basic challenge — the styled /login page handles browsers.
|
||||||
res.writeHead(302, { Location: '/login' });
|
if (isBrowserNavigation(req)) {
|
||||||
|
res.writeHead(302, { Location: '/login', 'Cache-Control': 'no-store' });
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' });
|
res.writeHead(401, { 'Cache-Control': 'no-store' });
|
||||||
res.end('Access denied');
|
res.end('Access denied');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,10 +116,26 @@ async function main() {
|
|||||||
assert.equal(redirected.status, 302);
|
assert.equal(redirected.status, 302);
|
||||||
assert.equal(redirected.headers.get('location'), '/login');
|
assert.equal(redirected.headers.get('location'), '/login');
|
||||||
|
|
||||||
// 6. non-browser requests keep the 401 challenge (curl/API compatibility)
|
// 6. non-browser requests get a plain 401 WITHOUT a Basic challenge
|
||||||
|
// (Safari pops its native auth dialog on challenged fetch calls).
|
||||||
const apiStyle = await fetch(`${BASE}/`);
|
const apiStyle = await fetch(`${BASE}/`);
|
||||||
assert.equal(apiStyle.status, 401);
|
assert.equal(apiStyle.status, 401);
|
||||||
assert.match(apiStyle.headers.get('www-authenticate') || '', /Basic realm="mozdIT CMS"/);
|
assert.equal(apiStyle.headers.get('www-authenticate'), null);
|
||||||
|
|
||||||
|
// 7. Safari scenario: browser navigation with CACHED Basic credentials but no
|
||||||
|
// session must still land on /login — otherwise logout would be ineffective
|
||||||
|
// in browsers that resend Basic auth automatically.
|
||||||
|
const basic = 'Basic ' + Buffer.from('login-test-user:login-test-pass').toString('base64');
|
||||||
|
const safariLike = await fetch(`${BASE}/`, {
|
||||||
|
headers: { Authorization: basic, Accept: 'text/html,application/xhtml+xml' },
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
assert.equal(safariLike.status, 302);
|
||||||
|
assert.equal(safariLike.headers.get('location'), '/login');
|
||||||
|
|
||||||
|
// 8. the same credentials DO authenticate a non-browser request (curl/API)
|
||||||
|
const curlLike = await fetch(`${BASE}/`, { headers: { Authorization: basic } });
|
||||||
|
assert.equal(curlLike.status, 200);
|
||||||
|
|
||||||
console.log('Content Editor login flow test: OK');
|
console.log('Content Editor login flow test: OK');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -47,10 +47,11 @@ async function waitForServer(timeoutMs = 10000) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
await waitForServer();
|
await waitForServer();
|
||||||
|
|
||||||
// 1. /logout answers 401 with a challenge header, without credentials
|
// 1. /logout answers 401 without a Basic challenge (Safari would pop its
|
||||||
|
// native auth dialog on challenged fetch calls)
|
||||||
const logoutRes = await fetch(`${BASE}/logout`);
|
const logoutRes = await fetch(`${BASE}/logout`);
|
||||||
assert.equal(logoutRes.status, 401);
|
assert.equal(logoutRes.status, 401);
|
||||||
assert.match(logoutRes.headers.get('www-authenticate') || '', /Basic realm="mozdIT CMS"/);
|
assert.equal(logoutRes.headers.get('www-authenticate'), null);
|
||||||
|
|
||||||
// 2. /logout is exempt from the auth rate limiter: many logout calls must not
|
// 2. /logout is exempt from the auth rate limiter: many logout calls must not
|
||||||
// consume the failed-login budget.
|
// consume the failed-login budget.
|
||||||
|
|||||||
Reference in New Issue
Block a user