diff --git a/docs/app-profiles/streampixels.md b/docs/app-profiles/streampixels.md index cf7b602..fe101eb 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) +- 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) - entitlement refresh - shared claim semantics diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 128b27a..621f3cb 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -250,3 +250,22 @@ export function isAllowedReturnOrigin(profile: AppProfile, returnTo: string): bo return false; } } + +/** + * What a completed OAuth callback may mint, decided at the callback from the + * signed state's mode and the provider's role in the profile (the start gate + * 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. + */ +export type CallbackMint = "viewer_session" | "custody_only" | "refused"; + +export function callbackMint(profile: AppProfile, provider: Provider, mode: OAuthMode): CallbackMint { + if (profile.identityProviders.includes(provider)) { + return "viewer_session"; + } + return mode === "connect" && profile.managedConnectionProviders.includes(provider) ? "custody_only" : "refused"; +} diff --git a/src/app.ts b/src/app.ts index 0a7dd60..a60e2d6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -23,7 +23,7 @@ import { type Provider, } from "./contracts.js"; import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js"; -import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode } from "./app-profiles.js"; +import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint } from "./app-profiles.js"; import { renderBrowserHandoffPage } from "./browser-handoff.js"; import { type HeimdallConfig, loadConfig } from "./config.js"; import { createTokenCustody, type TokenCustody } from "./custody.js"; @@ -1008,6 +1008,11 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, + Partial> { status: "success"; mode: OAuthMode; account: AccountSummary; diff --git a/src/claims.ts b/src/claims.ts index 11c42ea..b762cbd 100644 --- a/src/claims.ts +++ b/src/claims.ts @@ -39,6 +39,12 @@ export interface IssueAccessClaimInput { displayName?: string; linkedIdentities: LinkedIdentityInput[]; facts: string[]; + /** + * The claim names the account and its identities and carries no facts and + * no capabilities (a managed-credential connect for a provider that is not + * an identity provider). The facts are not even `identity.authenticated`. + */ + withoutCapabilities?: boolean; sessionId?: string; accessRevision?: number; ttlSeconds?: number; @@ -80,13 +86,17 @@ export async function issueAccessClaim(options: { // would name an app no verifier can resolve a profile for. throw new Error(`Cannot issue an access claim for unknown app '${options.input.appSlug}'.`); } - const facts = normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts); - const factSet = new Set(facts); - const sharedCapabilities = evaluateSharedCapabilities(profile, { - accountId: options.input.accountId, - facts: factSet, - identities: options.input.linkedIdentities, - }); + const withoutCapabilities = options.input.withoutCapabilities === true; + const facts = withoutCapabilities + ? [] + : normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts); + const sharedCapabilities = withoutCapabilities + ? [] + : evaluateSharedCapabilities(profile, { + accountId: options.input.accountId, + facts: new Set(facts), + identities: options.input.linkedIdentities, + }); const issuedAt = nowEpochSeconds(); const ttlSeconds = clampTtlSeconds( @@ -176,6 +186,8 @@ export async function issueAccessClaim(options: { kid: options.keys.kid, }, sharedCapabilities, - hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"), + hybridCapabilities: withoutCapabilities + ? [] + : profile.capabilities.filter((capability) => capability.mode === "hybrid"), }; } diff --git a/tests/app.test.ts b/tests/app.test.ts index 70ed754..b086853 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -2,7 +2,7 @@ import { inspect } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { type FastifyInstance } from "fastify"; import { buildApp, errorIdentity } from "../src/app.js"; -import { builtInAppProfiles } from "../src/app-profiles.js"; +import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js"; import { type HeimdallConfig, loadConfig } from "../src/config.js"; import { entitlementFacts, identityFacts } from "../src/facts.js"; @@ -2048,3 +2048,24 @@ describe("OAuth start admits a provider by the profile's role for the mode", () } }); }); + +describe("callbackMint", () => { + const streampixels = builtInAppProfiles.streampixels!; + it.each([ + ["twitch", "sign_in", "viewer_session"], + ["twitch", "link", "viewer_session"], + ["youtube", "sign_in", "viewer_session"], + ["twitch", "connect", "viewer_session"], + ["joystick", "connect", "custody_only"], + ["joystick", "sign_in", "refused"], + ["joystick", "link", "refused"], + ["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); + }); + + it("keeps Bifrost's discord connect a viewer session", () => { + expect(callbackMint(builtInAppProfiles.bifrost!, "discord", "connect")).toBe("viewer_session"); + }); +}); diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 36cc187..8a0d4d2 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -474,6 +474,84 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag }); }); + describe("what the callback mints is decided by mode and provider role", () => { + const returnTo = "https://streampixels.gamecult.org/auth/connect"; + + function stubProviders(identity: Record) { + globalThis.fetch = (async (input: string | URL) => { + const url = String(input); + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); + if (url.startsWith("https://api.joystick.tv/api/oauth/token") || Object.values(providerCatalog).some((d) => d.tokenEndpoint === url)) { + return json({ access_token: "tok-access", refresh_token: "tok-refresh-CANARY", token_type: "Bearer", expires_in: 3600 }); + } + if (url === "https://api.joystick.tv/api/v1/me/identity") return json({ channel_id: identity.joystick, username: "deru" }); + if (url === "https://api.twitch.tv/helix/users") return json({ data: [{ id: identity.twitch, login: "deru", display_name: "Deru" }] }); + if (url.startsWith("https://www.googleapis.com/youtube/v3/channels")) { + return json({ items: [{ id: identity.youtube, snippet: { title: "Deru" } }] }); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + } + + 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 } }); + expect(start.statusCode).toBe(201); + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, + }); + expect(callback.statusCode).toBe(201); + const redeem = await app.inject({ + method: "POST", + url: "/v1/apps/streampixels/auth-completions/redeem", + payload: { completionCode: callback.json().completion.code as string }, + }); + expect(redeem.statusCode).toBe(201); + return { callback, redeem }; + } + + it("a joystick connect on streampixels stores custody and yields no viewer_access, no capability and no refresh token", timeout, async () => { + const rig = await makeRig("joystick"); + stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" }); + const { callback, redeem } = await flow(rig, "joystick", "connect"); + + for (const payload of [callback.json(), redeem.json()] as Array>) { + expect(payload.mode).toBe("connect"); + expect(payload.claimSet.capabilities).toEqual([]); + expect(payload.claimSet.facts).toEqual([]); + expect(payload.sharedCapabilities).toEqual([]); + expect(payload.hybridCapabilities).toEqual([]); + expect(payload.claimSet.identities).toEqual([expect.objectContaining({ provider: "joystick", providerUserId: "channel-1" })]); + expect(payload.account.id).toBe(rig.accountId); + expect(payload.refreshToken).toBeUndefined(); + expect(payload.refresh).toBeUndefined(); + } + expect(callback.body + redeem.body).not.toContain("viewer_access"); + expect(callback.body + redeem.body).not.toContain("tok-refresh-CANARY"); + const row = await rig.row(); + expect(rig.custody.decrypt(row.accessTokenEncrypted!)).toBe("tok-access"); + expect(rig.custody.decrypt(row.refreshTokenEncrypted!)).toBe("tok-refresh-CANARY"); + }); + + it.each(["twitch", "youtube"] as const)("a %s sign_in on streampixels still yields viewer_access and a refresh token", timeout, async (provider) => { + const rig = await makeRig("joystick"); + stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" }); + const { callback, redeem } = await flow(rig, provider, "sign_in"); + for (const payload of [callback.json(), redeem.json()] as Array>) { + expect(payload.claimSet.capabilities).toContain("viewer_access"); + expect(payload.refreshToken).toEqual(expect.any(String)); + } + }); + + it("a twitch connect on streampixels keeps its current behaviour: an identity provider still signs the viewer in", timeout, async () => { + const rig = await makeRig("joystick"); + stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" }); + const { redeem } = await flow(rig, "twitch", "connect"); + expect(redeem.json().claimSet.capabilities).toContain("viewer_access"); + }); + }); + describe("updateLinkedIdentityCredential", () => { it("returns null for a missing id and does not call decide", async () => { const rig = await makeRig("twitch");