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:
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user