sync: update from private repo (484a803)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
547a4bbb00
commit
a0923abc40
@ -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 が自動更新する。
|
||||
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// セッションミドルウェア
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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'
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user