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
+38 -8
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
export interface TeslaStatus {
available: boolean;
connected: boolean;
ownerAuthenticated?: boolean;
connectedAt?: number | null;
vehicleId?: string | null;
vin?: string | null;
@@ -11,6 +12,30 @@ export interface TeslaStatus {
reason?: string;
}
export interface OwnerStatus {
authenticated: boolean;
required: boolean;
}
export async function fetchOwnerStatus(): Promise<OwnerStatus> {
const res = await fetch('/api/auth/owner/status');
if (!res.ok) return { authenticated: false, required: false };
return res.json();
}
export async function loginOwner(secret: string): Promise<boolean> {
const res = await fetch('/api/auth/owner', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret }),
});
return res.ok;
}
export async function logoutOwner(): Promise<void> {
await fetch('/api/auth/owner/logout', { method: 'POST' });
}
export interface TeslaState {
connected: boolean;
asleep?: boolean;
@@ -75,23 +100,25 @@ export async function sendToTeslaNav(args: { lat: number; lng: number; name?: st
/** Hook: subscribe to Tesla status + live state. Polls every 60s while connected. */
export function useTesla() {
const [status, setStatus] = useState<TeslaStatus | null>(null);
const [owner, setOwner] = useState<OwnerStatus | null>(null);
const [state, setState] = useState<TeslaState | null>(null);
const [stateLoading, setStateLoading] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Initial status fetch + react to ?tesla_connected=1 on return from OAuth.
useEffect(() => {
let cancelled = false;
(async () => {
const s = await fetchTeslaStatus();
if (!cancelled) setStatus(s);
const [s, o] = await Promise.all([fetchTeslaStatus(), fetchOwnerStatus()]);
if (!cancelled) { setStatus(s); setOwner(o); }
})();
return () => { cancelled = true; };
}, []);
// Fetch live state whenever we become connected, then poll.
// Live state polling. Only runs when the user is owner-authenticated AND
// the Tesla account is connected — anything else returns 401 and the poll
// would spam the log.
useEffect(() => {
if (!status?.connected) {
if (!status?.connected || !owner?.authenticated) {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setState(null);
return;
@@ -108,9 +135,12 @@ export function useTesla() {
tick();
pollRef.current = setInterval(tick, 60_000);
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
}, [status?.connected]);
}, [status?.connected, owner?.authenticated]);
const refreshStatus = async () => setStatus(await fetchTeslaStatus());
const refreshStatus = async () => {
const [s, o] = await Promise.all([fetchTeslaStatus(), fetchOwnerStatus()]);
setStatus(s); setOwner(o);
};
return { status, state, stateLoading, refreshStatus };
return { status, state, stateLoading, owner, refreshStatus };
}
+108 -1
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { toast } from 'sonner';
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla } from '../lib/tesla';
import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner } from '../lib/tesla';
import { detectInCar } from '../lib/incar';
import L from 'leaflet';
import {
@@ -1686,6 +1686,9 @@ export default function TeslaTripPlanner() {
const [travelDates, setTravelDates] = useState<TravelDates>({ outbound: null, return: null, travellers: 2 });
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [dateAnchor, setDateAnchor] = useState<DOMRect | null>(null);
const [ownerLoginOpen, setOwnerLoginOpen] = useState(false);
// After successful owner login, fire this pending action.
const ownerLoginThenRef = React.useRef<null | (() => void)>(null);
const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
const [variants, setVariants] = useState<RouteVariant[]>([]);
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
@@ -2189,6 +2192,14 @@ export default function TeslaTripPlanner() {
teslaStatus={tesla.status}
teslaState={tesla.state}
onConnectTesla={async () => {
if (tesla.owner?.required && !tesla.owner.authenticated) {
ownerLoginThenRef.current = async () => {
try { await startTeslaConnect(); }
catch { toast.error('Could not start Tesla OAuth'); }
};
setOwnerLoginOpen(true);
return;
}
try { await startTeslaConnect(); }
catch { toast.error('Could not start Tesla OAuth'); }
}}
@@ -2636,10 +2647,106 @@ export default function TeslaTripPlanner() {
}
}}
/>
<OwnerLoginModal
open={ownerLoginOpen}
onClose={() => setOwnerLoginOpen(false)}
onSuccess={async () => {
setOwnerLoginOpen(false);
await tesla.refreshStatus();
const fn = ownerLoginThenRef.current;
ownerLoginThenRef.current = null;
if (fn) fn();
}}
/>
</ErrorBoundary>
);
}
function OwnerLoginModal({ open, onClose, onSuccess }: {
open: boolean; onClose: () => void; onSuccess: () => void;
}) {
const [secret, setSecret] = React.useState('');
const [pending, setPending] = React.useState(false);
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const submit = async () => {
if (!secret) return;
setPending(true);
try {
const ok = await loginOwner(secret);
if (ok) {
toast.success('Logged in as owner');
setSecret('');
onSuccess();
} else {
toast.error('Invalid owner secret');
}
} finally {
setPending(false);
}
};
return (
<div
onClick={onClose}
className="fixed inset-0 grid place-items-center p-6"
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-[440px] max-w-full overflow-hidden"
style={{
background: 'var(--gd-bg-2)',
border: '1px solid var(--gd-border-2)',
borderRadius: 16,
boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
}}
>
<div className="px-5 py-4" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="text-[14px] font-semibold">Owner login required</div>
<div className="text-[11.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>
The Tesla integration is restricted to the deploying user. Enter the OWNER_SECRET set in the deploy environment.
</div>
</div>
<div className="px-5 py-4">
<input
type="password"
autoFocus
value={secret}
onChange={(e) => setSecret(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
placeholder="Owner secret"
className="w-full text-[13.5px] px-3 py-2.5 rounded-lg outline-none"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }}
/>
</div>
<div className="px-5 py-4 flex items-center gap-2" style={{ borderTop: '1px solid var(--gd-border)' }}>
<button
onClick={onClose}
className="flex-1 h-10 text-[12.5px] rounded-lg"
style={{ background: 'transparent', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
Cancel
</button>
<button
onClick={submit}
disabled={!secret || pending}
className="flex-[2] h-10 text-[12.5px] rounded-lg disabled:opacity-40"
style={{ background: 'var(--gd-red)', color: '#fff' }}
>
{pending ? 'Logging in' : 'Log in'}
</button>
</div>
</div>
</div>
);
}
// ─── Modal shell ─────────────────────────────────────────────────────────────
function ModalShell({
onClose, width = 720, title, subtitle, footer, children,