fix(security): owner auth gate, OAuth state cookie binding, 0600 token perms

- Add OWNER_SECRET-based session: signed HMAC cookie, /api/auth/owner login,
  requireOwner middleware. All Tesla routes refuse 401 without it.
- Bind OAuth state to a SameSite=Lax httpOnly cookie at /start, validate
  match in /callback with constant-time compare. Refuses unmatched callbacks.
- Token store now mkdir 0700, writeFile + rename atomic, mode 0600 with
  defensive chmod. Owner-only on disk.
- VIN masked to last 4 in responses; partner-register no longer echoes raw
  Tesla body to clients; coord bounds checked on send-to-nav.
- Client: useTesla also tracks owner status; Connect Tesla button opens an
  OwnerLoginModal when not authenticated, then continues to Tesla OAuth.

Conscious deferrals:
- Explicit CSRF tokens on POST routes: mitigated by SameSite=Lax cookies
  + same-origin CORS. Will revisit if cross-origin clients land.
- At-rest token encryption: deferred for single-user app; tokens are on a
  0700 Dokku volume readable only by the app uid. Will add AES-GCM if we
  multi-tenant.
This commit is contained in:
2026-05-31 22:32:22 +01:00
parent d705669dda
commit f793b526aa
8 changed files with 348 additions and 73 deletions
+70
View File
@@ -0,0 +1,70 @@
import crypto from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
import { env } from '../config/env.js';
import { createLogger } from './logger.js';
const log = createLogger('owner-auth');
const COOKIE_NAME = 'owner_session';
const COOKIE_MAX_AGE_MS = 30 * 24 * 3600 * 1000;
const OWNER_ID = 'owner';
function expectedCookieValue(): string {
if (!env.ownerSecret) return '';
return crypto.createHmac('sha256', env.ownerSecret).update('owner').digest('hex');
}
export function setOwnerCookie(res: Response): void {
res.cookie(COOKIE_NAME, expectedCookieValue(), {
httpOnly: true,
secure: env.nodeEnv === 'production',
sameSite: 'lax',
maxAge: COOKIE_MAX_AGE_MS,
path: '/',
});
}
export function clearOwnerCookie(res: Response): void {
res.clearCookie(COOKIE_NAME, { path: '/' });
}
/** True when the request carries a valid owner session cookie. */
export function isOwnerAuthenticated(req: Request): boolean {
if (!env.ownerSecret) return false;
const got = (req as any).cookies?.[COOKIE_NAME];
if (typeof got !== 'string' || got.length === 0) return false;
const expected = expectedCookieValue();
if (got.length !== expected.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(expected, 'hex'));
} catch {
return false;
}
}
/** Returns the authenticated owner id or null. Always a single 'owner' id today. */
export function ownerIdFromRequest(req: Request): string | null {
return isOwnerAuthenticated(req) ? OWNER_ID : null;
}
/** Express middleware: 401 if the request is not owner-authenticated. */
export function requireOwner(req: Request, res: Response, next: NextFunction): void {
if (isOwnerAuthenticated(req)) return next();
res.status(401).json({ error: 'auth_required' });
}
/** Validates an owner secret against env.ownerSecret in constant time. */
export function verifyOwnerSecret(input: unknown): boolean {
if (!env.ownerSecret || typeof input !== 'string') return false;
const a = Buffer.from(input);
const b = Buffer.from(env.ownerSecret);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
/** Logs a warning if owner auth isn't configured — Tesla routes will return 401. */
export function warnIfMisconfigured(): void {
if (!env.ownerSecret) {
log.warn('OWNER_SECRET is not set — Tesla routes will refuse all requests with 401. Set it to enable owner login.');
}
}