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_<SLUG>_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.
This commit is contained in:
Meta Vens 2026-09-08 13:16:19 +02:00
commit 326f613ffe
3 changed files with 30 additions and 4 deletions

View file

@ -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:

View file

@ -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<Record<AppSlug, string>>;

View file

@ -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(