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

This commit is contained in:
oss-sync 2026-06-12 06:01:03 +00:00
parent 547a4bbb00
commit a0923abc40
5 changed files with 183 additions and 18 deletions

View File

@ -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日
@ -381,6 +381,7 @@ tools:
# ─── Secrets ─────────────────────────────────────────────────
# secrets:
# 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 が自動更新する。

View File

@ -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
});
});

View File

@ -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);
}
// セッションミドルウェア

View File

@ -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;

View File

@ -492,6 +492,7 @@ export interface AppConfig {
secrets?: {
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'
/**