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:
Paul Nothaft
2026-05-02 22:56:04 +02:00
parent 7b2f75e6ae
commit f905f7e733
2 changed files with 360 additions and 1 deletions
+58 -1
View File
@@ -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);