fix(oidc): local-credential lockout, session hydration, split-origin gaps (codex round 3)

- OIDC-owned accounts can never authenticate locally: the password
  login rejects auth_provider='oidc' rows outright (generic 401), and
  the super-admin password reset refuses them with a clear message —
  previously a reset would have minted a local password bypassing the
  IdP's MFA/access policies
- /auth/session now returns a full adminUser payload (role join) and
  AdminAuthContext hydrates user state from it: an SSO redirect
  establishes the session without any login JSON, which left the header
  identity blank and current-admin form defaults empty
- the /sso/login error path redirects absolute to the frontend base
  (same split-origin reasoning as the callback)
- docker-compose.yml passes API_URL through to the backend (production
  compose uses env_file and needs nothing; dev compose is gitignored)
- authSession.symmetry test mock taught the joined admin lookup
  (leftJoin, prefixed columns, aliases) — the route change made the old
  mock throw, which read as "table missing, trust token"

Tests: new case pins that a known-good password on an OIDC-owned row
still gets 401. 14/14 OIDC, 13/13 symmetry.
This commit is contained in:
Paul Nothaft
2026-07-16 10:07:59 +02:00
parent 7f7d38a57f
commit e91c7deaa4
6 changed files with 88 additions and 9 deletions
+40 -6
View File
@@ -143,8 +143,11 @@ router.post('/admin/login', [
)
.first();
// Use generic error to prevent user enumeration
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
// Use generic error to prevent user enumeration. OIDC-owned accounts
// (#798) never authenticate locally — their random hash is unusable by
// design, and the explicit check keeps that true even if a hash ever
// gets set through some other path.
if (!admin || admin.auth_provider === 'oidc' || !await bcrypt.compare(password, admin.password_hash)) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
@@ -654,12 +657,22 @@ router.get('/session', async (req, res) => {
// or the gallery event was archived/deleted. Mirror those checks
// here so the session endpoint is always at least as strict as
// what the protected endpoints will enforce next.
// Full user payload for admin sessions — the SSO callback establishes
// the session via redirect (no JSON response the SPA could store), so
// session restoration must be able to hydrate the user object (#798).
let adminUser = null;
if (decoded.type === 'admin') {
let admin = null;
try {
admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'password_changed_at')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id', 'admin_users.username', 'admin_users.email',
'admin_users.password_changed_at', 'admin_users.must_change_password',
'roles.name as role_name', 'roles.display_name as role_display_name'
)
.first();
} catch (lookupErr) {
// admin_users table not present (test fixture, fresh DB) — fall
@@ -698,6 +711,19 @@ router.get('/session', async (req, res) => {
// Helper lookup failed (test stub may not export it) — fall through
// and trust the token. Real deployments always have the middleware.
}
if (admin) {
adminUser = {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
};
}
} else if (decoded.type === 'gallery') {
try {
const event = await db('events')
@@ -729,7 +755,11 @@ router.get('/session', async (req, res) => {
expiresIn: Math.floor(remainingTime),
user: decoded.username || decoded.eventSlug,
eventSlug: decoded.eventSlug,
adminUsername: decoded.username
adminUsername: decoded.username,
// Full admin payload (or null) — lets the SPA hydrate its user
// state after a redirect-established session (SSO, #798) where no
// login JSON response ever reached it.
adminUser
});
} catch (err) {
res.json({
@@ -894,7 +924,11 @@ router.get('/admin/sso/login', async (req, res) => {
return res.status(404).json({ error: 'SSO is not enabled' });
}
logger.error('OIDC login initiation failed', { error: error.message });
return res.redirect('/admin/login?sso_error=config');
// Absolute like the callback's redirects: in split-origin deployments a
// relative path would resolve on the API origin and 404.
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const frontendBase = (await getFrontendBaseUrl().catch(() => '')) || '';
return res.redirect(`${frontendBase}/admin/login?sso_error=config`);
}
});
@@ -459,6 +459,13 @@ async function resetAdminPassword(id, resetById) {
throw new NotFoundError('Admin user', id);
}
// OIDC-owned accounts (#798) have no usable local password by design —
// minting one here would hand out a login that bypasses the IdP's MFA
// and access policies.
if (user.auth_provider === 'oidc') {
throw new ValidationError('This account is managed by your identity provider (SSO) — reset the password there.');
}
const newPassword = generateReadablePassword();
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());