111 lines
4.3 KiB
TypeScript
111 lines
4.3 KiB
TypeScript
/**
|
|
* 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,
|
|
// BL-02: mirror the issue-time `secure` logic. issueLocalSessionCookie sets
|
|
// secure:false over plain HTTP (non-production), and a browser will REJECT a
|
|
// Secure delete-cookie sent over HTTP — so a hard-coded secure:true left the
|
|
// local-session cookie uncleared on every non-HTTPS deployment (local dev and any
|
|
// HTTP-only self-host), leaving the user "logged in" after logout. Match the
|
|
// context so the deletion cookie is accepted.
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'Lax',
|
|
});
|
|
}
|