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:
@@ -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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user