From f0d40e80a5ead39546059310fa39d9a9e9691b3d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 2 Jun 2026 19:25:14 +0100 Subject: [PATCH] Allow configured Spotiverse portal callbacks --- .env.example | 1 + docs/app-profiles/spotiverse.md | 6 +++- src/app.ts | 21 +++++++++--- src/config.ts | 14 ++++++++ state/evidence.jsonl | 1 + tests/app.test.ts | 58 +++++++++++++++++++++++++++++++++ tests/security.test.ts | 1 + 7 files changed, 97 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 2ae1e9e..37dd5ac 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,4 @@ GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID= GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET= GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET= +GC_ACCESS_APP_SPOTIVERSE_BACKEND_CALLBACK_URLS= diff --git a/docs/app-profiles/spotiverse.md b/docs/app-profiles/spotiverse.md index 62ce4db..9a9276a 100644 --- a/docs/app-profiles/spotiverse.md +++ b/docs/app-profiles/spotiverse.md @@ -31,7 +31,11 @@ Heimdall needs: GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID=... GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET=... GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET=... +GC_ACCESS_APP_SPOTIVERSE_BACKEND_CALLBACK_URLS=https://your-spotiverse-portal.example/auth/heimdall/callback ``` Spotiverse needs only the Heimdall base URL, matching app secret, and local -callback/return URLs. +portal callback/return URLs. The callback URL Spotiverse sends in +`HEIMDALL_CALLBACK_URL` must exactly match one URL in Heimdall's +`GC_ACCESS_APP_SPOTIVERSE_BACKEND_CALLBACK_URLS` allowlist unless both services +are running on the same local host. diff --git a/src/app.ts b/src/app.ts index 40befdc..e7ee39c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -458,17 +458,23 @@ async function evaluateStoredEntitlements(options: { return [...new Set(facts)]; } -function acceptsBackendPolicy(appSlug: AppSlug, handoff: OAuthHandoff): boolean { +function acceptsBackendCallback(config: HeimdallConfig, appSlug: AppSlug, handoff: OAuthHandoff): boolean { if (handoff.kind !== "backend_callback") { return false; } if (appSlug === "repixelizer") { - return handoff.callbackUrl === "https://repixelizer.gamecult.org/api/auth/heimdall/callback"; + return ( + handoff.callbackUrl === "https://repixelizer.gamecult.org/api/auth/heimdall/callback" || + (config.appBackendCallbacks.repixelizer ?? []).includes(handoff.callbackUrl) + ); } if (appSlug === "spotiverse") { - return handoff.callbackUrl === "http://127.0.0.1:8796/auth/heimdall/callback"; + return ( + handoff.callbackUrl === "http://127.0.0.1:8796/auth/heimdall/callback" || + (config.appBackendCallbacks.spotiverse ?? []).includes(handoff.callbackUrl) + ); } return false; @@ -649,7 +655,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise>; + appBackendCallbacks: Partial>; storage: StorageConfig; providers: Record; } @@ -78,6 +79,10 @@ function readProviderConfig(env: NodeJS.ProcessEnv, provider: Provider): Provide return config; } +function readList(envValue: string | undefined): string[] { + return envValue?.split(",").map((entry) => entry.trim()).filter(Boolean) ?? []; +} + export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig { const host = env.HOST ?? "127.0.0.1"; const port = readInt(env.PORT, 4100); @@ -96,6 +101,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig }) .filter(([, value]) => Boolean(value)) ) as Partial>; + const appBackendCallbacks = Object.fromEntries( + appSlugs + .map((appSlug) => { + const envKey = `GC_ACCESS_APP_${appSlug.toUpperCase()}_BACKEND_CALLBACK_URLS`; + return [appSlug, readList(env[envKey])]; + }) + .filter(([, value]) => Array.isArray(value) && value.length > 0) + ) as Partial>; const config: HeimdallConfig = { serviceName: "heimdall", @@ -109,6 +122,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig completionTtlSeconds: readInt(env.GC_ACCESS_COMPLETION_TTL_SECONDS, 300), bootstrapSigningPrivateKeyOnMissing: readBoolean(env.GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP, false), appSharedSecrets, + appBackendCallbacks, storage: { backend: storageBackend, applySchemaOnStartup: readBoolean(env.GC_ACCESS_APPLY_SCHEMA_ON_STARTUP, true), diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 054ed37..b7e8318 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -24,3 +24,4 @@ {"ts": "2026-05-20T12:43:02+00:00", "type": "implementation", "status": "accepted", "note": "Heimdall now issues signed app refresh tokens, exposes /v1/apps/{appSlug}/sessions/refresh, refreshes Discord/Patreon provider tokens before Repixelizer entitlement re-evaluation, and reissues short-lived app access claims without restarting provider OAuth."} {"ts": "2026-05-20T17:56:35+00:00", "type": "deployment", "status": "ok", "note": "Deployed Heimdall refresh-token session support to Yggdrasil and verified heimdall.service active, health/JWKS/discovery checks passing, and /v1/apps/repixelizer/sessions/refresh live with validation."} {"ts": "2026-06-02T15:25:08+00:00", "type": "implementation", "status": "accepted", "note": "Spotiverse now consumes Spotify as a Heimdall-managed credential: Heimdall owns Spotify OAuth/token custody, and Spotiverse resolves current access via /v1/apps/spotiverse/managed-credentials/resolve without storing provider refresh tokens."} +{"ts": "2026-06-02T18:25:05+00:00", "type": "implementation", "status": "accepted", "note": "Public Heimdall can now hand Spotiverse OAuth results to a configured portal callback URL via GC_ACCESS_APP_SPOTIVERSE_BACKEND_CALLBACK_URLS; backend_callback handoffs are rejected unless the app callback is trusted, preserving token-bearing handoff boundaries."} diff --git a/tests/app.test.ts b/tests/app.test.ts index 950881f..fca3159 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -25,6 +25,9 @@ function createTestConfig(): HeimdallConfig { streampixels: "streampixels-secret", spotiverse: "spotiverse-secret", }, + appBackendCallbacks: { + spotiverse: ["https://spotiverse-portal.gamecult.org/auth/heimdall/callback"], + }, storage: { backend: "memory", applySchemaOnStartup: true, @@ -779,6 +782,61 @@ describe("Heimdall service", () => { expect(credentialResponse.body).not.toContain("spotify-refresh-token"); }); + it("accepts configured Spotiverse portal backend callbacks", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/spotify/start", + payload: { + appSlug: "spotiverse", + mode: "connect", + returnTo: "https://spotiverse-portal.gamecult.org/auth/complete", + handoff: { + kind: "backend_callback", + attemptId: "spotiverse-portal-attempt", + callbackUrl: "https://spotiverse-portal.gamecult.org/auth/heimdall/callback", + }, + }, + }); + + expect(response.statusCode).toBe(201); + expect(response.json()).toEqual( + expect.objectContaining({ + provider: "spotify", + appSlug: "spotiverse", + }) + ); + }); + + it("rejects unconfigured backend callback URLs", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/spotify/start", + payload: { + appSlug: "spotiverse", + mode: "connect", + returnTo: "https://spotiverse-portal.gamecult.org/auth/complete", + handoff: { + kind: "backend_callback", + attemptId: "spotiverse-bad-attempt", + callbackUrl: "https://not-spotiverse.example/auth/heimdall/callback", + }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual( + expect.objectContaining({ + error: "untrusted_backend_callback", + }) + ); + }); + it("resolves app-managed provider credentials without exposing refresh custody", async () => { const store = new InMemoryStore(); const app = await buildApp({ diff --git a/tests/security.test.ts b/tests/security.test.ts index df7c8be..154f3f0 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -19,6 +19,7 @@ function createTestConfig(overrides: Partial = {}): HeimdallConf completionTtlSeconds: 300, bootstrapSigningPrivateKeyOnMissing: false, appSharedSecrets: {}, + appBackendCallbacks: {}, storage: { backend: "memory", applySchemaOnStartup: true,