fix(auth): ensure sessions table exists + log session.save errors

The redirect loop after successful login was almost certainly the
`sessions` table never being created. `schema.sql` defines it but
only runs on first-init via the postgres entrypoint; instances
bootstrapped via mam-api's own migration loop never got the table.
express-session's `req.session.save()` then failed silently and the
cookie pointed at a sid that wasn't in the store — every subsequent
request looked like a brand-new visitor.

  - New migration 021-ensure-sessions-table.sql (idempotent).
  - connect-pg-simple now configured with `createTableIfMissing: true`
    as belt-and-braces.
  - `POST /auth/login` now explicitly waits for session.save() and
    surfaces both regenerate() and save() errors instead of treating
    them as 'success'. Logs sid + req.secure + req.protocol so we can
    confirm trust-proxy is doing the right thing behind NPM.
This commit is contained in:
opencode 2026-05-27 02:54:25 +00:00
parent cfcbec0c85
commit 65684aa577
3 changed files with 38 additions and 8 deletions

View file

@ -0,0 +1,14 @@
-- connect-pg-simple's session store needs this table. It's defined in
-- schema.sql which only runs on first DB init via the postgres entrypoint;
-- on instances bootstrapped via migrations only (no entrypoint init), the
-- table never existed and every login silently failed to persist the
-- session — manifesting as a redirect loop after submitting valid creds.
-- Idempotent so this is safe to re-run.
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
sess JSONB NOT NULL,
expire TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expire ON sessions (expire);

View file

@ -76,8 +76,11 @@ app.use(
name: 'df.sid', name: 'df.sid',
store: new PgSession({ store: new PgSession({
pool, pool,
tableName: 'sessions', tableName: 'sessions',
pruneSessionInterval: 3600, pruneSessionInterval: 3600,
// Belt-and-braces: connect-pg-simple will CREATE TABLE on its first
// write if migration 021 somehow didn't run. Cheap, idempotent.
createTableIfMissing: true,
}), }),
secret: SESSION_SECRET, secret: SESSION_SECRET,
resave: false, resave: false,

View file

@ -127,17 +127,30 @@ router.post('/login', async (req, res, next) => {
// Successful login — clear any accumulated failed attempts // Successful login — clear any accumulated failed attempts
clearAttempts(req, username); clearAttempts(req, username);
// Regenerate session ID to prevent fixation attacks // Regenerate session ID to prevent fixation attacks.
// Explicit save() + log on success/failure so a broken session store
// (missing table, PG read-only, etc.) doesn't silently 200 with no
// persisted session — the symptom that caused the redirect loop.
req.session.regenerate((err) => { req.session.regenerate((err) => {
if (err) return next(err); if (err) {
console.error('[auth] session.regenerate failed:', err);
return next(err);
}
req.session.userId = user.id; req.session.userId = user.id;
req.session.username = user.username; req.session.username = user.username;
req.session.role = user.role; req.session.role = user.role;
res.json({ req.session.save((saveErr) => {
id: user.id, if (saveErr) {
username: user.username, console.error('[auth] session.save failed:', saveErr);
display_name: user.display_name, return next(saveErr);
role: user.role, }
console.log(`[auth] login ok user=${user.username} sid=${req.sessionID?.slice(0,8) || '?'} secure=${req.secure} proto=${req.protocol}`);
res.json({
id: user.id,
username: user.username,
display_name: user.display_name,
role: user.role,
});
}); });
}); });
} catch (err) { } catch (err) {