feat(19-01): implement localSession JWT cookie helpers and assertLocalSessionSecretSet boot guard
- localSession.ts: issueLocalSessionCookie/verifyLocalSessionCookie/clearLocalSessionCookie - Jwt namespace import from hono/utils/jwt (Pitfall 8 — not named sign/verify) - Cookie name: 'local-session' (distinct from 'oidc-auth', Pitfall 4) - httpOnly, sameSite=Lax, secure in production; try/catch on Jwt.verify (Pitfall 9) - verifyLocalSessionCookie returns null (never throws) on any error - bootGuards.ts: assertLocalSessionSecretSet — exit(1) if secret missing/<32 chars - Exempt when DEV_AUTH_BYPASS=true (bypass doesn't issue local-session cookies) - index.ts: wire assertLocalSessionSecretSet() after assertNotDevBypassInProduction() - All 5 unit tests pass; typecheck exits 0
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* localSession.ts — stateless JWT session-cookie helpers for local auth (D-05).
|
||||
*
|
||||
* Issues and verifies a signed httpOnly JWT cookie named 'local-session',
|
||||
* distinct from the OIDC cookie 'oidc-auth' (Pitfall 4).
|
||||
*
|
||||
* Uses Hono's built-in Jwt from 'hono/utils/jwt' via the { Jwt } namespace import
|
||||
* (Pitfall 8 — named sign/verify do not exist; Jwt.sign/Jwt.verify is correct).
|
||||
*
|
||||
* Security properties (T-19-02):
|
||||
* - HS256 signed with LOCAL_SESSION_SECRET from env (never stored in DB — SC-3)
|
||||
* - httpOnly: true — not accessible from client JavaScript
|
||||
* - secure: true in production, false in non-production (allows local dev over HTTP)
|
||||
* - sameSite: 'Lax' — CSRF mitigation for browser navigation
|
||||
* - Jwt.verify throws on expiry (Pitfall 9) — verifyLocalSessionCookie wraps in try/catch
|
||||
* - verifyLocalSessionCookie returns null (never throws) on any error
|
||||
*
|
||||
* Boot guard:
|
||||
* assertLocalSessionSecretSet() in lib/bootGuards.ts refuses to start if secret
|
||||
* is missing or < 32 chars when not in dev-bypass mode (Pitfall 10 / T-19-03).
|
||||
*/
|
||||
|
||||
import { Jwt } from 'hono/utils/jwt';
|
||||
import { setCookie, getCookie, deleteCookie } from 'hono/cookie';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
// Cookie name must be distinct from the OIDC cookie 'oidc-auth' (Pitfall 4)
|
||||
const COOKIE_NAME = 'local-session';
|
||||
|
||||
// Session max age: default 1 day (86400s); configurable via LOCAL_SESSION_EXPIRES env
|
||||
const SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400);
|
||||
|
||||
/**
|
||||
* Issue a signed local-session JWT cookie for the given userId.
|
||||
*
|
||||
* The JWT payload carries: { userId, iat, exp } (HS256, signed with LOCAL_SESSION_SECRET).
|
||||
* Throws if LOCAL_SESSION_SECRET is not set — the boot guard should have caught this.
|
||||
*/
|
||||
export async function issueLocalSessionCookie(c: Context, userId: number): Promise<void> {
|
||||
const secret = process.env.LOCAL_SESSION_SECRET;
|
||||
if (!secret) throw new Error('LOCAL_SESSION_SECRET env var not set');
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
userId,
|
||||
iat: now,
|
||||
exp: now + SESSION_MAX_AGE_SECONDS,
|
||||
};
|
||||
|
||||
// Pitfall 8: use Jwt.sign (namespace import), NOT named sign from hono/utils/jwt
|
||||
const token = await Jwt.sign(payload, secret, 'HS256');
|
||||
|
||||
setCookie(c, COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the local-session JWT cookie and return the userId, or null.
|
||||
*
|
||||
* Returns null (never throws) when:
|
||||
* - LOCAL_SESSION_SECRET is not set
|
||||
* - No 'local-session' cookie is present
|
||||
* - The JWT is expired (Jwt.verify throws JwtTokenExpired — caught here, Pitfall 9)
|
||||
* - The JWT has been tampered with
|
||||
* - The payload.userId is not a number
|
||||
* - Any other crypto/parse error
|
||||
*/
|
||||
export async function verifyLocalSessionCookie(c: Context): Promise<number | null> {
|
||||
const secret = process.env.LOCAL_SESSION_SECRET;
|
||||
if (!secret) return null;
|
||||
|
||||
const token = getCookie(c, COOKIE_NAME);
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
// Pitfall 8: Jwt.sign/Jwt.verify (namespace import — NOT named exports)
|
||||
// Pitfall 9: Jwt.verify throws on expiry — must catch all errors and return null
|
||||
const payload = await Jwt.verify(token, secret, 'HS256');
|
||||
return typeof payload.userId === 'number' ? payload.userId : null;
|
||||
} catch {
|
||||
// Includes JwtTokenExpired, tampered signature, malformed token, etc.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the local-session cookie.
|
||||
*
|
||||
* Cookie attributes must match the ones set on issue so the browser correctly
|
||||
* expires the cookie (path, httpOnly, sameSite all must match).
|
||||
*/
|
||||
export function clearLocalSessionCookie(c: Context): void {
|
||||
deleteCookie(c, COOKIE_NAME, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
// Use secure:true for delete (browsers only accept the attribute in matching context)
|
||||
// In practice this is safe because logout should happen over HTTPS in production.
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
});
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
||||
import { startBrokerPoller } from './broker/poller.js';
|
||||
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
|
||||
import { startReminderScheduler } from './broker/reminderScheduler.js';
|
||||
import { assertNotDevBypassInProduction } from './lib/bootGuards.js';
|
||||
import { assertNotDevBypassInProduction, assertLocalSessionSecretSet } from './lib/bootGuards.js';
|
||||
import webpush from 'web-push';
|
||||
|
||||
export const app = new Hono();
|
||||
@@ -134,6 +134,8 @@ function isMainModule(): boolean {
|
||||
if (isMainModule()) {
|
||||
// D-08: Production safety guard — must be FIRST, before VAPID config, workers, or serve().
|
||||
assertNotDevBypassInProduction();
|
||||
// D-05 / T-19-03: Refuse to start if LOCAL_SESSION_SECRET is missing/short in non-bypass mode.
|
||||
assertLocalSessionSecretSet();
|
||||
|
||||
// Configure VAPID credentials for web-push before starting background workers.
|
||||
// VAPID_SUBJECT must be a mailto: or https: URL identifying the operator.
|
||||
|
||||
@@ -32,3 +32,35 @@ export function assertNotDevBypassInProduction(): void {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to start the process when LOCAL_SESSION_SECRET is absent or shorter
|
||||
* than 32 characters, UNLESS dev-bypass mode is active.
|
||||
*
|
||||
* Rationale (D-05 / T-19-03 / Pitfall 10):
|
||||
* LOCAL_SESSION_SECRET signs the local-session JWT cookie. A missing or weak
|
||||
* secret means any issued session cookie can be trivially forged. This boot
|
||||
* guard converts a silent misconfiguration into an immediate loud failure
|
||||
* instead of letting the API start and issue insecure JWTs.
|
||||
*
|
||||
* DEV_AUTH_BYPASS=true is exempt: bypass mode never issues local-session cookies
|
||||
* (the OIDC/dev-bypass path handles auth), so the secret is not required there.
|
||||
* This mirrors assertNotDevBypassInProduction's exempt logic.
|
||||
*
|
||||
* Call immediately after assertNotDevBypassInProduction() in the isMainModule()
|
||||
* boot block in index.ts.
|
||||
*/
|
||||
export function assertLocalSessionSecretSet(): void {
|
||||
// Exempt when dev-bypass is active — bypass mode doesn't issue local-session JWTs
|
||||
if (process.env.DEV_AUTH_BYPASS === 'true') return;
|
||||
|
||||
const secret = process.env.LOCAL_SESSION_SECRET;
|
||||
if (!secret || secret.length < 32) {
|
||||
console.error(
|
||||
'[FATAL] LOCAL_SESSION_SECRET is not set or is shorter than 32 characters. ' +
|
||||
'This secret signs local-session JWT cookies. Refusing to start. ' +
|
||||
'Run: node scripts/generate-secrets.mjs to generate a value.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user