OAuth callback: mode and provider role decide what a completion mints

The start gate only refused early. The callback ignored the signed state's
mode, so a Joystick connect for StreamPixels redeemed into a viewer_access
session. callbackMint(profile, provider, mode) now decides at the callback:
identity providers keep a viewer session (Bifrost discord connect unchanged);
a connect for a managed-connection-only provider stores custody and mints a
claim with no facts, no capabilities and no refresh token (the refresh route
would recompute viewer_access from the account); anything else is refused.
StreamPixels' redeem reads only account, provider, mode, connection and
claimSet.identities, so it accepts the capability-free claim unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-19 13:23:53 +02:00
commit 79ea0007d3
7 changed files with 164 additions and 14 deletions

View file

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

View file

@ -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";
}

View file

@ -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<FastifyIn
const runtime = oauthRuntimes[request.params.provider];
try {
const callbackProfile = getAppProfile(statePayload.app_slug);
const mint = callbackProfile ? callbackMint(callbackProfile, request.params.provider, statePayload.mode) : "refused";
if (mint === "refused") {
throw new Error("The signed state names a mode the provider has no role for in this app.");
}
const tokenSet = await runtime.exchangeAuthorizationCode({
config,
code: request.query.code,
@ -1093,6 +1098,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
accountId: account.id,
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
facts: [...entitlementEvaluation.facts, ...buildGrantFacts(grants)],
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
};
if (account.displayName !== undefined) {
issueInput.displayName = account.displayName;
@ -1104,6 +1110,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
store,
input: issueInput,
});
// A custody-only completion carries no refresh token: the refresh
// route recomputes capabilities from the account's facts, so one
// would turn the capability-free session into a viewer session.
const { refreshToken: issuedRefreshToken, refresh: issuedRefresh, ...issuedWithoutRefresh } = issued;
const delivered =
mint === "custody_only"
? issuedWithoutRefresh
: { ...issuedWithoutRefresh, refreshToken: issuedRefreshToken, refresh: issuedRefresh };
await store.createAuditEvent({
accountId: account.id,
@ -1143,7 +1157,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
entitlements: entitlementEvaluation,
returnTo: statePayload.return_to,
connection: statePayload.connection,
...issued,
...delivered,
};
if (handoff.kind === "backend_callback") {
await maybeDeliverBackendHandoff(handoff, {
@ -1159,7 +1173,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
connection: statePayload.connection,
account: accountSummary,
entitlements: entitlementEvaluation,
...issued,
...delivered,
});
await store.createAuditEvent({

View file

@ -19,7 +19,13 @@ interface BackendHandoffBase {
connection: OAuthConnectionBinding | null;
}
export interface BackendHandoffSuccessPayload extends BackendHandoffBase, IssuedAccessClaimResult {
/** A custody-only completion (callbackMint) carries no refresh token. */
type RefreshFields = "refreshToken" | "refresh";
export interface BackendHandoffSuccessPayload
extends BackendHandoffBase,
Omit<IssuedAccessClaimResult, RefreshFields>,
Partial<Pick<IssuedAccessClaimResult, RefreshFields>> {
status: "success";
mode: OAuthMode;
account: AccountSummary;

View file

@ -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"),
};
}

View file

@ -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");
});
});

View file

@ -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<string, string>) {
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<Record<string, any>>) {
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<Record<string, any>>) {
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");