From 8c87090ff76bae09b7c48c7b74c53df8b2d5caac Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Fri, 15 May 2026 22:48:50 +0200 Subject: [PATCH] security(#228): replace x-api-key on sporty-sync endpoint with Supabase JWT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POST /api/sporty-sync HTTP trigger was authenticated via a shared x-api-key checked against SPORTY_SYNC_API_KEY. While VITE_SPORTY_SYNC_API_KEY was not in CI secrets, the pattern was fragile and the key could leak if accidentally added. The /api/claude endpoint already handles this correctly with verifySupabaseJwt + X-Supabase-Token; this commit applies the same pattern to the sporty-sync trigger. The /api/sporty-health monitoring endpoint retains x-api-key — it is only called by external monitoring tools, never by the frontend. Co-Authored-By: Claude Sonnet 4.6 --- app/api/local.settings.json.example | 2 +- app/api/sportySync.js | 13 ++++++++++--- app/src/components/Home.jsx | 6 +++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/api/local.settings.json.example b/app/api/local.settings.json.example index ac14e7a..a64a550 100644 --- a/app/api/local.settings.json.example +++ b/app/api/local.settings.json.example @@ -8,6 +8,6 @@ "VITE_SUPABASE_ANON_KEY": "eyJ...", "SUPABASE_ANON_KEY": "eyJ...", "SUPABASE_SERVICE_ROLE_KEY": "eyJ...", - "SPORTY_SYNC_API_KEY": "any-secret-string-you-choose" + "SPORTY_SYNC_API_KEY": "any-secret-string-you-choose (used by /api/sporty-health monitoring endpoint only)" } } diff --git a/app/api/sportySync.js b/app/api/sportySync.js index b31ba42..cc9857e 100644 --- a/app/api/sportySync.js +++ b/app/api/sportySync.js @@ -1,4 +1,5 @@ import { app } from '@azure/functions'; +import { verifySupabaseJwt } from './claudeUtils.js'; // BU 8 is hardcoded for the single-gym MVP (Sporty Thon Senter Ski). // When multi-gym support lands, replace with a DB lookup via user_gyms.sporty_business_unit_id. @@ -180,14 +181,20 @@ app.http('sportySyncHealth', { // ── HTTP trigger: manual kick + optional backfill ───────────────────── // POST /api/sporty-sync → sync today // POST /api/sporty-sync {"shiftDays":-7} → duplicate current data 7 days back -// Requires header: x-api-key: +// Requires header: X-Supabase-Token: +// (Azure SWA hijacks the Authorization header — never use it for app JWTs) app.http('sportySyncHttp', { methods: ['POST'], route: 'sporty-sync', authLevel: 'anonymous', handler: async (request, context) => { - const expectedKey = process.env.SPORTY_SYNC_API_KEY; - if (!expectedKey || request.headers.get('x-api-key') !== expectedKey) { + const token = request.headers.get('x-supabase-token'); + const userId = await verifySupabaseJwt( + token, + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY, + ); + if (!userId) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, diff --git a/app/src/components/Home.jsx b/app/src/components/Home.jsx index 4ca2e0d..85c5b06 100644 --- a/app/src/components/Home.jsx +++ b/app/src/components/Home.jsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { BodySVG } from "../lib/bodymap.jsx"; import { fetchLastSession, fetchThisWeekSessions } from "../lib/db"; import { extractMuscles, logDevError, getIntlLocale, toWeekIso, isoWeekMonday, toIsoDate } from "../lib/utils"; +import { supabase } from "../lib/supabase"; import PageShell, { SectionLabel, AccentChip } from "./PageShell"; import { useNav } from "../lib/NavContext"; @@ -45,9 +46,12 @@ export default function Home({ onShowHistoryWithDate }) { setSyncState('loading'); setSyncMsg(''); try { + const { data: { session } } = await supabase.auth.getSession(); const res = await fetch('/api/sporty-sync', { method: 'POST', - headers: { 'x-api-key': import.meta.env.VITE_SPORTY_SYNC_API_KEY ?? '' }, + headers: { + ...(session?.access_token ? { 'X-Supabase-Token': session.access_token } : {}), + }, }); const json = await res.json(); setSyncState(res.ok ? 'ok' : 'error');