fix(auth): /auth/session must reject tokens that adminAuth/galleryAuth would reject
Second loop fix in the same /admin/login → /admin/dashboard → /admin/login pattern as #355. The frontend trusts /auth/session as the source of truth for "is the user authenticated?". When that endpoint is more lenient than the protected middleware, every admin endpoint 401s right after /auth/session said valid:true, the response interceptor hard-redirects to /admin/login, /auth/session says valid again, and the cycle closes — exactly the loop reported on v3.32.4-beta.0. #355 fixed the issuer-claim asymmetry. This commit fixes the remaining asymmetries: /auth/session was missing the admin-existence, admin-active, password-change-after-iat, and gallery-existence / gallery-archived / gallery-expired checks that adminAuth and galleryAuth perform on every protected request. The fix is to mirror those checks in /auth/session, scoped by token type, and degrade gracefully when the underlying tables aren't present (test fixtures, early bootstrap) so the endpoint never fails-closed because of a missing table. Reproducer that the new test covers: 1. Admin logs in (token issued at T). 2. Admin (or another admin) changes their own password at T+1. 3. Browser still has the cookie from T. 4. /auth/session says valid:true (no password-change check). 5. /admin/dashboard fires queries; adminAuth rejects with PASSWORD_CHANGED 401. 6. Frontend redirects to /admin/login. 7. /auth/session says valid:true again. → loop. Other surfaces this also covers: - admin user deactivated (admin_users.is_active = false) - admin user deleted - gallery token whose event is archived - gallery token whose event has expired Tests live in __tests__/routes/authSession.symmetry.test.js — 9 cases, mocking db / tokenRevocation / tokenUtils / recaptcha / sessionTimeout so the suite runs without a real database.
This commit is contained in:
@@ -477,7 +477,7 @@ router.get('/session', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.query;
|
||||
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
@@ -498,6 +498,63 @@ router.get('/session', async (req, res) => {
|
||||
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
|
||||
}
|
||||
|
||||
// The redirect loop reported on the v3.32.4-beta.0 release came
|
||||
// from /auth/session reporting valid: true while the protected
|
||||
// adminAuth / galleryAuth middleware rejected the same token for
|
||||
// reasons /auth/session never checked: the admin user was
|
||||
// deactivated, the admin's password had been changed since iat,
|
||||
// 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.
|
||||
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')
|
||||
.first();
|
||||
} catch (lookupErr) {
|
||||
// admin_users table not present (test fixture, fresh DB) — fall
|
||||
// through and trust the token. Real deployments always have it.
|
||||
admin = null;
|
||||
// intentional swallow; if the table is missing we do not want
|
||||
// to fail-closed during e.g. early bootstrap.
|
||||
}
|
||||
|
||||
if (admin === null) {
|
||||
// Lookup didn't run because the table is missing; skip the
|
||||
// existence/password checks and treat the token as valid.
|
||||
} else if (!admin) {
|
||||
return res.json({ valid: false, error: 'Admin account no longer active' });
|
||||
} else if (admin.password_changed_at) {
|
||||
const passwordChangedSeconds = Math.floor(
|
||||
new Date(admin.password_changed_at).getTime() / 1000
|
||||
);
|
||||
if (decoded.iat < passwordChangedSeconds) {
|
||||
return res.json({ valid: false, error: 'Token invalid due to password change' });
|
||||
}
|
||||
}
|
||||
} else if (decoded.type === 'gallery') {
|
||||
try {
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false),
|
||||
})
|
||||
.first();
|
||||
if (!event) {
|
||||
return res.json({ valid: false, error: 'Gallery no longer available' });
|
||||
}
|
||||
if (event.expires_at && new Date(event.expires_at) < new Date()) {
|
||||
return res.json({ valid: false, error: 'Gallery has expired' });
|
||||
}
|
||||
} catch (galleryLookupErr) {
|
||||
// events table missing in this context — same fallback as
|
||||
// admin path; trust the token rather than fail-closed.
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate remaining time
|
||||
const now = Date.now() / 1000;
|
||||
const remainingTime = Math.max(0, decoded.exp - now);
|
||||
|
||||
Reference in New Issue
Block a user