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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
This commit is contained in:
Daniel 2026-09-15 04:14:04 +02:00
parent 29ff7e435c
commit 3c04d662f6
14 changed files with 102 additions and 211 deletions

View file

@ -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
```

View file

@ -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 <email> <new-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 <email> Disable user account');
console.log(' enable-user <email> Enable user account');
console.log(' delete-user <email> Delete user permanently');
console.log(' reset-password <email> <new-password> Reset user password');
console.log('');
console.log('App Settings:');
console.log(' stats Show app statistics');

View file

@ -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

View file

@ -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 |

View file

@ -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

View file

@ -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,

View file

@ -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 <email>` 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');

View file

@ -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 });

View file

@ -85,6 +85,7 @@
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
<hr style="border:none;border-top:1px solid #e5e7eb;position:absolute;top:50%;left:0;right:0;margin:0;">
</div>
<p id="signin-unavailable" class="hidden" style="display:none;margin:8px 0 0;font-size:14px;color:#6b7280;text-align:center;">Sign-in is not configured on this server. Accounts sign in through One Sign In, which has to be set up first.</p>
<a href="/api/auth/oidc" id="btn-sso" class="btn-auth" style="display:none;text-align:center;text-decoration:none;background:linear-gradient(135deg,#0f172a,#334155);margin-top:0;">
<i class="fas fa-shield-halved"></i> <span id="sso-label">Sign in with SSO</span>
</a>
@ -95,7 +96,6 @@
<div class="auth-links">
<a href="#" id="show-register" style="display:none">Create account</a>
<a href="#" id="show-forgot">Forgot password?</a>
<a href="#" id="show-admin-login" style="display:none">Administrator sign-in</a>
<a href="#" id="login-change-email" class="hidden" style="display:none;">Use a different email</a>
</div>
</form>

View file

@ -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() {});

View file

@ -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 ──────────────────────────────

View file

@ -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': {

View file

@ -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';
}

View file

@ -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 () => {