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",