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:
+38
-8
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
export interface TeslaStatus {
|
export interface TeslaStatus {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
ownerAuthenticated?: boolean;
|
||||||
connectedAt?: number | null;
|
connectedAt?: number | null;
|
||||||
vehicleId?: string | null;
|
vehicleId?: string | null;
|
||||||
vin?: string | null;
|
vin?: string | null;
|
||||||
@@ -11,6 +12,30 @@ export interface TeslaStatus {
|
|||||||
reason?: string;
|
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 {
|
export interface TeslaState {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
asleep?: 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. */
|
/** Hook: subscribe to Tesla status + live state. Polls every 60s while connected. */
|
||||||
export function useTesla() {
|
export function useTesla() {
|
||||||
const [status, setStatus] = useState<TeslaStatus | null>(null);
|
const [status, setStatus] = useState<TeslaStatus | null>(null);
|
||||||
|
const [owner, setOwner] = useState<OwnerStatus | null>(null);
|
||||||
const [state, setState] = useState<TeslaState | null>(null);
|
const [state, setState] = useState<TeslaState | null>(null);
|
||||||
const [stateLoading, setStateLoading] = useState(false);
|
const [stateLoading, setStateLoading] = useState(false);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// Initial status fetch + react to ?tesla_connected=1 on return from OAuth.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const s = await fetchTeslaStatus();
|
const [s, o] = await Promise.all([fetchTeslaStatus(), fetchOwnerStatus()]);
|
||||||
if (!cancelled) setStatus(s);
|
if (!cancelled) { setStatus(s); setOwner(o); }
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
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(() => {
|
useEffect(() => {
|
||||||
if (!status?.connected) {
|
if (!status?.connected || !owner?.authenticated) {
|
||||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||||
setState(null);
|
setState(null);
|
||||||
return;
|
return;
|
||||||
@@ -108,9 +135,12 @@ export function useTesla() {
|
|||||||
tick();
|
tick();
|
||||||
pollRef.current = setInterval(tick, 60_000);
|
pollRef.current = setInterval(tick, 60_000);
|
||||||
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
|
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 { detectInCar } from '../lib/incar';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import {
|
import {
|
||||||
@@ -1686,6 +1686,9 @@ export default function TeslaTripPlanner() {
|
|||||||
const [travelDates, setTravelDates] = useState<TravelDates>({ outbound: null, return: null, travellers: 2 });
|
const [travelDates, setTravelDates] = useState<TravelDates>({ outbound: null, return: null, travellers: 2 });
|
||||||
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||||
const [dateAnchor, setDateAnchor] = useState<DOMRect | null>(null);
|
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 lastODSent = React.useRef<{ from: string; to: string } | null>(null);
|
||||||
const [variants, setVariants] = useState<RouteVariant[]>([]);
|
const [variants, setVariants] = useState<RouteVariant[]>([]);
|
||||||
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
|
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
|
||||||
@@ -2189,6 +2192,14 @@ export default function TeslaTripPlanner() {
|
|||||||
teslaStatus={tesla.status}
|
teslaStatus={tesla.status}
|
||||||
teslaState={tesla.state}
|
teslaState={tesla.state}
|
||||||
onConnectTesla={async () => {
|
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(); }
|
try { await startTeslaConnect(); }
|
||||||
catch { toast.error('Could not start Tesla OAuth'); }
|
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>
|
</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 ─────────────────────────────────────────────────────────────
|
// ─── Modal shell ─────────────────────────────────────────────────────────────
|
||||||
function ModalShell({
|
function ModalShell({
|
||||||
onClose, width = 720, title, subtitle, footer, children,
|
onClose, width = 720, title, subtitle, footer, children,
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export const env = {
|
|||||||
grokEnabled: process.env.GROK_ENABLED !== 'false',
|
grokEnabled: process.env.GROK_ENABLED !== 'false',
|
||||||
forceXaiApi: process.env.FORCE_XAI_API === 'true',
|
forceXaiApi: process.env.FORCE_XAI_API === 'true',
|
||||||
|
|
||||||
|
// Owner auth — single-user gate for the Tesla integration until
|
||||||
|
// auth.tony.codes is wired in. Set OWNER_SECRET to a long random string.
|
||||||
|
ownerSecret: process.env.OWNER_SECRET || '',
|
||||||
|
|
||||||
// Tesla Fleet API
|
// Tesla Fleet API
|
||||||
tesla: {
|
tesla: {
|
||||||
// Public key served at /.well-known/appspecific/com.tesla.3p.public-key.pem
|
// Public key served at /.well-known/appspecific/com.tesla.3p.public-key.pem
|
||||||
|
|||||||
+6
-2
@@ -11,6 +11,8 @@ import { logger } from './lib/logger.js';
|
|||||||
import chatRoutes from './routes/chat.js';
|
import chatRoutes from './routes/chat.js';
|
||||||
import tripsRoutes from './routes/trips.js';
|
import tripsRoutes from './routes/trips.js';
|
||||||
import teslaRoutes from './routes/tesla.js';
|
import teslaRoutes from './routes/tesla.js';
|
||||||
|
import ownerRoutes from './routes/owner.js';
|
||||||
|
import { warnIfMisconfigured as warnOwnerAuth } from './lib/ownerAuth.js';
|
||||||
import { createOptionalAuth } from './lib/auth.js';
|
import { createOptionalAuth } from './lib/auth.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -38,8 +40,10 @@ if (auth) {
|
|||||||
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts');
|
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tesla integration: serves the partner public key + OAuth callback. Mounted
|
// Owner auth + Tesla integration. Tesla routes are owner-gated except the
|
||||||
// at the app root because Tesla's well-known path is fixed.
|
// public .well-known partner-key path. Owner routes handle login/logout.
|
||||||
|
warnOwnerAuth();
|
||||||
|
app.use(ownerRoutes);
|
||||||
app.use(teslaRoutes);
|
app.use(teslaRoutes);
|
||||||
|
|
||||||
app.use('/api', chatRoutes);
|
app.use('/api', chatRoutes);
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,8 @@ let writeLock: Promise<unknown> = Promise.resolve();
|
|||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
if (loaded) return;
|
if (loaded) return;
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(dataDir, { recursive: true });
|
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 });
|
||||||
|
await fs.chmod(dataDir, 0o700).catch(() => {}); // tighten if it already existed
|
||||||
const raw = await fs.readFile(tokenFile, 'utf8');
|
const raw = await fs.readFile(tokenFile, 'utf8');
|
||||||
cache = JSON.parse(raw);
|
cache = JSON.parse(raw);
|
||||||
log.info({ file: tokenFile, users: Object.keys(cache).length }, 'Loaded Tesla tokens');
|
log.info({ file: tokenFile, users: Object.keys(cache).length }, 'Loaded Tesla tokens');
|
||||||
@@ -50,11 +51,17 @@ async function load(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function persist(): Promise<void> {
|
async function persist(): Promise<void> {
|
||||||
// Serialise writes so concurrent set/remove calls don't race.
|
// Serialise writes so concurrent set/remove calls don't race. Write to a
|
||||||
|
// tmpfile then rename for atomicity; owner-only perms via mode + chmod.
|
||||||
writeLock = writeLock
|
writeLock = writeLock
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await fs.mkdir(dataDir, { recursive: true });
|
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 });
|
||||||
await fs.writeFile(tokenFile, JSON.stringify(cache, null, 2));
|
await fs.chmod(dataDir, 0o700).catch(() => {});
|
||||||
|
const tmp = `${tokenFile}.${process.pid}.tmp`;
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 });
|
||||||
|
await fs.chmod(tmp, 0o600).catch(() => {});
|
||||||
|
await fs.rename(tmp, tokenFile);
|
||||||
|
await fs.chmod(tokenFile, 0o600).catch(() => {});
|
||||||
})
|
})
|
||||||
.catch(err => log.error({ err: String(err) }, 'Failed to persist Tesla tokens'));
|
.catch(err => log.error({ err: String(err) }, 'Failed to persist Tesla tokens'));
|
||||||
await writeLock;
|
await writeLock;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import {
|
||||||
|
clearOwnerCookie,
|
||||||
|
isOwnerAuthenticated,
|
||||||
|
setOwnerCookie,
|
||||||
|
verifyOwnerSecret,
|
||||||
|
} from '../lib/ownerAuth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/api/auth/owner/status', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
authenticated: isOwnerAuthenticated(req),
|
||||||
|
required: !!env.ownerSecret,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/auth/owner', (req, res) => {
|
||||||
|
if (!env.ownerSecret) {
|
||||||
|
res.status(503).json({ ok: false, reason: 'owner_auth_not_configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { secret } = (req.body || {}) as { secret?: unknown };
|
||||||
|
if (!verifyOwnerSecret(secret)) {
|
||||||
|
res.status(401).json({ ok: false, reason: 'invalid_secret' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOwnerCookie(res);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/auth/owner/logout', (_req, res) => {
|
||||||
|
clearOwnerCookie(res);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
+73
-58
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
|
|||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import { createLogger } from '../lib/logger.js';
|
import { createLogger } from '../lib/logger.js';
|
||||||
import { teslaTokenStore } from '../lib/teslaTokenStore.js';
|
import { teslaTokenStore } from '../lib/teslaTokenStore.js';
|
||||||
|
import { ownerIdFromRequest, requireOwner } from '../lib/ownerAuth.js';
|
||||||
import {
|
import {
|
||||||
buildAuthorizeUrl,
|
buildAuthorizeUrl,
|
||||||
exchangeCodeForTokens,
|
exchangeCodeForTokens,
|
||||||
@@ -18,14 +19,10 @@ import {
|
|||||||
const log = createLogger('tesla');
|
const log = createLogger('tesla');
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Until auth.tony.codes is wired in, fall back to a single anonymous "owner"
|
const TESLA_STATE_COOKIE = 'tesla_oauth_state';
|
||||||
// identity so the integration works for the deploying user. Replace with
|
const STATE_TTL_S = 10 * 60;
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Domain verification ────────────────────────────────────────────────────
|
// ─── Domain verification (PUBLIC — Tesla fetches anonymously) ───────────────
|
||||||
router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => {
|
router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => {
|
||||||
if (!env.tesla.publicKey) {
|
if (!env.tesla.publicKey) {
|
||||||
log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty');
|
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);
|
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) => {
|
router.get('/api/tesla/status', async (req, res) => {
|
||||||
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
||||||
res.json({ available: false, reason: 'pending_partner_approval' });
|
res.json({ available: false, reason: 'pending_partner_approval' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tokens = await teslaTokenStore.get(userIdFor(req));
|
const ownerId = ownerIdFromRequest(req);
|
||||||
|
const tokens = ownerId ? await teslaTokenStore.get(ownerId) : null;
|
||||||
res.json({
|
res.json({
|
||||||
available: true,
|
available: true,
|
||||||
connected: !!tokens,
|
connected: !!tokens,
|
||||||
|
ownerAuthenticated: !!ownerId,
|
||||||
connectedAt: tokens?.connectedAt ?? null,
|
connectedAt: tokens?.connectedAt ?? null,
|
||||||
vehicleId: tokens?.vehicleId ?? null,
|
vehicleId: tokens?.vehicleId ?? null,
|
||||||
vin: tokens?.vin ?? null,
|
vin: tokens?.vin ? `…${tokens.vin.slice(-4)}` : null, // masked
|
||||||
carType: tokens?.carType ?? null,
|
carType: tokens?.carType ?? null,
|
||||||
trimBadging: tokens?.trimBadging ?? null,
|
trimBadging: tokens?.trimBadging ?? null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Start the OAuth dance ──────────────────────────────────────────────────
|
// ─── Everything below requires owner auth ──────────────────────────────────
|
||||||
const pendingStates = new Map<string, { userId: string; createdAt: number }>();
|
|
||||||
const STATE_TTL = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
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) {
|
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
||||||
res.status(503).json({ error: 'tesla_not_configured' });
|
res.status(503).json({ error: 'tesla_not_configured' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Clean expired states.
|
// Random state — bound to the browser via an httpOnly cookie so a CSRF
|
||||||
const now = Date.now();
|
// attacker can't pre-seed a state that lands tokens in our account.
|
||||||
for (const [k, v] of pendingStates) {
|
|
||||||
if (now - v.createdAt > STATE_TTL) pendingStates.delete(k);
|
|
||||||
}
|
|
||||||
const state = crypto.randomBytes(16).toString('hex');
|
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);
|
const url = buildAuthorizeUrl(state);
|
||||||
res.json({ authorizeUrl: url, state });
|
res.json({ authorizeUrl: url });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── OAuth callback ─────────────────────────────────────────────────────────
|
// ─── 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) => {
|
router.get('/api/auth/tesla/callback', async (req, res) => {
|
||||||
const { code, state, error } = req.query as Record<string, string | undefined>;
|
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) {
|
if (error) {
|
||||||
log.warn({ error, state }, 'Tesla OAuth error');
|
log.warn({ error }, 'Tesla OAuth error');
|
||||||
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
|
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
|
||||||
return;
|
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');
|
res.status(400).type('text/plain').send('Missing code or state');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pending = pendingStates.get(state);
|
if (!cookieState || typeof cookieState !== 'string' || cookieState.length !== state.length
|
||||||
if (!pending) {
|
|| !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(state))) {
|
||||||
res.status(400).type('text/plain').send('Unknown or expired OAuth state — restart from /api/auth/tesla/start');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
pendingStates.delete(state);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokens = await exchangeCodeForTokens(code);
|
const tokens = await exchangeCodeForTokens(code);
|
||||||
|
|
||||||
// Discover the user's vehicle so we can stash its id_s and config.
|
|
||||||
let vehicleId: string | undefined;
|
let vehicleId: string | undefined;
|
||||||
let vin: string | undefined;
|
let vin: string | undefined;
|
||||||
let carType: string | undefined;
|
let carType: string | undefined;
|
||||||
@@ -110,8 +128,6 @@ router.get('/api/auth/tesla/callback', async (req, res) => {
|
|||||||
vin = first.vin;
|
vin = first.vin;
|
||||||
}
|
}
|
||||||
if (vehicleId) {
|
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 {
|
try {
|
||||||
const data = await getVehicleData(tokens.access_token, vehicleId);
|
const data = await getVehicleData(tokens.access_token, vehicleId);
|
||||||
carType = data?.vehicle_config?.car_type;
|
carType = data?.vehicle_config?.car_type;
|
||||||
@@ -121,10 +137,10 @@ router.get('/api/auth/tesla/callback', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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,
|
accessToken: tokens.access_token,
|
||||||
refreshToken: tokens.refresh_token,
|
refreshToken: tokens.refresh_token,
|
||||||
expiresAt: Date.now() + tokens.expires_in * 1000,
|
expiresAt: Date.now() + tokens.expires_in * 1000,
|
||||||
@@ -136,7 +152,7 @@ router.get('/api/auth/tesla/callback', async (req, res) => {
|
|||||||
connectedAt: Date.now(),
|
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');
|
res.redirect('/?tesla_connected=1');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error({ err: String(err) }, 'Tesla OAuth callback failed');
|
log.error({ err: String(err) }, 'Tesla OAuth callback failed');
|
||||||
@@ -145,18 +161,20 @@ router.get('/api/auth/tesla/callback', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Disconnect ─────────────────────────────────────────────────────────────
|
// ─── Disconnect ─────────────────────────────────────────────────────────────
|
||||||
router.post('/api/tesla/disconnect', async (req, res) => {
|
router.post('/api/tesla/disconnect', requireOwner, async (req, res) => {
|
||||||
await teslaTokenStore.remove(userIdFor(req));
|
const ownerId = ownerIdFromRequest(req)!;
|
||||||
|
await teslaTokenStore.remove(ownerId);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Vehicle state (battery, range, location) ───────────────────────────────
|
// ─── 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) {
|
if (!env.tesla.clientId) {
|
||||||
res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
|
res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tokens = await getAccessToken(userIdFor(req));
|
const ownerId = ownerIdFromRequest(req)!;
|
||||||
|
const tokens = await getAccessToken(ownerId);
|
||||||
if (!tokens) {
|
if (!tokens) {
|
||||||
res.status(401).json({ connected: false, reason: 'not_connected' });
|
res.status(401).json({ connected: false, reason: 'not_connected' });
|
||||||
return;
|
return;
|
||||||
@@ -176,29 +194,26 @@ router.get('/api/tesla/state', async (req, res) => {
|
|||||||
connected: true,
|
connected: true,
|
||||||
asleep: false,
|
asleep: false,
|
||||||
battery: typeof cs.battery_level === 'number' ? cs.battery_level : null,
|
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,
|
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,
|
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,
|
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,
|
lat: typeof ds.latitude === 'number' ? ds.latitude : null,
|
||||||
lng: typeof ds.longitude === 'number' ? ds.longitude : null,
|
lng: typeof ds.longitude === 'number' ? ds.longitude : null,
|
||||||
heading: typeof ds.heading === 'number' ? ds.heading : null,
|
heading: typeof ds.heading === 'number' ? ds.heading : null,
|
||||||
speedKmh: typeof ds.speed === 'number' ? Math.round(ds.speed * 1.60934) : 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,
|
odometerKm: typeof vs.odometer === 'number' ? Math.round(vs.odometer * 1.60934) : null,
|
||||||
carType: vc.car_type ?? tokens.carType ?? null,
|
carType: vc.car_type ?? tokens.carType ?? null,
|
||||||
trimBadging: vc.trim_badging ?? tokens.trimBadging ?? 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,
|
vehicleName: vs.vehicle_name ?? null,
|
||||||
softwareVersion: vs.car_version ?? null,
|
softwareVersion: vs.car_version ?? null,
|
||||||
fetchedAt: Date.now(),
|
fetchedAt: Date.now(),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = String(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)) {
|
if (/408/.test(msg) || /asleep/i.test(msg)) {
|
||||||
res.status(202).json({ connected: true, asleep: true, vehicleId: tokens.vehicleId });
|
res.status(202).json({ connected: true, asleep: true, vehicleId: tokens.vehicleId });
|
||||||
return;
|
return;
|
||||||
@@ -208,9 +223,9 @@ router.get('/api/tesla/state', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Wake the vehicle ───────────────────────────────────────────────────────
|
router.post('/api/tesla/wake', requireOwner, async (req, res) => {
|
||||||
router.post('/api/tesla/wake', async (req, res) => {
|
const ownerId = ownerIdFromRequest(req)!;
|
||||||
const tokens = await getAccessToken(userIdFor(req));
|
const tokens = await getAccessToken(ownerId);
|
||||||
if (!tokens || !tokens.vehicleId) {
|
if (!tokens || !tokens.vehicleId) {
|
||||||
res.status(401).json({ ok: false, reason: 'not_connected' });
|
res.status(401).json({ ok: false, reason: 'not_connected' });
|
||||||
return;
|
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', requireOwner, async (req: Request, res: Response) => {
|
||||||
router.post('/api/tesla/send-to-nav', async (req: Request, res: Response) => {
|
const ownerId = ownerIdFromRequest(req)!;
|
||||||
const tokens = await getAccessToken(userIdFor(req));
|
const tokens = await getAccessToken(ownerId);
|
||||||
if (!tokens || !tokens.vehicleId) {
|
if (!tokens || !tokens.vehicleId) {
|
||||||
res.status(401).json({ ok: false, reason: 'not_connected' });
|
res.status(401).json({ ok: false, reason: 'not_connected' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { lat, lng, name } = req.body || {};
|
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' });
|
res.status(400).json({ ok: false, reason: 'bad_coords' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -245,10 +261,9 @@ router.post('/api/tesla/send-to-nav', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── One-shot partner-account registration ─────────────────────────────────
|
// ─── Partner-account registration ─────────────────────────────────────────
|
||||||
// Visit this once after partner approval to register our domain + public key
|
// One-shot. Owner-gated. Does NOT echo Tesla's response body to the client.
|
||||||
// with Tesla so command signing works. Subsequent calls are no-ops.
|
router.post('/api/tesla/register-partner', requireOwner, async (_req, res) => {
|
||||||
router.post('/api/tesla/register-partner', async (_req, res) => {
|
|
||||||
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
||||||
res.status(503).json({ ok: false, reason: 'tesla_not_configured' });
|
res.status(503).json({ ok: false, reason: 'tesla_not_configured' });
|
||||||
return;
|
return;
|
||||||
@@ -256,11 +271,11 @@ router.post('/api/tesla/register-partner', async (_req, res) => {
|
|||||||
try {
|
try {
|
||||||
const appToken = await getAppToken();
|
const appToken = await getAppToken();
|
||||||
const domain = new URL(env.tesla.redirectUri).hostname;
|
const domain = new URL(env.tesla.redirectUri).hostname;
|
||||||
const out = await registerPartnerAccount(appToken, domain);
|
await registerPartnerAccount(appToken, domain);
|
||||||
res.json({ ok: true, response: out });
|
res.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error({ err: String(err) }, 'partner registration failed');
|
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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user