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. 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 ## 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 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 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 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 docker exec pediatric-ai-scribe node admin-cli.js stats
``` ```

View file

@ -130,19 +130,10 @@ async function main() {
break; 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': { case 'stats': {
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []); var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
var apiCount = await db.get('SELECT COUNT(*) as count FROM api_log', []); 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 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('');
console.log('📊 App Statistics'); console.log('📊 App Statistics');
@ -173,7 +164,6 @@ async function main() {
console.log(' reset-password <email> <new-password> Reset user password'); console.log(' reset-password <email> <new-password> Reset user password');
console.log(''); console.log('');
console.log('App Settings:'); console.log('App Settings:');
console.log(' toggle-registration Toggle registration on/off');
console.log(' stats Show app statistics'); console.log(' stats Show app statistics');
console.log(''); console.log('');
} }

View file

@ -3,9 +3,11 @@
## Sign-in is SSO-only ## Sign-in is SSO-only
The front door is `sso.pedshub.com` (Authentik, `/home/danvics/docker/authentik-pedshub`). 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`, With `oidc.enabled` set, `/register` (always 410), `/forgot-password`,
`/register`, `/forgot-password`, `/reset-password`, `/change-password` and the 2FA `/reset-password`, `/change-password` and the 2FA routes answer 403
routes answer 403 (`requireLocalAuth`). The OIDC client is `src/routes/oidc.js`: (`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 signed state cookie, PKCE, nonce, `email_verified` required before an existing
local account is linked, `sub` mismatch refused, session row written before the 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 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 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 itself). The panel greys the fields and says why, but the refusal is the
control. Still editable under lockdown: the announcement banner, 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. Lifting it takes a host change and a restart.
## Password hashing ## Password hashing

View file

@ -113,7 +113,6 @@ with 2-minute in-memory cache. Writes invalidate the cache immediately.
| Key | Purpose | | Key | Purpose |
|---|---| |---|---|
| `registration_enabled` | `true`/`false`. Gate new signups. |
| `site.name` | Display name. | | `site.name` | Display name. |
| `site.auto_delete_days` | Days before encounters auto-expire (default 7). | | `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.issuer` | OIDC issuer URL. |
| `oidc.client_id`, `oidc.client_secret` | OAuth client credentials. | | `oidc.client_id`, `oidc.client_secret` | OAuth client credentials. |
| `oidc.button_label` | Login-page button text (default "Sign in with SSO"). | | `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). | | `oidc.allowed_ips` | CIDR whitelist for SSO (optional). |
### AI / models / prompts ### AI / models / prompts

View file

@ -126,7 +126,7 @@ for that.
```js ```js
var v = await config.get('feature.read_aloud', 'false'); // key, default 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. 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 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 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 `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, account signs into the quiz app at `pedshub.com`. Registration, password reset and the app's own emailed codes are gone; the
registration, reset and the app's own emailed codes are switched off only password door is the administrators' "Administrator sign-in", the way
(`oidc.disable_local_auth`); a local account with the same email is the same back in if the provider is down. A local account with the same email is the
account. same account.
## Text To Speech ## Text To Speech
@ -99,7 +99,7 @@ deliberately never cached. Full detail in
## Admin Panel ## 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 ## Feature Status

View file

@ -78,13 +78,6 @@
<label for="oidc-button-label" class="admin-row-label">Button Label:</label> <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"> <input type="text" id="oidc-button-label" placeholder="Sign in with SSO" class="admin-control">
</div> </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"> <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> <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> <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 --> <!-- PedsHub account: name, email, passkeys live at the provider -->
<div class="settings-section card" id="pedshub-account-section"> <div class="settings-section card" id="pedshub-account-section">
<h3><i class="fas fa-id-badge"></i> Your PedsHub account</h3> <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 PedsHub, which opens in a new tab so this page stays where it is. Close that tab to come back.</p> <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 PedsHub account</a> <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> </div>
<!-- Nextcloud --> <!-- Nextcloud -->

View file

@ -95,6 +95,7 @@
<div class="auth-links"> <div class="auth-links">
<a href="#" id="show-register" style="display:none">Create account</a> <a href="#" id="show-register" style="display:none">Create account</a>
<a href="#" id="show-forgot">Forgot password?</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> <a href="#" id="login-change-email" class="hidden" style="display:none;">Use a different email</a>
</div> </div>
</form> </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-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-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-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() {}); .catch(function() {});
} }
@ -587,8 +586,7 @@ function adminTabActive() {
'oidc.enabled': document.getElementById('oidc-enabled').value, 'oidc.enabled': document.getElementById('oidc-enabled').value,
'oidc.issuer': document.getElementById('oidc-issuer').value.trim(), 'oidc.issuer': document.getElementById('oidc-issuer').value.trim(),
'oidc.client_id': document.getElementById('oidc-client-id').value.trim(), 'oidc.client_id': document.getElementById('oidc-client-id').value.trim(),
'oidc.button_label': document.getElementById('oidc-button-label').value.trim(), 'oidc.button_label': document.getElementById('oidc-button-label').value.trim()
'oidc.disable_local_auth': document.getElementById('oidc-disable-local').value
}; };
// Only send secret if user typed a new one // Only send secret if user typed a new one
var secretEl = document.getElementById('oidc-client-secret'); var secretEl = document.getElementById('oidc-client-secret');

