Serve Spotify client ID from /api/config instead of baking it in at build
All checks were successful
Docker / build-and-push (push) Successful in 46s
All checks were successful
Docker / build-and-push (push) Successful in 46s
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).
This commit is contained in:
@@ -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
|
IMAGE=repo.explewd.com/explewd/fullhouse
|
||||||
HOST_PORT=3080
|
HOST_PORT=3080
|
||||||
|
|
||||||
@@ -7,6 +6,11 @@ HOST_PORT=3080
|
|||||||
# exactly (Spotify does exact string matching, trailing slash included),
|
# exactly (Spotify does exact string matching, trailing slash included),
|
||||||
# plus a separate one for the guest PKCE flow: <your domain>/ (root,
|
# plus a separate one for the guest PKCE flow: <your domain>/ (root,
|
||||||
# no path — see client/src/lib/pkce.ts).
|
# 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_ID=
|
||||||
SPOTIFY_CLIENT_SECRET=
|
SPOTIFY_CLIENT_SECRET=
|
||||||
SPOTIFY_HOST_REDIRECT_URI=https://fullhouse.yourdomain.com/api/spotify/callback
|
SPOTIFY_HOST_REDIRECT_URI=https://fullhouse.yourdomain.com/api/spotify/callback
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
push: true
|
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: |
|
tags: |
|
||||||
${{ steps.meta.outputs.image }}:latest
|
${{ steps.meta.outputs.image }}:latest
|
||||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}
|
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -6,13 +6,13 @@ COPY client/package.json client/package-lock.json* ./
|
|||||||
RUN npm install
|
RUN npm install
|
||||||
COPY client/ .
|
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
|
# Deliberately empty: relies on nginx same-origin-proxying /api to the
|
||||||
# api service (see nginx.conf) instead of an absolute cross-origin URL
|
# 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=
|
ENV VITE_API_BASE=
|
||||||
|
|
||||||
RUN npx tsc && npx vite build
|
RUN npx tsc && npx vite build
|
||||||
|
|||||||
@@ -3,7 +3,22 @@
|
|||||||
// involvement, tokens never leave the browser. Same flow shape as the
|
// involvement, tokens never leave the browser. Same flow shape as the
|
||||||
// original bingo-bango (https://github.com/f8al/bingo-bango, MIT) uses
|
// original bingo-bango (https://github.com/f8al/bingo-bango, MIT) uses
|
||||||
// for its only mode; this is a fresh implementation, not copied code.
|
// 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<string> {
|
||||||
|
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
|
// Root URL, not a dedicated path — a single static index.html handles
|
||||||
// the callback via a query param (?code=...) instead of needing a
|
// the callback via a query param (?code=...) instead of needing a
|
||||||
// second HTML entry point or server-side rewrite rules for a nested
|
// second HTML entry point or server-side rewrite rules for a nested
|
||||||
@@ -33,13 +48,14 @@ function randomVerifier(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startGuestLogin(): Promise<void> {
|
export async function startGuestLogin(): Promise<void> {
|
||||||
|
const clientId = await getClientId();
|
||||||
const verifier = randomVerifier();
|
const verifier = randomVerifier();
|
||||||
sessionStorage.setItem(VERIFIER_KEY, verifier);
|
sessionStorage.setItem(VERIFIER_KEY, verifier);
|
||||||
const challenge = base64url(await sha256(verifier));
|
const challenge = base64url(await sha256(verifier));
|
||||||
|
|
||||||
const url = new URL('https://accounts.spotify.com/authorize');
|
const url = new URL('https://accounts.spotify.com/authorize');
|
||||||
url.search = new URLSearchParams({
|
url.search = new URLSearchParams({
|
||||||
client_id: CLIENT_ID,
|
client_id: clientId,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
redirect_uri: REDIRECT_URI,
|
redirect_uri: REDIRECT_URI,
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
@@ -52,6 +68,7 @@ export async function startGuestLogin(): Promise<void> {
|
|||||||
export type GuestToken = { access_token: string; expires_at: number };
|
export type GuestToken = { access_token: string; expires_at: number };
|
||||||
|
|
||||||
export async function completeGuestLogin(code: string): Promise<GuestToken> {
|
export async function completeGuestLogin(code: string): Promise<GuestToken> {
|
||||||
|
const clientId = await getClientId();
|
||||||
const verifier = sessionStorage.getItem(VERIFIER_KEY);
|
const verifier = sessionStorage.getItem(VERIFIER_KEY);
|
||||||
if (!verifier) throw new Error('missing PKCE verifier — start login again');
|
if (!verifier) throw new Error('missing PKCE verifier — start login again');
|
||||||
|
|
||||||
@@ -59,7 +76,7 @@ export async function completeGuestLogin(code: string): Promise<GuestToken> {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
client_id: CLIENT_ID,
|
client_id: clientId,
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
redirect_uri: REDIRECT_URI,
|
redirect_uri: REDIRECT_URI,
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ app.use(express.json());
|
|||||||
|
|
||||||
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
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/spotify', hostAuth);
|
||||||
app.use('/api/host', hostPlaylists);
|
app.use('/api/host', hostPlaylists);
|
||||||
app.use('/api/public-playlist', publicPlaylist);
|
app.use('/api/public-playlist', publicPlaylist);
|
||||||
|
|||||||
Reference in New Issue
Block a user