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 <noreply@anthropic.com>
This commit is contained in:
parent
26f264877a
commit
e1ac84fb70
5 changed files with 88 additions and 13 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
12
src/app.ts
12
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<FastifyIn
|
|||
|
||||
try {
|
||||
const callbackProfile = getAppProfile(statePayload.app_slug);
|
||||
const mint = callbackProfile ? callbackMint(callbackProfile, request.params.provider, statePayload.mode) : "refused";
|
||||
const mint = callbackProfile ? callbackMint(callbackProfile, request.params.provider, statePayload.mode, statePayload.connection) : "refused";
|
||||
if (mint === "refused") {
|
||||
throw new Error("The signed state names a mode the provider has no role for in this app.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1356,15 +1356,59 @@ describe("Heimdall service", () => {
|
|||
});
|
||||
|
||||
describe("joystick connect", () => {
|
||||
async function connect(app: FastifyInstance) {
|
||||
const creator = { kind: "creator", targetId: "creator-1" };
|
||||
async function connect(app: FastifyInstance, extra: Record<string, unknown> = { 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<string, unknown> }).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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue