From 326f613ffeb58007b5b4df69a92712b50889e280 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 8 Sep 2026 13:16:19 +0200 Subject: [PATCH] Delete the global GC_ACCESS_APP_SHARED_SECRET fallback (R6, fork 2) Per-app identity with a global fallback made repixelizer and streampixels one identity in practice. Each app now needs its own GC_ACCESS_APP__SHARED_SECRET; operator mints distinct values on Yggdrasil in the same deploy window. Doc note in service-contract.md corrected to stop advertising the fallback. --- docs/service-contract.md | 4 ++-- src/config.ts | 2 +- tests/app.test.ts | 28 +++++++++++++++++++++++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/service-contract.md b/docs/service-contract.md index 2cffa2a..5b18fae 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -439,8 +439,8 @@ Purpose: Authentication: - requires `x-heimdall-app-secret` -- the configured secret comes from `GC_ACCESS_APP_{APP_SLUG}_SHARED_SECRET` or - the generic `GC_ACCESS_APP_SHARED_SECRET` +- the configured secret comes from `GC_ACCESS_APP_{APP_SLUG}_SHARED_SECRET`, + per app; there is no generic fallback secret shared across apps Request body: diff --git a/src/config.ts b/src/config.ts index 93addf9..23941d1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -212,7 +212,7 @@ export function loadConfig( appSlugs .map((appSlug) => { const envKey = `GC_ACCESS_APP_${appSlug.toUpperCase()}_SHARED_SECRET`; - return [appSlug, env[envKey] ?? env.GC_ACCESS_APP_SHARED_SECRET]; + return [appSlug, env[envKey]]; }) .filter(([, value]) => Boolean(value)) ) as Partial>; diff --git a/tests/app.test.ts b/tests/app.test.ts index 47196c6..834a3aa 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { type FastifyInstance } from "fastify"; import { buildApp } from "../src/app.js"; -import { type HeimdallConfig } from "../src/config.js"; +import { type HeimdallConfig, loadConfig } from "../src/config.js"; import { entitlementFacts, identityFacts } from "../src/facts.js"; import { createOAuthRuntimeRegistry, type OAuthProviderRuntime } from "../src/oauth.js"; import { verifyJwt } from "../src/signing.js"; @@ -1091,6 +1091,32 @@ describe("Heimdall service", () => { expect(credentialResponse.body).not.toContain("rotated-twitch-refresh-token"); }); + it("no longer honors a global shared secret across apps", async () => { + // A per-app identity model with a global fallback is a two-app shared + // secret in practice (R6, fork 2). GC_ACCESS_APP_SHARED_SECRET alone must + // produce no usable secret for any app. + const config = loadConfig({ GC_ACCESS_APP_SHARED_SECRET: "one-secret-for-everyone" }); + expect(config.appSharedSecrets).toEqual({}); + + const app = await buildApp({ + config: { + ...createTestConfig(), + appSharedSecrets: {}, + }, + }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/v1/apps/streampixels/managed-credentials/resolve", + headers: { "x-heimdall-app-secret": "one-secret-for-everyone" }, + payload: { accountId: "acct-1", provider: "twitch" }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual(expect.objectContaining({ error: "app_auth_required" })); + }); + it("accepts Twitch token scopes returned as an array", async () => { globalThis.fetch = async () => new Response(