View file

@ -276,11 +276,23 @@ document.addEventListener('DOMContentLoaded', function() {
if (ssoDivider) ssoDivider.style.display = 'block'; if (ssoDivider) ssoDivider.style.display = 'block';
if (ssoLabel && data.buttonLabel) ssoLabel.textContent = data.buttonLabel; if (ssoLabel && data.buttonLabel) ssoLabel.textContent = data.buttonLabel;
if (data.disableLocalAuth) { 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, ' + var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, ' +
'#btn-login-continue, #login-change-email, #show-register, #show-forgot'); '#btn-login-continue, #login-change-email, #show-register, #show-forgot');
localFields.forEach(function(el) { el.style.display = 'none'; }); localFields.forEach(function(el) { el.style.display = 'none'; });
if (ssoDivider) ssoDivider.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) // LOGIN (checks disabled status)
// ============================================================ // ============================================================
router.post('/login', requireLocalAuth, async (req, res) => { router.post('/login', async (req, res) => {
try { try {
var { email, password, totpCode } = req.body; var { email, password, totpCode } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); 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); logger.access(user.id, 'login', req, true);
// Create session record // 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(); var sessionId = generateSessionId();
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)', 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'])]); [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) { router.get('/oidc-status', async function(req, res) {
try { try {
var enabled = await db.getSetting('oidc.enabled'); var enabled = await db.getSetting('oidc.enabled');
var disableLocal = await db.getSetting('oidc.disable_local_auth');
var buttonLabel = await db.getSetting('oidc.button_label'); var buttonLabel = await db.getSetting('oidc.button_label');
res.json({ res.json({
oidcEnabled: enabled === 'true', 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' buttonLabel: buttonLabel || 'Sign in with SSO'
}); });
} catch (e) { } catch (e) {
@ -316,7 +316,7 @@ router.get('/oidc/callback', async function(req, res) {
// ── Admin: GET OIDC config ────────────────────────────────────────────── // ── Admin: GET OIDC config ──────────────────────────────────────────────
router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) { router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) {
try { 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 = {}; var config = {};
for (var i = 0; i < keys.length; i++) { for (var i = 0; i < keys.length; i++) {
config[keys[i]] = await db.getSetting(keys[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 // 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. // 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') }); 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; var updates = req.body;
for (var i = 0; i < allowed.length; i++) { for (var i = 0; i < allowed.length; i++) {

View file

@ -1,7 +1,10 @@
var db = require('../db/database'); 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() { 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) { 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. // nothing to scroll and no way to reach the rest.
assert.doesNotMatch(sso, /min-width:160px/); assert.doesNotMatch(sso, /min-width:160px/);
assert.doesNotMatch(sso, /display:flex;align-items:center;gap:12px;/); 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-row-label"/);
assert.match(sso, /class="admin-control"/); assert.match(sso, /class="admin-control"/);
// .admin-row stacks below 640px and its controls stop constraining width. // .admin-row stacks below 640px and its controls stop constraining width.

View file

@ -20,7 +20,7 @@ function fixture(envOverrides = {}) {
settings: { settings: {
'models.custom': JSON.stringify([{ id: 'allowed', name: 'Allowed' }, { id: 'other', name: 'Other' }]), 'models.custom': JSON.stringify([{ id: 'allowed', name: 'Allowed' }, { id: 'other', name: 'Other' }]),
'models.disabled': '[]', 'models.default': 'allowed', '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', 'oidc.issuer': 'https://idp.example', 'oidc.client_id': 'synthetic-client',
'feature.read_aloud': 'true', 'feature.nextcloud': 'true', 'feature.memories': 'true', 'feature.read_aloud': 'true', 'feature.nextcloud': 'true', 'feature.memories': 'true',
'tts.model': 'groq-orpheus-english', 'tts.voice': 'hannah', 'stt.model': 'synthetic-stt' '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); const f = fixture(); const request = await f.serve(t, true);
f.state.settings['oidc.disable_local_auth'] = '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']) {
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']) {
const response = await request(route, { method: 'POST', body: {}, authenticated: true }); 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(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(f.state.writes.length, 0);
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, true); assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, true);
assert.equal((await request('/api/auth/me', { authenticated: true })).data.user.canLocalAuth, false); 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'; f.state.settings['oidc.enabled'] = 'false';
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, 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' } }); 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); assert.equal(local.status, 200); assert.equal(authCookies(local).length, 1);
f.state.settingsError = true; 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); assert.equal(unavailable.status, 503); assert.equal(authCookies(unavailable).length, 0);
}); });