feat: One Sign In is the only door; the SSO-only switch is gone

With OIDC on, every password route is shut except /api/auth/login for
administrators — the way back in if the provider is down, reached from an
"Administrator sign-in" link. The disable-local-auth setting, the
registration CLI command and the docs that described them are removed;
accounts, roles and invitations live in authentik.

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 03:33:53 +02:00
parent 4c699b86ef
commit 29ff7e435c
16 changed files with 64 additions and 50 deletions

View file

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

View file

@ -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 <email> <new-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('');
}

View file

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

View file

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

View file

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

View file

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

View file

@ -78,13 +78,6 @@
<label for="oidc-button-label" class="admin-row-label">Button Label:</label>
<input type="text" id="oidc-button-label" placeholder="Sign in with SSO" class="admin-control">
</div>
<div class="admin-row">
<label for="oidc-disable-local" class="admin-row-label">Disable local login:</label>
<select id="oidc-disable-local" class="admin-control">
<option value="false">No (both SSO and local login)</option>
<option value="true">Yes (SSO only)</option>
</select>
</div>
<div class="admin-save-row">
<button id="btn-save-oidc" class="btn-sm btn-primary" type="button"><i class="fas fa-floppy-disk"></i> Save OIDC Settings</button>
<span id="oidc-save-status" role="status" style="font-size:12px;color:var(--g500);"></span>

View file

@ -108,9 +108,9 @@
<!-- PedsHub account: name, email, passkeys live at the provider -->
<div class="settings-section card" id="pedshub-account-section">
<h3><i class="fas fa-id-badge"></i> Your PedsHub account</h3>
<p style="font-size:13px;color:var(--g600);">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.</p>
<a class="btn-sm btn-ghost" href="https://sso.pedshub.com/if/user/#/settings" target="_blank" rel="noopener" id="link-pedshub-account"><i class="fas fa-arrow-up-right-from-square"></i> Manage my PedsHub account</a>
<h3><i class="fas fa-id-badge"></i> Your One Sign In account</h3>
<p style="font-size:13px;color:var(--g600);">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.</p>
<a class="btn-sm btn-ghost" href="https://sso.pedshub.com/if/user/#/settings" target="_blank" rel="noopener" id="link-pedshub-account"><i class="fas fa-arrow-up-right-from-square"></i> Manage my One Sign In account</a>
</div>
<!-- Nextcloud -->

View file

@ -95,6 +95,7 @@
<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

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

View file

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

View file

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

View file

@ -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++) {

View file

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

View file

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

View file

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