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
+73 -58
View File
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
import { env } from '../config/env.js';
import { createLogger } from '../lib/logger.js';
import { teslaTokenStore } from '../lib/teslaTokenStore.js';
import { ownerIdFromRequest, requireOwner } from '../lib/ownerAuth.js';
import {
buildAuthorizeUrl,
exchangeCodeForTokens,
@@ -18,14 +19,10 @@ import {
const log = createLogger('tesla');
const router = Router();
// Until auth.tony.codes is wired in, fall back to a single anonymous "owner"
// identity so the integration works for the deploying user. Replace with
// req.auth!.userId once the auth middleware is mounted in front of this.
function userIdFor(req: Request): string {
return (req as any).auth?.userId || 'owner';
}
const TESLA_STATE_COOKIE = 'tesla_oauth_state';
const STATE_TTL_S = 10 * 60;
// ─── Domain verification ────────────────────────────────────────────────────
// ─── Domain verification (PUBLIC — Tesla fetches anonymously) ───────────────
router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => {
if (!env.tesla.publicKey) {
log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty');
@@ -37,49 +34,64 @@ router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) =
res.send(env.tesla.publicKey);
});
// ─── Connect status ─────────────────────────────────────────────────────────
// ─── Connect status (PUBLIC — used by client to decide UI state) ────────────
// Reveals only whether the integration is configured / connected; never
// emits any credentials or vehicle data.
router.get('/api/tesla/status', async (req, res) => {
if (!env.tesla.clientId || !env.tesla.clientSecret) {
res.json({ available: false, reason: 'pending_partner_approval' });
return;
}
const tokens = await teslaTokenStore.get(userIdFor(req));
const ownerId = ownerIdFromRequest(req);
const tokens = ownerId ? await teslaTokenStore.get(ownerId) : null;
res.json({
available: true,
connected: !!tokens,
ownerAuthenticated: !!ownerId,
connectedAt: tokens?.connectedAt ?? null,
vehicleId: tokens?.vehicleId ?? null,
vin: tokens?.vin ?? null,
vin: tokens?.vin ? `${tokens.vin.slice(-4)}` : null, // masked
carType: tokens?.carType ?? null,
trimBadging: tokens?.trimBadging ?? null,
});
});
// ─── Start the OAuth dance ──────────────────────────────────────────────────
const pendingStates = new Map<string, { userId: string; createdAt: number }>();
const STATE_TTL = 10 * 60 * 1000;
// ─── Everything below requires owner auth ──────────────────────────────────
router.get('/api/auth/tesla/start', (req, res) => {
// ─── Start the OAuth dance ──────────────────────────────────────────────────
router.get('/api/auth/tesla/start', requireOwner, (req, res) => {
if (!env.tesla.clientId || !env.tesla.clientSecret) {
res.status(503).json({ error: 'tesla_not_configured' });
return;
}
// Clean expired states.
const now = Date.now();
for (const [k, v] of pendingStates) {
if (now - v.createdAt > STATE_TTL) pendingStates.delete(k);
}
// Random state — bound to the browser via an httpOnly cookie so a CSRF
// attacker can't pre-seed a state that lands tokens in our account.
const state = crypto.randomBytes(16).toString('hex');
pendingStates.set(state, { userId: userIdFor(req), createdAt: now });
res.cookie(TESLA_STATE_COOKIE, state, {
httpOnly: true,
secure: env.nodeEnv === 'production',
sameSite: 'lax',
maxAge: STATE_TTL_S * 1000,
path: '/',
});
const url = buildAuthorizeUrl(state);
res.json({ authorizeUrl: url, state });
res.json({ authorizeUrl: url });
});
// ─── OAuth callback ─────────────────────────────────────────────────────────
// Tesla redirects the browser here after the user authorises. The owner cookie
// from when /start was called must still be present (SameSite=Lax allows it
// through on the top-level navigation back from Tesla), and the state in the
// cookie must equal the state Tesla bounced back.
router.get('/api/auth/tesla/callback', async (req, res) => {
const { code, state, error } = req.query as Record<string, string | undefined>;
const cookieState = (req as any).cookies?.[TESLA_STATE_COOKIE];
// Always clear the state cookie — single-use.
res.clearCookie(TESLA_STATE_COOKIE, { path: '/' });
if (error) {
log.warn({ error, state }, 'Tesla OAuth error');
log.warn({ error }, 'Tesla OAuth error');
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
return;
}
@@ -87,17 +99,23 @@ router.get('/api/auth/tesla/callback', async (req, res) => {
res.status(400).type('text/plain').send('Missing code or state');
return;
}
const pending = pendingStates.get(state);
if (!pending) {
res.status(400).type('text/plain').send('Unknown or expired OAuth state — restart from /api/auth/tesla/start');
if (!cookieState || typeof cookieState !== 'string' || cookieState.length !== state.length
|| !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(state))) {
log.warn({ haveCookie: !!cookieState }, 'OAuth state cookie missing or mismatched — rejecting');
res.status(400).type('text/plain').send('OAuth state mismatch — restart from the planner');
return;
}
const ownerId = ownerIdFromRequest(req);
if (!ownerId) {
// Owner cookie expired or the user opened the callback in a different
// browser. Refuse rather than fall back to a global identity.
res.status(401).type('text/plain').send('Owner session expired — log in and reconnect');
return;
}
pendingStates.delete(state);
try {
const tokens = await exchangeCodeForTokens(code);
// Discover the user's vehicle so we can stash its id_s and config.
let vehicleId: string | undefined;
let vin: string | undefined;
let carType: string | undefined;
@@ -110,8 +128,6 @@ router.get('/api/auth/tesla/callback', async (req, res) => {
vin = first.vin;
}
if (vehicleId) {
// vehicle_config can fail if the car is asleep — that's fine, we'll
// fill it in next time the user fetches /api/tesla/state.
try {
const data = await getVehicleData(tokens.access_token, vehicleId);
carType = data?.vehicle_config?.car_type;
@@ -121,10 +137,10 @@ router.get('/api/auth/tesla/callback', async (req, res) => {
}
}
} catch (e) {
log.warn({ err: String(e) }, 'listVehicles failed at connect — continuing without vehicle id');
log.warn({ err: String(e) }, 'listVehicles failed at connect');
}
await teslaTokenStore.set(pending.userId, {
await teslaTokenStore.set(ownerId, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
@@ -136,7 +152,7 @@ router.get('/api/auth/tesla/callback', async (req, res) => {
connectedAt: Date.now(),
});
log.info({ userId: pending.userId, vehicleId, vin: vin?.slice(-6) }, 'Tesla connected');
log.info({ ownerId, vehicleId, vin: vin?.slice(-4) }, 'Tesla connected');
res.redirect('/?tesla_connected=1');
} catch (err) {
log.error({ err: String(err) }, 'Tesla OAuth callback failed');
@@ -145,18 +161,20 @@ router.get('/api/auth/tesla/callback', async (req, res) => {
});
// ─── Disconnect ─────────────────────────────────────────────────────────────
router.post('/api/tesla/disconnect', async (req, res) => {
await teslaTokenStore.remove(userIdFor(req));
router.post('/api/tesla/disconnect', requireOwner, async (req, res) => {
const ownerId = ownerIdFromRequest(req)!;
await teslaTokenStore.remove(ownerId);
res.json({ ok: true });
});
// ─── Vehicle state (battery, range, location) ───────────────────────────────
router.get('/api/tesla/state', async (req, res) => {
router.get('/api/tesla/state', requireOwner, async (req, res) => {
if (!env.tesla.clientId) {
res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
return;
}
const tokens = await getAccessToken(userIdFor(req));
const ownerId = ownerIdFromRequest(req)!;
const tokens = await getAccessToken(ownerId);
if (!tokens) {
res.status(401).json({ connected: false, reason: 'not_connected' });
return;
@@ -176,29 +194,26 @@ router.get('/api/tesla/state', async (req, res) => {
connected: true,
asleep: false,
battery: typeof cs.battery_level === 'number' ? cs.battery_level : null,
// Convert miles → km. Tesla returns rated range in miles regardless of region settings.
rangeKm: typeof cs.battery_range === 'number' ? Math.round(cs.battery_range * 1.60934) : null,
idealRangeKm: typeof cs.ideal_battery_range === 'number' ? Math.round(cs.ideal_battery_range * 1.60934) : null,
chargingState: cs.charging_state ?? null, // Charging | Disconnected | Stopped | Complete
chargingState: cs.charging_state ?? null,
chargerPowerKw: typeof cs.charger_power === 'number' ? cs.charger_power : null,
timeToFullCharge: typeof cs.time_to_full_charge === 'number' ? cs.time_to_full_charge : null, // hours
timeToFullCharge: typeof cs.time_to_full_charge === 'number' ? cs.time_to_full_charge : null,
lat: typeof ds.latitude === 'number' ? ds.latitude : null,
lng: typeof ds.longitude === 'number' ? ds.longitude : null,
heading: typeof ds.heading === 'number' ? ds.heading : null,
speedKmh: typeof ds.speed === 'number' ? Math.round(ds.speed * 1.60934) : null,
shiftState: ds.shift_state ?? null, // P | R | N | D
shiftState: ds.shift_state ?? null,
odometerKm: typeof vs.odometer === 'number' ? Math.round(vs.odometer * 1.60934) : null,
carType: vc.car_type ?? tokens.carType ?? null,
trimBadging: vc.trim_badging ?? tokens.trimBadging ?? null,
vin: tokens.vin ?? null,
vin: tokens.vin ? `${tokens.vin.slice(-4)}` : null,
vehicleName: vs.vehicle_name ?? null,
softwareVersion: vs.car_version ?? null,
fetchedAt: Date.now(),
});
} catch (err) {
const msg = String(err);
// 408 from Tesla typically means the car is asleep — caller can prompt
// for a wake.
if (/408/.test(msg) || /asleep/i.test(msg)) {
res.status(202).json({ connected: true, asleep: true, vehicleId: tokens.vehicleId });
return;
@@ -208,9 +223,9 @@ router.get('/api/tesla/state', async (req, res) => {
}
});
// ─── Wake the vehicle ───────────────────────────────────────────────────────
router.post('/api/tesla/wake', async (req, res) => {
const tokens = await getAccessToken(userIdFor(req));
router.post('/api/tesla/wake', requireOwner, async (req, res) => {
const ownerId = ownerIdFromRequest(req)!;
const tokens = await getAccessToken(ownerId);
if (!tokens || !tokens.vehicleId) {
res.status(401).json({ ok: false, reason: 'not_connected' });
return;
@@ -224,15 +239,16 @@ router.post('/api/tesla/wake', async (req, res) => {
}
});
// ─── Send navigation destination to the car ─────────────────────────────────
router.post('/api/tesla/send-to-nav', async (req: Request, res: Response) => {
const tokens = await getAccessToken(userIdFor(req));
router.post('/api/tesla/send-to-nav', requireOwner, async (req: Request, res: Response) => {
const ownerId = ownerIdFromRequest(req)!;
const tokens = await getAccessToken(ownerId);
if (!tokens || !tokens.vehicleId) {
res.status(401).json({ ok: false, reason: 'not_connected' });
return;
}
const { lat, lng, name } = req.body || {};
if (typeof lat !== 'number' || typeof lng !== 'number') {
if (typeof lat !== 'number' || typeof lng !== 'number'
|| lat < -90 || lat > 90 || lng < -180 || lng > 180) {
res.status(400).json({ ok: false, reason: 'bad_coords' });
return;
}
@@ -245,10 +261,9 @@ router.post('/api/tesla/send-to-nav', async (req: Request, res: Response) => {
}
});
// ─── One-shot partner-account registration ─────────────────────────────────
// Visit this once after partner approval to register our domain + public key
// with Tesla so command signing works. Subsequent calls are no-ops.
router.post('/api/tesla/register-partner', async (_req, res) => {
// ─── Partner-account registration ─────────────────────────────────────────
// One-shot. Owner-gated. Does NOT echo Tesla's response body to the client.
router.post('/api/tesla/register-partner', requireOwner, async (_req, res) => {
if (!env.tesla.clientId || !env.tesla.clientSecret) {
res.status(503).json({ ok: false, reason: 'tesla_not_configured' });
return;
@@ -256,11 +271,11 @@ router.post('/api/tesla/register-partner', async (_req, res) => {
try {
const appToken = await getAppToken();
const domain = new URL(env.tesla.redirectUri).hostname;
const out = await registerPartnerAccount(appToken, domain);
res.json({ ok: true, response: out });
await registerPartnerAccount(appToken, domain);
res.json({ ok: true });
} catch (err) {
log.error({ err: String(err) }, 'partner registration failed');
res.status(502).json({ ok: false, reason: 'partner_register_failed', detail: String(err).slice(0, 200) });
res.status(502).json({ ok: false, reason: 'partner_register_failed' });
}
});