diff --git a/README.md b/README.md index 1e473021..9eb95b50 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ curl -fsS http://127.0.0.1:3552/api/health Prometheus metrics are exposed at `GET /metrics` with the `ped_ai_` metric prefix. -The first registered user becomes an admin unless registration has already been configured differently. +Accounts, roles and invitations are managed in authentik (One Sign In); the app has no registration. ## Core Environment @@ -107,7 +107,6 @@ 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 toggle-registration docker exec pediatric-ai-scribe node admin-cli.js stats ``` diff --git a/admin-cli.js b/admin-cli.js index 1b4e1ec7..f9efb32b 100644 --- a/admin-cli.js +++ b/admin-cli.js @@ -130,19 +130,10 @@ async function main() { break; } - case 'toggle-registration': { - var current = await db.getSetting('registration_enabled'); - var newValue = current === 'false' ? 'true' : 'false'; - await db.setSetting('registration_enabled', newValue); - console.log('✅ Registration is now: ' + (newValue === 'true' ? 'ENABLED' : 'DISABLED')); - break; - } - case 'stats': { var userCount = await db.get('SELECT COUNT(*) as count FROM users', []); var apiCount = await db.get('SELECT COUNT(*) as count FROM api_log', []); var todayApi = await db.get("SELECT COUNT(*) as count FROM api_log WHERE timestamp > NOW() - INTERVAL '1 day'", []); - var regEnabled = await db.getSetting('registration_enabled'); console.log(''); console.log('📊 App Statistics'); @@ -173,7 +164,6 @@ async function main() { console.log(' reset-password Reset user password'); console.log(''); console.log('App Settings:'); - console.log(' toggle-registration Toggle registration on/off'); console.log(' stats Show app statistics'); console.log(''); } diff --git a/docs/authentication.md b/docs/authentication.md index b006f21a..4942d352 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -3,9 +3,11 @@ ## Sign-in is SSO-only The front door is `sso.pedshub.com` (Authentik, `/home/danvics/docker/authentik-pedshub`). -`oidc.enabled` and `oidc.disable_local_auth` are both `true`, so `/api/auth/login`, -`/register`, `/forgot-password`, `/reset-password`, `/change-password` and the 2FA -routes answer 403 (`requireLocalAuth`). The OIDC client is `src/routes/oidc.js`: +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). +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 cookie is set. New accounts are created at the SSO from an invitation link @@ -52,7 +54,7 @@ refuses the write with 403 (`src/utils/adminLockdown.js`; the gate at the top of `src/routes/adminConfig.js`, and `PUT /api/auth/oidc/config` checks it itself). The panel greys the fields and says why, but the refusal is the control. Still editable under lockdown: the announcement banner, -`registration_enabled`, `feature.*` switches, `site.*`, and the test buttons. +`feature.*` switches, `site.*`, and the test buttons. Lifting it takes a host change and a restart. ## Password hashing diff --git a/docs/configuration.md b/docs/configuration.md index 277ea5d2..b8990c1d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -113,7 +113,6 @@ with 2-minute in-memory cache. Writes invalidate the cache immediately. | Key | Purpose | |---|---| -| `registration_enabled` | `true`/`false`. Gate new signups. | | `site.name` | Display name. | | `site.auto_delete_days` | Days before encounters auto-expire (default 7). | @@ -141,7 +140,6 @@ with 2-minute in-memory cache. Writes invalidate the cache immediately. | `oidc.issuer` | OIDC issuer URL. | | `oidc.client_id`, `oidc.client_secret` | OAuth client credentials. | | `oidc.button_label` | Login-page button text (default "Sign in with SSO"). | -| `oidc.disable_local_auth` | Hide local login form when SSO is enabled. | | `oidc.allowed_ips` | CIDR whitelist for SSO (optional). | ### AI / models / prompts diff --git a/docs/developer-guide.md b/docs/developer-guide.md index e71f4bef..23929be2 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -126,7 +126,7 @@ for that. ```js var v = await config.get('feature.read_aloud', 'false'); // key, default -await config.set('registration_enabled', 'true'); +await config.set('feature.read_aloud', 'true'); ``` 2-minute in-memory cache. Writes invalidate immediately. diff --git a/docs/features-explained.md b/docs/features-explained.md index c91a2d9d..72376319 100644 --- a/docs/features-explained.md +++ b/docs/features-explained.md @@ -30,10 +30,10 @@ 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`. Local password sign-in, -registration, reset and the app's own emailed codes are switched off -(`oidc.disable_local_auth`); a local account with the same email is the same -account. +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. ## Text To Speech @@ -99,7 +99,7 @@ deliberately never cached. Full detail in ## Admin Panel -Admins can manage users, roles, registration, security settings, model defaults, prompts and logs. Production deployments should enable SSO/2FA and restrict admin access. +Admins can manage users, security settings, model defaults, prompts and logs; accounts, roles and invitations live in authentik. Production deployments should enable SSO/2FA and restrict admin access. ## Feature Status diff --git a/public/components/admin.html b/public/components/admin.html index 6f55f744..8daa556c 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -78,13 +78,6 @@ -
- - -
diff --git a/public/components/settings.html b/public/components/settings.html index c5e2b283..99a5e7e6 100644 --- a/public/components/settings.html +++ b/public/components/settings.html @@ -108,9 +108,9 @@
-

