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:
parent
6bdaed2a9b
commit
79ea0007d3
7 changed files with 164 additions and 14 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)
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
20
src/app.ts
20
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<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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue