From dc90e7404b2b0c2c92b273f1482906c05ae15b90 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Sat, 18 Jul 2026 22:15:13 +0200 Subject: [PATCH] Serve Spotify client ID from /api/config instead of baking it in at build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was a Vite build-time env var, requiring a second copy of a non-secret value as a separate Gitea CI secret alongside the one already in .env for the api service. Client id isn't sensitive (public in every Spotify authorize URL), so it doesn't need build-time baking at all — now fetched once from a new GET /api/config on the already-running api server, cached client-side after the first call. Collapses config back down to the one place it already lived (the deploy host's .env / docker-compose environment), removes the CI build-arg and its secret entirely. Verified end-to-end: /api/config returns the real client id, and a live guest-login click correctly carries it through into the actual Spotify authorize URL (decoded and checked, not just assumed from the code). --- .env.example | 6 +++++- .gitea/workflows/docker.yml | 4 ---- Dockerfile | 10 +++++----- client/src/lib/pkce.ts | 23 ++++++++++++++++++++--- server/index.js | 9 +++++++++ 5 files changed, 39 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 953c203..7fb20ea 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,3 @@ -# Used both at deploy time (docker-compose.yml) and by CI (as a repo secret). IMAGE=repo.explewd.com/explewd/fullhouse HOST_PORT=3080 @@ -7,6 +6,11 @@ HOST_PORT=3080 # exactly (Spotify does exact string matching, trailing slash included), # plus a separate one for the guest PKCE flow: / (root, # no path — see client/src/lib/pkce.ts). +# +# This is the only place SPOTIFY_CLIENT_ID needs configuring — the +# client fetches it from the api container's /api/config at runtime +# (it's not sensitive data), not baked in at build time, so there's no +# separate CI secret for it. SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= SPOTIFY_HOST_REDIRECT_URI=https://fullhouse.yourdomain.com/api/spotify/callback diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 959bbf2..971afd7 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -53,10 +53,6 @@ jobs: context: . file: Dockerfile push: true - # Baked in at build time (Vite env vars are compile-time) — - # add SPOTIFY_CLIENT_ID as a repo secret too. - build-args: | - VITE_SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }} tags: | ${{ steps.meta.outputs.image }}:latest ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }} diff --git a/Dockerfile b/Dockerfile index b1ab1e4..9d49868 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,13 +6,13 @@ COPY client/package.json client/package-lock.json* ./ RUN npm install COPY client/ . -# Vite bakes VITE_* vars in at build time, not runtime — has to be a -# real build ARG, not just a container env var set later. -ARG VITE_SPOTIFY_CLIENT_ID -ENV VITE_SPOTIFY_CLIENT_ID=${VITE_SPOTIFY_CLIENT_ID} # Deliberately empty: relies on nginx same-origin-proxying /api to the # api service (see nginx.conf) instead of an absolute cross-origin URL -# — the CORS bug that broke local dev earlier in this build. +# — the CORS bug that broke local dev earlier in this build. No +# SPOTIFY_CLIENT_ID build-arg needed — the client fetches it from +# /api/config at runtime instead (see client/src/lib/pkce.ts); it's not +# sensitive data, and this keeps it configured in exactly one place +# (the server's own .env), not a second copy baked in here. ENV VITE_API_BASE= RUN npx tsc && npx vite build diff --git a/client/src/lib/pkce.ts b/client/src/lib/pkce.ts index d81e5b5..df3fb54 100644 --- a/client/src/lib/pkce.ts +++ b/client/src/lib/pkce.ts @@ -3,7 +3,22 @@ // involvement, tokens never leave the browser. Same flow shape as the // original bingo-bango (https://github.com/f8al/bingo-bango, MIT) uses // for its only mode; this is a fresh implementation, not copied code. -const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID as string; +// Fetched from the API at runtime instead of baked in at build time — +// not sensitive data (Spotify's client id is public in every authorize +// URL), but this keeps it configured in exactly one place (the server's +// .env), not a second copy as a separate CI build-arg. Cached after the +// first call. +let cachedClientId: string | null = null; +async function getClientId(): Promise { + if (cachedClientId) return cachedClientId; + const res = await fetch('/api/config'); + if (!res.ok) throw new Error('could not load Spotify config from the server'); + const data = await res.json(); + if (!data.clientId) throw new Error('server has no SPOTIFY_CLIENT_ID configured'); + cachedClientId = data.clientId as string; + return cachedClientId; +} + // Root URL, not a dedicated path — a single static index.html handles // the callback via a query param (?code=...) instead of needing a // second HTML entry point or server-side rewrite rules for a nested @@ -33,13 +48,14 @@ function randomVerifier(): string { } export async function startGuestLogin(): Promise { + const clientId = await getClientId(); const verifier = randomVerifier(); sessionStorage.setItem(VERIFIER_KEY, verifier); const challenge = base64url(await sha256(verifier)); const url = new URL('https://accounts.spotify.com/authorize'); url.search = new URLSearchParams({ - client_id: CLIENT_ID, + client_id: clientId, response_type: 'code', redirect_uri: REDIRECT_URI, code_challenge_method: 'S256', @@ -52,6 +68,7 @@ export async function startGuestLogin(): Promise { export type GuestToken = { access_token: string; expires_at: number }; export async function completeGuestLogin(code: string): Promise { + const clientId = await getClientId(); const verifier = sessionStorage.getItem(VERIFIER_KEY); if (!verifier) throw new Error('missing PKCE verifier — start login again'); @@ -59,7 +76,7 @@ export async function completeGuestLogin(code: string): Promise { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ - client_id: CLIENT_ID, + client_id: clientId, grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, diff --git a/server/index.js b/server/index.js index bc87e56..7740726 100644 --- a/server/index.js +++ b/server/index.js @@ -12,6 +12,15 @@ app.use(express.json()); app.get('/api/health', (req, res) => res.json({ ok: true })); +// Spotify's client id is not sensitive (it's public in every authorize +// URL, visible in any browser network tab) — served here instead of +// baked into the client at build time so the whole app has exactly one +// place to configure it (this server's own .env), not a second copy as +// a separate CI build-arg/secret. +app.get('/api/config', (req, res) => { + res.json({ clientId: process.env.SPOTIFY_CLIENT_ID }); +}); + app.use('/api/spotify', hostAuth); app.use('/api/host', hostPlaylists); app.use('/api/public-playlist', publicPlaylist);