Your PedsHub account

-

Your name, email, passkeys and sign-in devices are managed at PedsHub, which opens in a new tab so this page stays where it is. Close that tab to come back.

- Manage my PedsHub account +

Your One Sign In account

+

Your name, email, passkeys and sign-in devices are managed at One Sign In, which opens in a new tab so this page stays where it is. Close that tab to come back.

+ Manage my One Sign In account
diff --git a/public/index.html b/public/index.html index 90feff34..61f498e0 100644 --- a/public/index.html +++ b/public/index.html @@ -95,6 +95,7 @@ diff --git a/public/js/admin.js b/public/js/admin.js index b42f4175..24931ada 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -572,7 +572,6 @@ function adminTabActive() { el = document.getElementById('oidc-client-id'); if (el) el.value = c['oidc.client_id'] || ''; el = document.getElementById('oidc-client-secret'); if (el) el.placeholder = c['oidc.client_secret'] ? c['oidc.client_secret'] : 'Enter client secret'; el = document.getElementById('oidc-button-label'); if (el) el.value = c['oidc.button_label'] || ''; - el = document.getElementById('oidc-disable-local'); if (el) el.value = c['oidc.disable_local_auth'] || 'false'; }) .catch(function() {}); } @@ -587,8 +586,7 @@ function adminTabActive() { 'oidc.enabled': document.getElementById('oidc-enabled').value, 'oidc.issuer': document.getElementById('oidc-issuer').value.trim(), 'oidc.client_id': document.getElementById('oidc-client-id').value.trim(), - 'oidc.button_label': document.getElementById('oidc-button-label').value.trim(), - 'oidc.disable_local_auth': document.getElementById('oidc-disable-local').value + 'oidc.button_label': document.getElementById('oidc-button-label').value.trim() }; // Only send secret if user typed a new one var secretEl = document.getElementById('oidc-client-secret'); diff --git a/public/js/auth.js b/public/js/auth.js index 59c80a61..009127df 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -276,11 +276,23 @@ document.addEventListener('DOMContentLoaded', function() { 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 + // 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'; + }); + } } } }) diff --git a/src/routes/auth.js b/src/routes/auth.js index bd9c02ac..e4d31544 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -222,7 +222,7 @@ router.post('/resend-verification', async (req, res) => { // ============================================================ // LOGIN (checks disabled status) // ============================================================ -router.post('/login', requireLocalAuth, async (req, res) => { +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' }); @@ -295,6 +295,17 @@ router.post('/login', requireLocalAuth, async (req, res) => { 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'])]); diff --git a/src/routes/oidc.js b/src/routes/oidc.js index 9bd238f9..4b626b0a 100644 --- a/src/routes/oidc.js +++ b/src/routes/oidc.js @@ -85,11 +85,11 @@ function setAuthCookie(res, token) { router.get('/oidc-status', async function(req, res) { try { var enabled = await db.getSetting('oidc.enabled'); - var disableLocal = await db.getSetting('oidc.disable_local_auth'); var buttonLabel = await db.getSetting('oidc.button_label'); res.json({ oidcEnabled: enabled === 'true', - disableLocalAuth: enabled === 'true' && disableLocal === 'true', + // Kept for the sign-in screen: local fields fold away whenever SSO is on. + disableLocalAuth: enabled === 'true', buttonLabel: buttonLabel || 'Sign in with SSO' }); } catch (e) { @@ -316,7 +316,7 @@ router.get('/oidc/callback', async function(req, res) { // ── Admin: GET OIDC config ────────────────────────────────────────────── router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) { try { - var keys = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips', 'oidc.admin_groups', 'oidc.moderator_groups']; + var keys = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.button_label', 'oidc.allowed_ips', 'oidc.admin_groups', 'oidc.moderator_groups']; var config = {}; for (var i = 0; i < keys.length; i++) { config[keys[i]] = await db.getSetting(keys[i]) || ''; @@ -336,7 +336,7 @@ router.put('/oidc/config', authMiddleware, adminMiddleware, async function(req, // lockdown it is read-only like every other setting that changes how the // service behaves. This router is not behind the admin gate, so it says so itself. if (lockdown.enabled()) return res.status(403).json({ error: lockdown.refusal('oidc') }); - var allowed = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips', 'oidc.admin_groups', 'oidc.moderator_groups']; + var allowed = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.button_label', 'oidc.allowed_ips', 'oidc.admin_groups', 'oidc.moderator_groups']; var updates = req.body; for (var i = 0; i < allowed.length; i++) { diff --git a/src/utils/policy.js b/src/utils/policy.js index ac7b4d64..1a89cb6f 100644 --- a/src/utils/policy.js +++ b/src/utils/policy.js @@ -1,7 +1,10 @@ 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). async function isSSOOnly() { - return await db.getSetting('oidc.enabled') === 'true' && await db.getSetting('oidc.disable_local_auth') === 'true'; + return await db.getSetting('oidc.enabled') === 'true'; } async function requireLocalAuth(req, res, next) { diff --git a/test/assistant-mobile.test.js b/test/assistant-mobile.test.js index 586b388a..76bcdb8f 100644 --- a/test/assistant-mobile.test.js +++ b/test/assistant-mobile.test.js @@ -202,7 +202,7 @@ test('the SSO settings fit a phone instead of running off the side', () => { // nothing to scroll and no way to reach the rest. assert.doesNotMatch(sso, /min-width:160px/); assert.doesNotMatch(sso, /display:flex;align-items:center;gap:12px;/); - assert.equal((sso.match(/class="admin-row"/g) || []).length, 6, 'every row uses the pattern that stacks'); + assert.equal((sso.match(/class="admin-row"/g) || []).length, 5, 'every row uses the pattern that stacks'); assert.match(sso, /class="admin-row-label"/); assert.match(sso, /class="admin-control"/); // .admin-row stacks below 640px and its controls stop constraining width. diff --git a/test/policy-flows.test.js b/test/policy-flows.test.js index c8f7e49f..a3f11df2 100644 --- a/test/policy-flows.test.js +++ b/test/policy-flows.test.js @@ -20,7 +20,7 @@ function fixture(envOverrides = {}) { settings: { 'models.custom': JSON.stringify([{ id: 'allowed', name: 'Allowed' }, { id: 'other', name: 'Other' }]), 'models.disabled': '[]', 'models.default': 'allowed', - 'oidc.enabled': 'true', 'oidc.disable_local_auth': 'false', + 'oidc.enabled': 'true', 'oidc.issuer': 'https://idp.example', 'oidc.client_id': 'synthetic-client', 'feature.read_aloud': 'true', 'feature.nextcloud': 'true', 'feature.memories': 'true', 'tts.model': 'groq-orpheus-english', 'tts.voice': 'hannah', 'stt.model': 'synthetic-stt' @@ -241,22 +241,29 @@ test('OIDC refuses unsafe linking, disabled/mismatched identities, and session f } }); -test('active SSO-only policy denies local login/registration/credential creation, but disabled OIDC cannot lock out local auth', async t => { +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 => { const f = fixture(); const request = await f.serve(t, true); - f.state.settings['oidc.disable_local_auth'] = 'true'; - for (const route of ['/api/auth/login', '/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']) { + 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 }); assert.equal(response.status, 403, route); assert.equal(response.data.code, 'sso_only'); assert.equal(authCookies(response).length, 0); } 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: {} }); + 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); });