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
+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,