From 547a4bbb00923933511db3dc9c9924bdc5cd4925 Mon Sep 17 00:00:00 2001 From: oss-sync Date: Fri, 12 Jun 2026 05:43:19 +0000 Subject: [PATCH] sync: update from private repo (9b2ab10) --- config.yaml.example | 2 +- src/bridge/auth.session-store.test.ts | 147 ++++++++++++++++++++++++++ src/bridge/auth.ts | 40 ++++++- 3 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 src/bridge/auth.session-store.test.ts diff --git a/config.yaml.example b/config.yaml.example index d2e92d5..451734b 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -342,7 +342,7 @@ tools: # # 本番では `openssl rand -hex 32` 等で固定値を生成して設定すること。 # # ⚠ プレースホルダ文字列をそのまま使わない (公開された secret は偽造・改ざんに使われる)。 # session_secret: "" -# session_max_age: 86400000 # 24h (ms) +# session_max_age: 86400000 # 無操作タイムアウト (ms)。アクセスごとに延長 (rolling)。未設定時は30日 # secure_cookie: false # HTTPS 環境では true (TLS 終端の背後では必須) # admin_emails: # - "admin@example.com" diff --git a/src/bridge/auth.session-store.test.ts b/src/bridge/auth.session-store.test.ts new file mode 100644 index 0000000..c0c567b --- /dev/null +++ b/src/bridge/auth.session-store.test.ts @@ -0,0 +1,147 @@ +/** + * SQLite session store TTL behaviour (the "sessions disappear after a fixed + * time" fix). + * + * Regression guards: + * - set() writes an `expired` ≈ now + cookie.maxAge (ms), NOT now + maxAge*1000. + * The old code re-scaled the already-millisecond maxAge by 1000. + * - with no cookie.maxAge, set()/touch() fall back to DEFAULT_SESSION_MAX_AGE_MS + * (30d), not a 1-day hard expiry. + * - touch() slides the expiry forward (rolling sessions). + * - get() deletes and returns null for an already-expired row. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { Repository } from '../db/repository.js'; +import { createSqliteSessionStore, DEFAULT_SESSION_MAX_AGE_MS, setupAuth } from './auth.js'; +import type { AuthConfig } from '../config.js'; +import type { SessionData } from 'express-session'; + +function makeStore() { + const repo = new Repository(':memory:'); + return { repo, store: createSqliteSessionStore(repo.getDb()) }; +} + +function sess(maxAge?: number): SessionData { + return { cookie: { originalMaxAge: maxAge ?? null, maxAge } } as unknown as SessionData; +} + +function readExpired(repo: Repository, sid: string): number { + const row = repo.getDb().prepare('SELECT expired FROM sessions WHERE sid = ?').get(sid) as + | { expired: string } + | undefined; + return row ? new Date(row.expired).getTime() : NaN; +} + +describe('SQLite session store TTL', () => { + let repo: Repository; + let store: ReturnType; + beforeEach(() => { + ({ repo, store } = makeStore()); + }); + + it('set() expiry tracks cookie.maxAge in milliseconds (no ×1000 re-scale)', async () => { + const oneHour = 60 * 60 * 1000; + const before = Date.now(); + await new Promise((r) => store.set('s1', sess(oneHour), () => r())); + const expired = readExpired(repo, 's1'); + // Should be ~1h out, definitely under a day. The old bug put it 1000h out. + expect(expired - before).toBeGreaterThan(oneHour - 5_000); + expect(expired - before).toBeLessThan(oneHour + 60_000); + }); + + it('set() with no maxAge falls back to the 30-day default, not 1 day', async () => { + const before = Date.now(); + await new Promise((r) => store.set('s2', sess(undefined), () => r())); + const expired = readExpired(repo, 's2'); + expect(expired - before).toBeGreaterThan(DEFAULT_SESSION_MAX_AGE_MS - 60_000); + expect(expired - before).toBeLessThan(DEFAULT_SESSION_MAX_AGE_MS + 60_000); + }); + + it('touch() slides the expiry forward', async () => { + // Seed a row that expires soon. + repo.getDb() + .prepare('INSERT INTO sessions (sid, sess, expired) VALUES (?, ?, ?)') + .run('s3', JSON.stringify(sess(1000)), new Date(Date.now() + 1000).toISOString()); + const before = readExpired(repo, 's3'); + const oneHour = 60 * 60 * 1000; + await new Promise((r) => store.touch('s3', sess(oneHour), () => r())); + const after = readExpired(repo, 's3'); + expect(after).toBeGreaterThan(before); + expect(after - Date.now()).toBeGreaterThan(oneHour - 5_000); + }); + + it('get() deletes and returns null for an expired row', async () => { + repo.getDb() + .prepare('INSERT INTO sessions (sid, sess, expired) VALUES (?, ?, ?)') + .run('s4', JSON.stringify(sess(1000)), new Date(Date.now() - 1000).toISOString()); + const got = await new Promise((r) => store.get('s4', (_e, s) => r(s))); + expect(got).toBeNull(); + expect(repo.getDb().prepare('SELECT 1 FROM sessions WHERE sid = ?').get('s4')).toBeUndefined(); + }); + + it('get() returns the stored session for a live row', async () => { + await new Promise((r) => store.set('s5', sess(60 * 60 * 1000), () => r())); + const got = (await new Promise((r) => store.get('s5', (_e, s) => r(s)))) as SessionData | null; + expect(got).not.toBeNull(); + expect(got?.cookie).toBeTruthy(); + }); +}); + +// ── Middleware wiring: rolling + maxAge fallback ────────────────────────────── + +/** Seconds until the session cookie expires, from Max-Age or Expires (express- + * session serializes the lifetime as an `Expires=`, not Max-Age). */ +function cookieTtlSec(setCookie: string | string[] | undefined): number | null { + const header = Array.isArray(setCookie) ? setCookie.join('\n') : (setCookie ?? ''); + const ma = /Max-Age=(\d+)/i.exec(header); + if (ma) return Number(ma[1]); + const ex = /Expires=([^;]+)/i.exec(header); + if (ex) { + const t = Date.parse(ex[1]); + if (!Number.isNaN(t)) return Math.round((t - Date.now()) / 1000); + } + return null; +} + +function appFor(cfg: Partial) { + const repo = new Repository(':memory:'); + const full: AuthConfig = { + sessionSecret: 'test-secret', secureCookie: false, adminEmails: [], providers: {}, + ...cfg, + } as AuthConfig; + const { sessionMiddleware } = setupAuth(repo, full); + const app = express(); + app.use(sessionMiddleware); + // /bump modifies the session (forces a save); /ping leaves it untouched. + app.get('/bump', (req, res) => { + (req.session as unknown as { n: number }).n = ((req.session as unknown as { n?: number }).n ?? 0) + 1; + res.json({ ok: true }); + }); + app.get('/ping', (_req, res) => res.json({ ok: true })); + return app; +} + +describe('session middleware wiring', () => { + it('issues a Max-Age ≈ 30 days when session_max_age is unset', async () => { + const app = appFor({ sessionMaxAge: undefined }); + const res = await request(app).get('/bump'); + const sec = cookieTtlSec(res.headers['set-cookie']); + expect(sec).not.toBeNull(); + // 30d = 2_592_000s. express-session rounds; allow a minute of slack. + expect(Math.abs((sec as number) - DEFAULT_SESSION_MAX_AGE_MS / 1000)).toBeLessThan(60); + }); + + it('re-issues the cookie on an unmodified request (rolling: true)', async () => { + const app = appFor({ sessionMaxAge: 60 * 60 * 1000 }); // 1h + const agent = request.agent(app); + const first = await agent.get('/bump'); // establishes the session + expect(first.headers['set-cookie']).toBeTruthy(); + // A request that does NOT touch the session must still get a fresh cookie + // because rolling re-issues it every response (sliding expiry). + const second = await agent.get('/ping'); + expect(second.headers['set-cookie']).toBeTruthy(); + expect(cookieTtlSec(second.headers['set-cookie'])).toBeGreaterThan(0); + }); +}); diff --git a/src/bridge/auth.ts b/src/bridge/auth.ts index 3ab9178..e6c8b73 100644 --- a/src/bridge/auth.ts +++ b/src/bridge/auth.ts @@ -253,11 +253,21 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction): v // ── SQLite Session Store ────────────────────────────────────────────────────── +/** + * Default session lifetime (ms) when auth.session_max_age is unset. Applied to + * BOTH the cookie maxAge and the store TTL so an unconfigured deployment gets a + * concrete, sliding expiry window instead of a browser-session cookie paired + * with a store row that expires after a day (the old `?? 86400` fallback). + * With rolling this is an inactivity window — an active session never expires; + * we intentionally don't impose an absolute timeout for this self-hosted tool. + */ +export const DEFAULT_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + /** * Repository の SQLite DB を使ったカスタムセッションストア。 * sessions テーブル (sid, sess, expired) を直接操作する。 */ -function createSqliteSessionStore(db: Database): session.Store { +export function createSqliteSessionStore(db: Database): session.Store { // session.Store の基底クラスを継承 const Store = session.Store as unknown as new () => session.Store; @@ -289,7 +299,10 @@ function createSqliteSessionStore(db: Database): session.Store { set(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void { try { - const ttl = (sessionData.cookie?.maxAge ?? 86400) * 1000; + // cookie.maxAge is already in milliseconds; don't re-scale it. The + // fallback is the same ms default the middleware applies, so the store + // row and the cookie expire together. + const ttl = sessionData.cookie?.maxAge ?? DEFAULT_SESSION_MAX_AGE_MS; const expired = new Date(Date.now() + ttl).toISOString(); const sess = JSON.stringify(sessionData); @@ -316,7 +329,7 @@ function createSqliteSessionStore(db: Database): session.Store { touch(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void { try { - const ttl = (sessionData.cookie?.maxAge ?? 86400) * 1000; + const ttl = sessionData.cookie?.maxAge ?? DEFAULT_SESSION_MAX_AGE_MS; const expired = new Date(Date.now() + ttl).toISOString(); db.prepare("UPDATE sessions SET expired = ? WHERE sid = ?").run(expired, sid); @@ -327,6 +340,15 @@ function createSqliteSessionStore(db: Database): session.Store { } } + // Eagerly sweep already-expired rows at startup. get() prunes lazily, but a + // session whose owner never returns would otherwise linger forever; the + // idx_sessions_expired index keeps this cheap. + try { + db.prepare("DELETE FROM sessions WHERE expired < ?").run(new Date().toISOString()); + } catch { + // Non-fatal: a missing table on a brand-new DB is fine; the store still works. + } + return new SqliteStore(); } @@ -749,6 +771,11 @@ export function setupAuth( secret: sessionSecret, resave: false, saveUninitialized: false, + // rolling: re-issue the cookie (and bump the store TTL via touch) on every + // response so an actively-used session never expires mid-use. Without it the + // cookie's Max-Age is frozen at login and the user is logged out at a fixed + // wall-clock time regardless of activity. + rolling: true, store: createSqliteSessionStore(db), cookie: { // httpOnly: keep the session cookie out of document.cookie (defense in @@ -758,7 +785,12 @@ export function setupAuth( httpOnly: true, sameSite: 'lax', secure: authConfig.secureCookie, - maxAge: authConfig.sessionMaxAge, + // Always give the cookie a concrete lifetime. When session_max_age is + // unset, fall back to the default instead of leaving maxAge undefined — + // an undefined maxAge yields a browser-session cookie (no Max-Age) that the + // browser keeps only until it closes, paired with the store's 1-day TTL. + // A concrete maxAge + rolling gives a proper sliding inactivity window. + maxAge: authConfig.sessionMaxAge ?? DEFAULT_SESSION_MAX_AGE_MS, }, });