From a0923abc40bda1a85906f4a0cd422ed9a2cb2fa9 Mon Sep 17 00:00:00 2001 From: oss-sync Date: Fri, 12 Jun 2026 06:01:03 +0000 Subject: [PATCH] sync: update from private repo (484a803) --- config.yaml.example | 7 +- src/bridge/auth.session-store.test.ts | 107 +++++++++++++++++++++++++- src/bridge/auth.ts | 79 ++++++++++++++++--- src/bridge/server.ts | 3 +- src/config.ts | 5 +- 5 files changed, 183 insertions(+), 18 deletions(-) diff --git a/config.yaml.example b/config.yaml.example index 451734b..8d4127a 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -338,8 +338,8 @@ tools: # ─── 認証 (オプション) ──────────────────────────────────────── # 未設定なら認証なしで動作 (従来互換)。 # auth: -# # 空のままだと起動時にプロセスごとのランダム値で代替する (再起動でセッション失効)。 -# # 本番では `openssl rand -hex 32` 等で固定値を生成して設定すること。 +# # 空のままだと data/secrets/session-secret.key に固定値を自動生成して使う (再起動でセッション維持)。 +# # 明示設定したい場合は `openssl rand -hex 32` 等で生成した固定値を入れる (複数ノードで共有する場合は必須)。 # # ⚠ プレースホルダ文字列をそのまま使わない (公開された secret は偽造・改ざんに使われる)。 # session_secret: "" # session_max_age: 86400000 # 無操作タイムアウト (ms)。アクセスごとに延長 (rolling)。未設定時は30日 @@ -380,7 +380,8 @@ tools: # ─── Secrets ───────────────────────────────────────────────── # secrets: -# master_key_path: ./data/secrets/master.key # 32-byte key, auto-generated on first start (mode 0600) +# master_key_path: ./data/secrets/master.key # 32-byte key, auto-generated on first start (mode 0600) +# session_secret_path: ./data/secrets/session-secret.key # auth.session_secret 未設定時の自動生成先 (mode 0600) # ─── Reflection ("Hermes" mode) ────────────────────────────── # default OFF。ON にすると毎ジョブ完了後に user memory を LLM が自動更新する。 diff --git a/src/bridge/auth.session-store.test.ts b/src/bridge/auth.session-store.test.ts index c0c567b..d0dc447 100644 --- a/src/bridge/auth.session-store.test.ts +++ b/src/bridge/auth.session-store.test.ts @@ -10,11 +10,19 @@ * - 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 { describe, it, expect, beforeEach, afterEach } from 'vitest'; import express from 'express'; import request from 'supertest'; +import { mkdtempSync, rmSync, existsSync, statSync, writeFileSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { Repository } from '../db/repository.js'; -import { createSqliteSessionStore, DEFAULT_SESSION_MAX_AGE_MS, setupAuth } from './auth.js'; +import { + createSqliteSessionStore, + DEFAULT_SESSION_MAX_AGE_MS, + setupAuth, + loadOrCreateSessionSecret, +} from './auth.js'; import type { AuthConfig } from '../config.js'; import type { SessionData } from 'express-session'; @@ -145,3 +153,98 @@ describe('session middleware wiring', () => { expect(cookieTtlSec(second.headers['set-cookie'])).toBeGreaterThan(0); }); }); + +// ── Persistent session secret (survives restarts) ──────────────────────────── + +describe('loadOrCreateSessionSecret', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'sess-secret-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('generates, persists (0600), and reuses the same secret', () => { + const p = join(dir, 'nested', 'session-secret.key'); + const first = loadOrCreateSessionSecret(p); + expect(first).toMatch(/^[0-9a-f]{64}$/); // 32 bytes hex + expect(existsSync(p)).toBe(true); + expect(statSync(p).mode & 0o777).toBe(0o600); + // A second call (≈ a restart) must return the SAME secret, not a new one. + expect(loadOrCreateSessionSecret(p)).toBe(first); + }); + + it('honours an existing secret file verbatim', () => { + const p = join(dir, 'session-secret.key'); + writeFileSync(p, 'pre-existing-secret\n'); + expect(loadOrCreateSessionSecret(p)).toBe('pre-existing-secret'); // trimmed + expect(readFileSync(p, 'utf8')).toBe('pre-existing-secret\n'); // unchanged + }); + + it('regenerates over an empty/blank file', () => { + const p = join(dir, 'session-secret.key'); + writeFileSync(p, ' \n'); // blank + const secret = loadOrCreateSessionSecret(p); + expect(secret).toMatch(/^[0-9a-f]{64}$/); + expect(readFileSync(p, 'utf8').trim()).toBe(secret); // overwritten on disk + }); + + it('falls back to a random secret (no throw) when the path is unwritable', () => { + const blocker = join(dir, 'blocker'); // a regular file … + writeFileSync(blocker, 'x'); + const p = join(blocker, 'session-secret.key'); // … used as a parent dir → ENOTDIR + const secret = loadOrCreateSessionSecret(p); + expect(secret).toMatch(/^[0-9a-f]{64}$/); // random in-memory fallback + expect(existsSync(p)).toBe(false); // nothing persisted + }); +}); + +describe('setupAuth persistent secret wiring', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'sess-auth-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('a session signed before a "restart" is still valid after, when session_secret is unset', async () => { + const dbPath = join(dir, 'db.sqlite'); + const secretPath = join(dir, 'session-secret.key'); + const cfg = { sessionSecret: '', secureCookie: false, adminEmails: [], providers: {} } as AuthConfig; + + function bootApp() { + const repo = new Repository(dbPath); // same on-disk store across "restarts" + const { sessionMiddleware } = setupAuth(repo, cfg, undefined, secretPath); + const app = express(); + app.use(sessionMiddleware); + app.get('/bump', (req, res) => { + (req.session as unknown as { n: number }).n = 7; + res.json({ ok: true }); + }); + app.get('/read', (req, res) => res.json({ n: (req.session as unknown as { n?: number }).n ?? null })); + return app; + } + + const before = bootApp(); + const res1 = await request(before).get('/bump'); // signs a session cookie + const cookie = res1.headers['set-cookie']; + expect(cookie).toBeTruthy(); + + // Simulate a process restart: a fresh setupAuth re-reads the persisted + // secret. With the old per-process random secret the cookie signature would + // no longer verify and the session would be lost. + const after = bootApp(); + const replay = await request(after).get('/read').set('Cookie', cookie); + expect(replay.body.n).toBe(7); + }); + + it('does NOT touch the secret file when auth.session_secret is configured', () => { + const secretPath = join(dir, 'session-secret.key'); + const repo = new Repository(':memory:'); + const cfg = { sessionSecret: 'a-configured-secret', secureCookie: false, adminEmails: [], providers: {} } as AuthConfig; + setupAuth(repo, cfg, undefined, secretPath); + expect(existsSync(secretPath)).toBe(false); // configured secret wins; no persistence + }); + + it('treats a whitespace-only session_secret as unset (persists a real one)', () => { + const secretPath = join(dir, 'session-secret.key'); + const repo = new Repository(':memory:'); + const cfg = { sessionSecret: ' ', secureCookie: false, adminEmails: [], providers: {} } as AuthConfig; + setupAuth(repo, cfg, undefined, secretPath); + expect(existsSync(secretPath)).toBe(true); // blank → fell back to a persisted secret + }); +}); diff --git a/src/bridge/auth.ts b/src/bridge/auth.ts index e6c8b73..5661e09 100644 --- a/src/bridge/auth.ts +++ b/src/bridge/auth.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'fs'; +import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import type { Request, Response, NextFunction, RequestHandler, Router } from 'express'; @@ -263,6 +263,65 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction): v */ export const DEFAULT_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +/** Default on-disk location for the auto-generated session secret. */ +export const DEFAULT_SESSION_SECRET_PATH = './data/secrets/session-secret.key'; + +/** + * Resolve the session secret when auth.session_secret is unset. Reads a stable + * secret persisted at `secretPath`, generating + saving one (0600, in a 0700 + * dir) on first run. This keeps sessions valid across restarts instead of the + * old per-process random secret that logged everyone out on every restart. + * Mirrors initMasterKey (src/crypto/sessions.ts). Falls back to an in-memory + * random secret only if the file can't be read or written. + */ +export function loadOrCreateSessionSecret(secretPath: string): string { + try { + if (existsSync(secretPath)) { + const existing = readFileSync(secretPath, 'utf8').trim(); + if (existing.length > 0) { + if (existing.length < 16) { + logger.warn( + `[auth] session secret at ${secretPath} is suspiciously short (${existing.length} chars) — it may be truncated. Delete it to regenerate, or set auth.session_secret.`, + ); + } + return existing; + } + } + const secret = randomBytes(32).toString('hex'); + mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 }); + if (existsSync(secretPath)) { + // File exists but was empty/blank (we'd have returned above otherwise) — + // overwrite it; no creation race to worry about since it already exists. + writeFileSync(secretPath, secret, { mode: 0o600 }); + } else { + // Atomic create: 'wx' fails if another process won the race to create the + // file first; re-read theirs so all processes converge on one secret. + try { + writeFileSync(secretPath, secret, { mode: 0o600, flag: 'wx' }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'EEXIST') { + const won = readFileSync(secretPath, 'utf8').trim(); + if (won.length > 0) return won; + writeFileSync(secretPath, secret, { mode: 0o600 }); // racer wrote blank — overwrite + } else { + throw e; + } + } + } + chmodSync(secretPath, 0o600); + logger.info( + `[auth] generated a persistent session secret at ${secretPath} (sessions now survive restarts). Override with auth.session_secret in Settings → Authentication.`, + ); + return secret; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn( + `[auth] could not persist a session secret at ${secretPath} (${msg}) — using a random per-process secret; sessions reset on restart. Set auth.session_secret in Settings → Authentication.`, + ); + return randomBytes(32).toString('hex'); + } +} + /** * Repository の SQLite DB を使ったカスタムセッションストア。 * sessions テーブル (sid, sess, expired) を直接操作する。 @@ -750,20 +809,20 @@ export function setupAuth( repo: Repository, authConfig: AuthConfig, getBranding?: () => LoginBranding, + sessionSecretPath: string = DEFAULT_SESSION_SECRET_PATH, ): AuthMiddlewares { const db = repo.getDb(); // express-session throws "secret option required" (→ 500 on every request) if // the secret is empty. Auth can be enabled from the Settings UI before a - // session_secret is set, so fall back to a random per-process secret with a - // warning rather than bricking the server. Sessions reset on restart until a - // stable value is configured. - let sessionSecret = authConfig.sessionSecret; - if (!sessionSecret || sessionSecret.length === 0) { - sessionSecret = randomBytes(32).toString('hex'); - logger.warn( - '[auth] auth.session_secret is unset — using a random per-process secret; sessions reset on restart. Set a stable value in Settings → Authentication.', - ); + // session_secret is set, so when it's unset we use a secret persisted on disk + // (generated once) rather than a per-process random one — the latter logged + // everyone out on every restart. A configured auth.session_secret still wins. + // Trim so a whitespace-only YAML value (" ") isn't accepted as a (very weak) + // secret — treat it as unset and fall back to the persisted secret. + let sessionSecret = authConfig.sessionSecret?.trim() ?? ''; + if (sessionSecret.length === 0) { + sessionSecret = loadOrCreateSessionSecret(sessionSecretPath); } // セッションミドルウェア diff --git a/src/bridge/server.ts b/src/bridge/server.ts index 8d3d0ba..d6c2689 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -23,7 +23,7 @@ import { setSessionManager } from '../engine/tools/browser.js'; import { setUserFolderToolDeps } from '../engine/tools/user-folder.js'; import { setSkillToolDeps } from '../engine/tools/skills.js'; import { setAppDocsDeps } from '../engine/tools/app-docs.js'; -import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds } from './auth.js'; +import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds, DEFAULT_SESSION_SECRET_PATH } from './auth.js'; import { canUserSeeTask } from './visibility.js'; import { mountAdminApi } from './admin-api.js'; import { createAdminGatewayApi } from './admin-gateway-api.js'; @@ -329,6 +329,7 @@ export function createCoreServer(opts: CoreServerOptions): { const b = resolveBranding(opts.configManager); return { appName: b.appName, loginPageTitle: b.loginPageTitle }; }, + loadConfig()?.secrets?.sessionSecretPath ?? DEFAULT_SESSION_SECRET_PATH, ); const { sessionMiddleware, passportInit, passportSession, authRouter } = auth; authenticateUpgrade = auth.authenticateUpgrade; diff --git a/src/config.ts b/src/config.ts index aa9a851..b6958d0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -490,8 +490,9 @@ export interface AppConfig { branding?: BrandingConfig; reflection: ReflectionConfig; secrets?: { - masterKeyPath?: string; // default './data/secrets/master.key' - mcpKeyPath?: string; // default './data/secrets/mcp.key' + masterKeyPath?: string; // default './data/secrets/master.key' + mcpKeyPath?: string; // default './data/secrets/mcp.key' + sessionSecretPath?: string; // default './data/secrets/session-secret.key' (auto-generated when auth.session_secret unset) }; userFolderRoot?: string; // default './data/users' /**