From 3c04d662f69465442d2e9e6d08c293f2674768d5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 15 Sep 2026 04:14:04 +0200 Subject: [PATCH] feat: the password door is gone; One Sign In is the only way in POST /api/auth/login answers 410 for everyone, administrators included. The sign-in screen never draws an email or a password: it is the provider's button, or a sentence saying sign-in is not configured. The admin CLI no longer resets passwords. The e2e harness mints its sessions inside the container instead of signing in with a password. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP --- README.md | 6 +- admin-cli.js | 14 ----- docs/authentication.md | 6 +- docs/developer-guide.md | 2 +- docs/features-explained.md | 7 +-- e2e/fixtures.js | 36 ++++++------ e2e/seed.js | 24 ++++++++ e2e/tests/auth-screen.spec.js | 32 ++++------- public/index.html | 2 +- public/js/auth.js | 34 ++++------- src/routes/auth.js | 104 +++------------------------------- src/utils/openapiRoutes.js | 4 +- src/utils/policy.js | 7 ++- test/policy-flows.test.js | 35 +++++------- 14 files changed, 102 insertions(+), 211 deletions(-) diff --git a/README.md b/README.md index 9eb95b50..e5a1eb3e 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,8 @@ The app runs as an authenticated Express/Postgres service with a browser fronten ### Admin And Security -- Sign in with a password or a six-digit code emailed to you — offered side by side, because a code depends on mail arriving and a password does not. -- Role-based access, TOTP 2FA, OIDC/SSO, email verification, and optional Turnstile. Passwords are argon2id, with bcrypt rows rehashed on their next sign-in. -- Registration can be open, closed, or invite-only with generated codes. A code can be revoked while live, and deleted only once it is spent. +- One Sign In (OIDC, Authentik) is the only way in. There is no password sign-in, no registration and no password reset in the app; accounts, invitations and roles (from groups) are managed at the provider. +- Role-based access from the provider's groups, audit trail, and optional Turnstile on the routes that still take free text. - Admin panel for users, settings, prompts, models, and logs. - Audit, API, access, and client-error logs with redaction hardening. - OpenBao secret loading support at container startup. @@ -106,7 +105,6 @@ Supported text AI providers are LiteLLM, OpenRouter, AWS Bedrock, and Azure Open docker exec pediatric-ai-scribe node admin-cli.js list-users docker exec pediatric-ai-scribe node admin-cli.js create-admin admin@example.com password123 "Dr. Admin" docker exec pediatric-ai-scribe node admin-cli.js make-admin user@example.com -docker exec pediatric-ai-scribe node admin-cli.js reset-password user@example.com newpassword docker exec pediatric-ai-scribe node admin-cli.js stats ``` diff --git a/admin-cli.js b/admin-cli.js index f9efb32b..39e47ecd 100644 --- a/admin-cli.js +++ b/admin-cli.js @@ -77,19 +77,6 @@ async function main() { break; } - case 'reset-password': { - var email = args[1]; - var newPass = args[2]; - if (!email || !newPass) { console.log('Usage: node admin-cli.js reset-password '); process.exit(1); } - if (newPass.length < 8) { console.log('❌ Password must be 8+ characters'); process.exit(1); } - var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]); - if (!user) { console.log('❌ User not found: ' + email); process.exit(1); } - var hash = await bcrypt.hash(newPass, 12); - await db.run('UPDATE users SET password = ? WHERE id = ?', [hash, user.id]); - console.log('✅ Password reset for ' + user.name + ' (' + email + ')'); - break; - } - case 'create-admin': { var email = args[1]; var password = args[2]; @@ -161,7 +148,6 @@ async function main() { console.log(' disable-user Disable user account'); console.log(' enable-user Enable user account'); console.log(' delete-user Delete user permanently'); - console.log(' reset-password Reset user password'); console.log(''); console.log('App Settings:'); console.log(' stats Show app statistics'); diff --git a/docs/authentication.md b/docs/authentication.md index 4942d352..65981b3f 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -5,8 +5,10 @@ The front door is `sso.pedshub.com` (Authentik, `/home/danvics/docker/authentik-pedshub`). With `oidc.enabled` set, `/register` (always 410), `/forgot-password`, `/reset-password`, `/change-password` and the 2FA routes answer 403 -(`requireLocalAuth`); `/api/auth/login` admits administrators only, as the way -back in if the provider is down ("Administrator sign-in" on the sign-in screen). +(`requireLocalAuth`); `/api/auth/login` always answers 410. There is no +password door. If the provider is down, fix the provider: authentik's own +recovery link is minted on the host with +`docker exec authentik-pedshub-server ak create_recovery_key 10 akadmin`. Roles come from the provider's groups (`oidc.admin_groups`, `oidc.moderator_groups`). The OIDC client is `src/routes/oidc.js`: signed state cookie, PKCE, nonce, `email_verified` required before an existing local account is linked, `sub` mismatch refused, session row written before the diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 23929be2..f2fe2f9c 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -259,7 +259,7 @@ docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_my_table | File | Mount | Auth | Purpose | |---|---|---|---| -| `auth.js` | `/api/auth` | Public | Register, login, 2FA, email verify, password reset, backup codes | +| `auth.js` | `/api/auth` | Public | `/me`, logout, sessions; `/login` and `/register` answer 410; the password and 2FA routes sit behind `requireLocalAuth` | | `oidc.js` | `/api/auth` | Public | OIDC SSO (Authorization Code + PKCE) | | `sessions.js` | `/api/sessions` | Auth | Active sessions list + revoke | | `hpi.js` | `/api` | Auth | HPI from encounter or dictation | diff --git a/docs/features-explained.md b/docs/features-explained.md index 72376319..6620e883 100644 --- a/docs/features-explained.md +++ b/docs/features-explained.md @@ -30,10 +30,9 @@ their email and the code that is sent to it — no password. New people are invited with a sign-up link (`authentik-pedshub/invite.py` on the host mints one); they enter a name and email, confirm with a code, and land in the `pedshub-members` group, which is what both PedsHub apps admit. The same -account signs into the quiz app at `pedshub.com`. Registration, password reset and the app's own emailed codes are gone; the -only password door is the administrators' "Administrator sign-in", the way -back in if the provider is down. A local account with the same email is the -same account. +account signs into the quiz app at `pedshub.com`. Registration, password reset, password sign-in and the app's own emailed +codes are all gone; there is no door but the provider's. A local account with +the same email is the same account. ## Text To Speech diff --git a/e2e/fixtures.js b/e2e/fixtures.js index 4789ebbe..e80c7a70 100644 --- a/e2e/fixtures.js +++ b/e2e/fixtures.js @@ -19,7 +19,6 @@ const base = require('@playwright/test'); const E2E_BASE = process.env.E2E_AUTH_BASE_URL || 'http://127.0.0.1:3553'; const TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-user@ped-ai.test'; -const TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'E2E-testPassword123!'; // Seeded with the admin role by e2e/seed.js. Kept as a separate account rather // than promoting the ordinary user, so a test that asserts something is denied // to a non-admin still has a non-admin to assert it with. @@ -53,28 +52,32 @@ function isAllowedConsoleNoise(text) { } // ── Auth — module-scoped token cache ──────────────────────── -// Keeps one login per account per worker to avoid the 10/15-min login -// rate-limiter. Keyed by email, because there is more than one account now and -// a single slot would have each login evicting the other's token. +// One session per account per worker. There is no password sign-in to call, +// so the session is minted inside the app container by the seed script (a +// signed token and a session row, exactly what the server writes after the +// provider has vouched for the person). Keyed by email, because there is more +// than one account and a single slot would have each evicting the other. const _tokenCache = new Map(); async function tokenFor(request, email) { if (_tokenCache.has(email)) return _tokenCache.get(email); - const r = await request.post(E2E_BASE + '/api/auth/login', { - data: { email, password: TEST_PASSWORD }, - }); - if (!r.ok()) { - const text = await r.text(); + const { execFileSync } = require('child_process'); + const path = require('path'); + let token; + try { + token = execFileSync('docker', ['compose', '-f', 'docker-compose.yml', '-f', 'docker-compose.e2e.yml', + 'exec', '-T', 'pediatric-scribe-e2e', 'node', 'e2e/seed.js', 'token', email], + { cwd: path.resolve(__dirname, '..'), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + } catch (err) { // The overwhelmingly likely cause is an unseeded database, and saying so - // beats leaving someone to work back from a 401. + // beats leaving someone to work back from a stack trace. throw new Error( - `E2E login failed for ${email} (status ${r.status()}): ${text}\n` + - 'If the account does not exist, seed it: docker exec pediatric-ai-scribe-e2e node e2e/seed.js' + `E2E could not mint a session for ${email}: ${(err.stderr || err.message || '').toString().trim()}\n` + + 'If the account does not exist, seed it: docker compose -f docker-compose.yml -f docker-compose.e2e.yml exec -T pediatric-scribe-e2e node e2e/seed.js' ); } - const body = await r.json(); - if (!body.token) throw new Error('Login response missing token: ' + JSON.stringify(body)); - _tokenCache.set(email, body.token); - return body.token; + if (!token) throw new Error('Seed script printed no token for ' + email); + _tokenCache.set(email, token); + return token; } async function getAuthToken(request) { return tokenFor(request, TEST_EMAIL); } @@ -204,7 +207,6 @@ module.exports = { expect, E2E_BASE, TEST_EMAIL, - TEST_PASSWORD, ADMIN_EMAIL, loginAs, getAuthToken, diff --git a/e2e/seed.js b/e2e/seed.js index ecd32f4a..f2610cd3 100644 --- a/e2e/seed.js +++ b/e2e/seed.js @@ -41,6 +41,26 @@ var passwords = require('../src/utils/passwords'); var TEST_DOMAIN = '@ped-ai.test'; var PASSWORD = process.env.E2E_TEST_PASSWORD || 'E2E-testPassword123!'; +// `node e2e/seed.js token ` prints a signed session token for a seeded +// account. The suite used to sign in with the password; there is no password +// sign-in any more, so the harness mints the session the way the server would +// after the provider vouched for the person: a signed JWT and a session row. +async function mintToken(email) { + email = String(email || '').toLowerCase().trim(); + if (email.slice(-TEST_DOMAIN.length) !== TEST_DOMAIN) { + throw new Error('refusing to mint a token for ' + email + ': only ' + TEST_DOMAIN + ' addresses'); + } + var user = await db.get('SELECT id FROM users WHERE email = ?', [email]); + if (!user) throw new Error('no such seeded account: ' + email); + var jwt = require('jsonwebtoken'); + var JWT_SECRET = require('../src/middleware/auth').JWT_SECRET; + var sessions = require('../src/utils/sessions'); + var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '30d' }); + await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)', + [sessions.generateSessionId(), user.id, sessions.hashToken(token), '127.0.0.1', 'e2e', 'e2e']); + return token; +} + var ACCOUNTS = [ { email: process.env.E2E_TEST_EMAIL || 'e2e-user' + TEST_DOMAIN, name: 'E2E User', role: 'user' }, { email: process.env.E2E_ADMIN_EMAIL || 'e2e-admin' + TEST_DOMAIN, name: 'E2E Admin', role: 'admin' } @@ -108,6 +128,10 @@ async function seed(account) { (async function () { try { + if (process.argv[2] === 'token') { + process.stdout.write(await mintToken(process.argv[3])); + process.exit(0); + } for (var i = 0; i < ACCOUNTS.length; i++) await seed(ACCOUNTS[i]); await seedSettings(); console.log('e2e accounts ready'); diff --git a/e2e/tests/auth-screen.spec.js b/e2e/tests/auth-screen.spec.js index 18b7fa0e..652a0397 100644 --- a/e2e/tests/auth-screen.spec.js +++ b/e2e/tests/auth-screen.spec.js @@ -11,36 +11,24 @@ test.describe('Unauthenticated auth screen', () => { // Use the base test that doesn't auto-login. // - // Signing in is a stepped flow, not one form: email first, then a choice - // between a password and an emailed code. The password field exists in the - // DOM from the start but stays hidden until that choice is made, so asserting - // it visible on the landing screen tests a page that no longer exists. - test('landing asks for the email only, and hides the rest of the flow', async ({ page }) => { + // There is no password sign-in. The screen is the provider's button when a + // provider is configured, and otherwise a sentence saying sign-in is not + // set up; the email and password fields exist in the DOM for the local + // machinery behind the switch and are never shown. + test('the sign-in screen never offers an email or a password', async ({ page }) => { await page.goto(E2E_BASE + '/'); await expect(page.locator('#auth-screen')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('#login-email')).toBeVisible(); - await expect(page.locator('#btn-login-continue')).toBeVisible(); - // Later steps are present but not yet offered. + await expect(page.locator('#login-email')).toBeHidden(); + await expect(page.locator('#btn-login-continue')).toBeHidden(); await expect(page.locator('#login-password')).toBeHidden(); await expect(page.locator('#btn-local-login')).toBeHidden(); + await expect(page.locator('#show-admin-login')).toHaveCount(0); + // One of the two things the screen can be. + await expect(page.locator('#btn-sso:visible, #signin-unavailable:visible')).toHaveCount(1); // main app body must be hidden while unauthenticated await expect(page.locator('#main-app')).toBeHidden(); }); - test('an email leads straight to the password', async ({ page }) => { - await page.goto(E2E_BASE + '/'); - await page.waitForSelector('#auth-screen', { timeout: 10000 }); - await page.fill('#login-email', 'someone@ped-ai.test'); - await page.click('#btn-login-continue'); - - // The address is fixed once the flow has moved past it; "use a different - // email" is how you go back, and it only appears after the first step. - await expect(page.locator('#login-email')).toHaveJSProperty('readOnly', true); - await expect(page.locator('#login-change-email')).toBeVisible(); - await expect(page.locator('#login-password')).toBeVisible(); - await expect(page.locator('#btn-local-login')).toBeVisible(); - }); - test('register form DOM is wired correctly if manually unhidden', async ({ page }) => { await page.goto(E2E_BASE + '/'); await page.waitForSelector('#auth-screen', { timeout: 10000 }); diff --git a/public/index.html b/public/index.html index 61f498e0..3e007f6f 100644 --- a/public/index.html +++ b/public/index.html @@ -85,6 +85,7 @@ or
+ @@ -95,7 +96,6 @@ diff --git a/public/js/auth.js b/public/js/auth.js index 009127df..44f4149a 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -255,7 +255,7 @@ document.addEventListener('DOMContentLoaded', function() { no_email: 'Your identity provider did not return an email', disabled: 'Account disabled', sso_failed: 'SSO login failed', - sso_disabled: 'SSO is no longer enabled. Use local sign-in.', + sso_disabled: 'One Sign In is not enabled on this server.', account_link_required: 'This local account is unverified. Account recovery and administrator-assisted linking are required before SSO sign-in.', email_unverified: 'Your identity provider did not confirm your email is verified. Contact your administrator.', sub_mismatch: 'Your SSO identity does not match the linked account. Contact your administrator.' @@ -268,32 +268,22 @@ document.addEventListener('DOMContentLoaded', function() { fetch('/api/auth/oidc-status') .then(function(r) { return r.json(); }) .then(function(data) { + // The password door is gone from the server, so the email and password + // fields are never drawn: with a provider the button is the whole + // screen, and without one a sentence says sign-in is not set up. + var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, ' + + '#btn-login-continue, #login-change-email, #show-register, #show-forgot, #bio-divider'); + localFields.forEach(function(el) { el.style.display = 'none'; }); + var ssoDivider = document.getElementById('sso-divider'); + if (ssoDivider) ssoDivider.style.display = 'none'; if (data.oidcEnabled) { var ssoBtn = document.getElementById('btn-sso'); - var ssoDivider = document.getElementById('sso-divider'); var ssoLabel = document.getElementById('sso-label'); if (ssoBtn) ssoBtn.style.display = 'block'; - if (ssoDivider) ssoDivider.style.display = 'block'; if (ssoLabel && data.buttonLabel) ssoLabel.textContent = data.buttonLabel; - if (data.disableLocalAuth) { - // Hide local login form fields, only show SSO. A small link brings - // them back for an administrator: the password is the way back in - // when the provider is down, and nobody else's door. - var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, ' + - '#btn-login-continue, #login-change-email, #show-register, #show-forgot'); - localFields.forEach(function(el) { el.style.display = 'none'; }); - if (ssoDivider) ssoDivider.style.display = 'none'; - var adminLink = document.getElementById('show-admin-login'); - if (adminLink) { - adminLink.style.display = ''; - adminLink.addEventListener('click', function(e) { - e.preventDefault(); - document.querySelectorAll('#login-form .form-group, #btn-local-login, #btn-login-continue') - .forEach(function(el) { el.style.display = ''; }); - adminLink.style.display = 'none'; - }); - } - } + } else { + var unavailable = document.getElementById('signin-unavailable'); + if (unavailable) { unavailable.className = unavailable.className.replace('hidden', '').trim(); unavailable.style.display = ''; } } }) .catch(function() {}); diff --git a/src/routes/auth.js b/src/routes/auth.js index e4d31544..dd1310cf 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -222,103 +222,13 @@ router.post('/resend-verification', async (req, res) => { // ============================================================ // LOGIN (checks disabled status) // ============================================================ -router.post('/login', async (req, res) => { - try { - var { email, password, totpCode } = req.body; - if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); - - // No Turnstile on login. The widget could not reliably complete a - // challenge inside the Capacitor WebView, which locked mobile users out. - // Brute-force cover here comes from the 10-per-15-min per-IP rate limit - // (server.js), the constant-time bcrypt comparison below, and TOTP 2FA. - // Registration and password reset — the endpoints that actually attract - // bots — are still gated. - - var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]); - // Enumeration-resistant: always run bcrypt to keep timing constant, and return - // the same generic message for unknown-user, wrong-password, disabled, unverified. - var DUMMY_HASH = '$2b$12$CwTycUXWue0Thq9StjUM0uJ8.aDLA18dY6nB5xuWz5M6l8lR6rYS.'; - if (!user) { - // Server-side only — no email in the message, so operators can watch - // lookup-miss rates in Grafana without leaking which addresses exist. - console.warn('[Auth] login: user not found (ip=' + req.ip + ')'); - await bcrypt.compare(password, DUMMY_HASH).catch(function(){}); - return res.status(401).json({ error: 'Invalid credentials' }); - } - - var valid; - try { - valid = await passwords.verify(password, user.password); - } catch (verifyErr) { - console.error('[Auth] passwords.verify threw:', verifyErr.message); - return res.status(500).json({ error: 'Request failed' }); - } - if (!valid) { - await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]).catch(function(){}); - logger.access(user.id, 'login_failed', req, false); - return res.status(401).json({ error: 'Invalid credentials' }); - } - - // Transparent migration: bcrypt → argon2id on next successful login. - passwords.maybeRehash(password, user.password).then(function(newHash) { - if (newHash) db.run('UPDATE users SET password = ? WHERE id = ?', [newHash, user.id]).catch(function(){}); - }).catch(function(){}); - - if (user.disabled) { - await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)', - [user.id, 'login_blocked', req.ip, 'Account disabled']).catch(function(){}); - logger.access(user.id, 'login_blocked', req, false); - return res.status(401).json({ error: 'Invalid credentials' }); - } - - if (!user.email_verified) { - return res.status(403).json({ error: 'Email not verified', needsVerification: true }); - } - - if (user.totp_enabled) { - if (!totpCode) return res.json({ requires2FA: true }); - var totpInput = String(totpCode).trim(); - var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpInput, window: 1 }); - if (!verified) { - // Backup-code fallback. Backup codes are 10-char alphanumeric and - // each hash is bcrypt — consumed on use. - var consumed = await tryConsumeBackupCode(user.id, totpInput); - if (!consumed) return res.status(401).json({ error: 'Invalid 2FA code' }); - await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)', - [user.id, '2fa_backup_code_used', req.ip, 'Backup code consumed at login']).catch(function(){}); - logger.audit(user.id, '2fa_backup_code_used', 'Backup code consumed at login', req, { category: 'auth' }); - } - } - - var token = signAuthToken(user.id, req); - await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]); - logger.access(user.id, 'login', req, true); - - // Create session record - // Everyone signs in through One Sign In. A password is the administrators' - // way back in when the provider is unreachable, nothing more; anyone else - // who still holds one is sent to the front door. Checked after the - // credential so this route does not say which addresses hold a password. - var ssoOnly; - try { ssoOnly = await isSSOOnly(); } catch (e) { - return res.status(503).json({ error: 'Authentication policy unavailable' }); - } - if (ssoOnly && user.role !== 'admin') { - return res.status(403).json({ error: 'Sign in with One Sign In. Password sign-in is kept for administrators.', code: 'sso_only' }); - } - var sessionId = generateSessionId(); - await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)', - [sessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]); - - // Notify user of new login (fire-and-forget) - notifyNewLogin(user.id, parseUserAgent(req.headers['user-agent']), req.ip); - - setAuthCookie(res, token); - res.json({ - success: true, token: token, sessionId: sessionId, - user: { id: user.id, email: user.email, name: user.name, role: user.role, totp_enabled: user.totp_enabled, email_verified: user.email_verified } - }); - } catch (err) { console.error('[Auth] Login error:', err.message); res.status(500).json({ error: 'Login failed' }); } +// Closed. Every account signs in through One Sign In. This was the last +// password door, kept for administrators as the way back in if the provider +// was down; the provider's own recovery key is the way back in (see +// docs/authentication.md), and a second credential nobody rotates is a hole, +// not a spare key. The address stays so an old client is told plainly. +router.post('/login', function (req, res) { + res.status(410).json({ error: 'Password sign-in has been removed. Sign in with One Sign In.', code: 'sso_only' }); }); // ── 2FA backup codes helpers ────────────────────────────── diff --git a/src/utils/openapiRoutes.js b/src/utils/openapiRoutes.js index 2a981b0a..198f5035 100644 --- a/src/utils/openapiRoutes.js +++ b/src/utils/openapiRoutes.js @@ -51,8 +51,8 @@ var operations = { }, // ── Session ───────────────────────────────────────────────────────── 'POST /api/auth/login': { - summary: 'Sign in with a password', - description: 'Returns a token and sets the ped_auth cookie. Rate limited.', + summary: 'Closed', + description: 'Always 410. Sign in through One Sign In (GET /api/auth/oidc).', public: true }, 'POST /api/auth/register': { diff --git a/src/utils/policy.js b/src/utils/policy.js index 1a89cb6f..c0e045de 100644 --- a/src/utils/policy.js +++ b/src/utils/policy.js @@ -1,8 +1,9 @@ var db = require('../db/database'); -// One Sign In is the front door whenever OIDC is configured. There is no -// second switch: the only password door that remains is /api/auth/login for -// administrators, the way back in if the provider is down (see auth.js). +// One Sign In is the only door whenever OIDC is configured. There is no +// second switch and no password door: /api/auth/login answers 410, and the +// local password machinery behind requireLocalAuth is reachable only on a +// server with no provider configured. async function isSSOOnly() { return await db.getSetting('oidc.enabled') === 'true'; } diff --git a/test/policy-flows.test.js b/test/policy-flows.test.js index a3f11df2..97df753e 100644 --- a/test/policy-flows.test.js +++ b/test/policy-flows.test.js @@ -241,7 +241,7 @@ test('OIDC refuses unsafe linking, disabled/mismatched identities, and session f } }); -test('with One Sign In on, every password door but the administrators\' login is shut; with it off, local auth works; a settings outage answers 503', async t => { +test('with One Sign In on, every password door is shut; the login route is closed whatever the switch says', async t => { const f = fixture(); const request = await f.serve(t, true); for (const route of ['/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/change-password', '/api/auth/setup-2fa', '/api/auth/verify-2fa', '/api/auth/2fa/backup-codes', '/api/admin/users/7/reset-password']) { const response = await request(route, { method: 'POST', body: {}, authenticated: true }); @@ -250,31 +250,22 @@ test('with One Sign In on, every password door but the administrators\' login is assert.equal(f.state.writes.length, 0); assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, true); assert.equal((await request('/api/auth/me', { authenticated: true })).data.user.canLocalAuth, false); - // An administrator's password still opens the door; a learner's does not. - const admin = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } }); - assert.equal(admin.status, 200); assert.equal(authCookies(admin).length, 1); - f.state.user.role = 'user'; - const learner = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } }); - assert.equal(learner.status, 403); assert.equal(learner.data.code, 'sso_only'); assert.equal(authCookies(learner).length, 0); - f.state.user.role = 'admin'; - f.state.settings['oidc.enabled'] = 'false'; - assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, false); - f.state.user.role = 'user'; - const local = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } }); - assert.equal(local.status, 200); assert.equal(authCookies(local).length, 1); - f.state.settingsError = true; - const unavailable = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } }); - assert.equal(unavailable.status, 503); assert.equal(authCookies(unavailable).length, 0); + // No password opens the door — not an administrator's, not with the switch + // off, not when the settings store is away. Nothing is read, nothing is written. + for (const arrange of [() => {}, () => { f.state.user.role = 'user'; }, () => { f.state.settings['oidc.enabled'] = 'false'; }, () => { f.state.settingsError = true; }]) { + arrange(); + const closed = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } }); + assert.equal(closed.status, 410); assert.equal(closed.data.code, 'sso_only'); assert.equal(authCookies(closed).length, 0); + } + assert.equal(f.state.writes.filter(w => w.sql.includes('INSERT INTO user_sessions')).length, 0); }); -test('local login never issues success/cookie on session insert failure, and registration is closed', async t => { - const f = fixture(); const request = await f.serve(t); f.state.sessionError = true; - const login = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } }); - assert.equal(login.status, 500); assert.equal(authCookies(login).length, 0); assert.equal(login.data.success, undefined); - assert.equal(f.state.writes.filter(w => w.sql.includes('INSERT INTO user_sessions')).length, 1); - // Local registration is closed for good: it answers 410 before touching anything. +test('registration is closed for good', async t => { + const f = fixture(); const request = await f.serve(t); + // Answers 410 before touching anything. const registration = await request('/api/auth/register', { method: 'POST', body: { email: 'new@example.test', password: 'synthetic-password', name: 'Synthetic' } }); assert.equal(registration.status, 410); assert.equal(authCookies(registration).length, 0); + assert.equal(f.state.writes.length, 0); }); test('final model allowlist is enforced for streaming/nonstream, invalid settings/outage, defaults and fallback', async () => {