From e1ac84fb70708c3a3176709bf3621b9ee6757f13 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 14:34:24 +0200 Subject: [PATCH] Joystick custody needs a creator binding, at start and at the callback callbackMint now takes the connection: custody_only is a connect for a managed-connection-only provider bound to connection.kind creator with a targetId. Anything else is refused at start with creator_connection_required before any redirect, and at the callback from the signed state before the code exchange. The streampixels profile note says what Heimdall guarantees and records the S1 rule that SP's redeem treats a non-creator Joystick completion as an error, never a sign-in. Co-Authored-By: Claude Opus 5.5 --- docs/app-profiles/streampixels.md | 2 +- src/app-profiles.ts | 24 +++++++++--- src/app.ts | 12 +++++- tests/app.test.ts | 61 +++++++++++++++++++++++++++++-- tests/managed-credential.test.ts | 2 +- 5 files changed, 88 insertions(+), 13 deletions(-) diff --git a/docs/app-profiles/streampixels.md b/docs/app-profiles/streampixels.md index fe101eb..dbd91f1 100644 --- a/docs/app-profiles/streampixels.md +++ b/docs/app-profiles/streampixels.md @@ -42,7 +42,7 @@ In the service-first Heimdall version, the most likely reusable slice is: - provider OAuth authority - identity-linking authority -- managed Twitch/YouTube/Joystick credential custody for creator connections (Joystick is a managed connection only, not a viewer sign-in provider: `startOAuthFlow` admits `sign_in` and `link` only for `identityProviders` and `connect` only for `managedConnectionProviders` or `identityProviders`, and refuses anything else before any redirect. The callback decides again from the signed state's mode: a `connect` for a managed-connection-only provider stores the credential and mints a claim with no facts, no capabilities and no refresh token, so it can never be a viewer session) +- managed Twitch/YouTube/Joystick credential custody for creator connections (Joystick is a managed connection only, not a viewer sign-in provider: `startOAuthFlow` admits `sign_in` and `link` only for `identityProviders` and `connect` only for `managedConnectionProviders` or `identityProviders`, and refuses anything else before any redirect. The callback decides again from the signed state's mode: a `connect` for a managed-connection-only provider is admitted only with `connection.kind` `creator` and a `targetId`, refused otherwise at start and again at the callback before the code exchange; an admitted one stores the credential and mints a claim with no facts, no capabilities and no refresh token. Heimdall's claim is therefore never a viewer session. Heimdall cannot stop an app from minting its own viewer from a completion, so StreamPixels' redeem must treat a non-creator Joystick completion as an error, never as a sign-in. This is an S1 rule, recorded for SP) - entitlement refresh - shared claim semantics diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 621f3cb..3489a48 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -3,7 +3,7 @@ import { type CapabilityDefinition, type CapabilityMode, } from "./capability-rules.js"; -import { type AppSlug, type LinkedIdentityInput, type OAuthEntitlementPolicy, type OAuthMode, type Provider } from "./contracts.js"; +import { type AppSlug, type LinkedIdentityInput, type OAuthConnectionBinding, type OAuthEntitlementPolicy, type OAuthMode, type Provider } from "./contracts.js"; import { entitlementFacts, grantFacts, identityFacts } from "./facts.js"; export type { CapabilityDefinition, CapabilityMode }; @@ -257,15 +257,27 @@ export function isAllowedReturnOrigin(profile: AppProfile, returnTo: string): bo * is only an early refusal). `viewer_session`: the provider is one of the * profile's `identityProviders`, so the completion signs the account in with * the profile's capabilities. `custody_only`: a `connect` for a provider that - * is only a managed connection (Joystick on StreamPixels) stores the creator - * credential and mints a claim with no capabilities and no refresh token. - * `refused`: any other combination, which the start gate never admits. + * is only a managed connection (Joystick on StreamPixels), bound to a creator + * target, stores the creator credential and mints a claim with no capabilities + * and no refresh token. Without a creator binding it is `refused`, as is any + * other combination. The start gate asks this same question, so an unbound + * connect never reaches a redirect. */ export type CallbackMint = "viewer_session" | "custody_only" | "refused"; -export function callbackMint(profile: AppProfile, provider: Provider, mode: OAuthMode): CallbackMint { +export function callbackMint( + profile: AppProfile, + provider: Provider, + mode: OAuthMode, + connection: OAuthConnectionBinding | null | undefined +): CallbackMint { if (profile.identityProviders.includes(provider)) { return "viewer_session"; } - return mode === "connect" && profile.managedConnectionProviders.includes(provider) ? "custody_only" : "refused"; + return mode === "connect" && + profile.managedConnectionProviders.includes(provider) && + connection?.kind === "creator" && + connection.targetId !== "" + ? "custody_only" + : "refused"; } diff --git a/src/app.ts b/src/app.ts index a60e2d6..40ceb20 100644 --- a/src/app.ts +++ b/src/app.ts @@ -501,6 +501,16 @@ export async function startOAuthFlow( }; } + if (callbackMint(profile, provider, input.mode, input.connection) === "refused") { + return { + statusCode: 400, + body: { + error: "creator_connection_required", + detail: `${provider} on ${profile.slug} is a managed creator connection: connect needs connection.kind creator and a targetId.`, + }, + }; + } + if (!isAllowedReturnOrigin(profile, input.returnTo)) { return { statusCode: 400, @@ -1009,7 +1019,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { }); describe("joystick connect", () => { - async function connect(app: FastifyInstance) { + const creator = { kind: "creator", targetId: "creator-1" }; + async function connect(app: FastifyInstance, extra: Record = { connection: creator }) { const start = await app.inject({ method: "POST", url: "/v1/oauth/joystick/start", - payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect" }, + payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", ...extra }, }); return start; } + it.each([ + ["no connection", {}], + ["an account connection", { connection: { kind: "account", targetId: "victim-1" } }], + ["an empty targetId", { connection: { kind: "creator", targetId: "" } }], + ])("refuses a joystick connect with %s at start, before any redirect", async (_name, extra) => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + const start = await connect(app, extra); + expect(start.statusCode).toBe(400); + expect(start.json().authorizationUrl).toBeUndefined(); + expect(start.json().stateToken).toBeUndefined(); + }); + + it.each([ + ["no connection", null], + ["an account connection", { kind: "account", targetId: "victim-1" }], + ["an empty targetId", { kind: "creator", targetId: "" }], + ])("the callback refuses a signed joystick connect with %s before the code exchange, and nothing is written", async (_name, connection) => { + const dir = mkdtempSync(join(tmpdir(), "heimdall-forge-")); + try { + const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true }; + const store = new InMemoryStore(); + const app = await buildApp({ config, store }); + apps.push(app); + const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); + const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); + const stateToken = (await connect(app)).json().stateToken as string; + const verified = verifyJwt(stateToken, keys.publicKey); + expect(verified.valid).toBe(true); + const forged = signJwt({ ...(verified as { payload: Record }).payload, connection }, keys); + + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, + }); + expect(callback.statusCode).toBeGreaterThanOrEqual(400); + expect(calls).toEqual([]); + expect(await store.findStoredLinkedIdentity("joystick", "chan-forged")).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("starts a streampixels connect at joystick.tv with the five scopes and the callback redirect", async () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); @@ -2096,10 +2140,19 @@ describe("callbackMint", () => { ["discord", "connect", "refused"], ["discord", "sign_in", "refused"], ] as const)("%s %s on streampixels is %s", (provider, mode, expected) => { - expect(callbackMint(streampixels, provider, mode)).toBe(expected); + expect(callbackMint(streampixels, provider, mode, { kind: "creator", targetId: "creator-1" })).toBe(expected); + }); + + it.each([ + ["null", null], + ["undefined", undefined], + ["an account binding", { kind: "account", targetId: "a-1" }], + ["an empty creator targetId", { kind: "creator", targetId: "" }], + ] as const)("a joystick connect with %s is refused", (_name, connection) => { + expect(callbackMint(streampixels, "joystick", "connect", connection)).toBe("refused"); }); it("keeps Bifrost's discord connect a viewer session", () => { - expect(callbackMint(builtInAppProfiles.bifrost!, "discord", "connect")).toBe("viewer_session"); + expect(callbackMint(builtInAppProfiles.bifrost!, "discord", "connect", null)).toBe("viewer_session"); }); }); diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index da7f991..4cd7396 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -562,7 +562,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag async function flow(rig: Rig, provider: "joystick" | "twitch" | "youtube", mode: "connect" | "sign_in") { const app = rig.apps[0]!; - const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo } }); + const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo, ...(mode === "connect" ? { connection: { kind: "creator", targetId: "creator-1" } } : {}) } }); expect(start.statusCode).toBe(201); const callback = await app.inject({ method: "GET",