sync: update from private repo (9b2ab10)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-12 05:43:19 +00:00
parent 7d5d2d44b1
commit 547a4bbb00
3 changed files with 184 additions and 5 deletions

View File

@ -342,7 +342,7 @@ tools:
# # 本番では `openssl rand -hex 32` 等で固定値を生成して設定すること。 # # 本番では `openssl rand -hex 32` 等で固定値を生成して設定すること。
# # ⚠ プレースホルダ文字列をそのまま使わない (公開された secret は偽造・改ざんに使われる)。 # # ⚠ プレースホルダ文字列をそのまま使わない (公開された secret は偽造・改ざんに使われる)。
# session_secret: "" # session_secret: ""
# session_max_age: 86400000 # 24h (ms) # session_max_age: 86400000 # 無操作タイムアウト (ms)。アクセスごとに延長 (rolling)。未設定時は30日
# secure_cookie: false # HTTPS 環境では true (TLS 終端の背後では必須) # secure_cookie: false # HTTPS 環境では true (TLS 終端の背後では必須)
# admin_emails: # admin_emails:
# - "admin@example.com" # - "admin@example.com"

View File

@ -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<typeof createSqliteSessionStore>;
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<void>((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<void>((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<void>((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<void>((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=<date>`, 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<AuthConfig>) {
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);
});
});

View File

@ -253,11 +253,21 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction): v
// ── SQLite Session Store ────────────────────────────────────────────────────── // ── 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 使 * Repository SQLite DB 使
* sessions (sid, sess, expired) * sessions (sid, sess, expired)
*/ */
function createSqliteSessionStore(db: Database): session.Store { export function createSqliteSessionStore(db: Database): session.Store {
// session.Store の基底クラスを継承 // session.Store の基底クラスを継承
const Store = session.Store as unknown as new () => 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 { set(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
try { 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 expired = new Date(Date.now() + ttl).toISOString();
const sess = JSON.stringify(sessionData); 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 { touch(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
try { 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(); const expired = new Date(Date.now() + ttl).toISOString();
db.prepare("UPDATE sessions SET expired = ? WHERE sid = ?").run(expired, sid); 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(); return new SqliteStore();
} }
@ -749,6 +771,11 @@ export function setupAuth(
secret: sessionSecret, secret: sessionSecret,
resave: false, resave: false,
saveUninitialized: 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), store: createSqliteSessionStore(db),
cookie: { cookie: {
// httpOnly: keep the session cookie out of document.cookie (defense in // httpOnly: keep the session cookie out of document.cookie (defense in
@ -758,7 +785,12 @@ export function setupAuth(
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secure: authConfig.secureCookie, 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,
}, },
}); });