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
|
- provider OAuth authority
|
||||||
- identity-linking 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
|
- entitlement refresh
|
||||||
- shared claim semantics
|
- shared claim semantics
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -250,3 +250,22 @@ export function isAllowedReturnOrigin(profile: AppProfile, returnTo: string): bo
|
||||||
return false;
|
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,
|
type Provider,
|
||||||
} from "./contracts.js";
|
} from "./contracts.js";
|
||||||
import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.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 { renderBrowserHandoffPage } from "./browser-handoff.js";
|
||||||
import { type HeimdallConfig, loadConfig } from "./config.js";
|
import { type HeimdallConfig, loadConfig } from "./config.js";
|
||||||
import { createTokenCustody, type TokenCustody } from "./custody.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];
|
const runtime = oauthRuntimes[request.params.provider];
|
||||||
|
|
||||||
try {
|
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({
|
const tokenSet = await runtime.exchangeAuthorizationCode({
|
||||||
config,
|
config,
|
||||||
code: request.query.code,
|
code: request.query.code,
|
||||||
|
|
@ -1093,6 +1098,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
|
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
|
||||||
facts: [...entitlementEvaluation.facts, ...buildGrantFacts(grants)],
|
facts: [...entitlementEvaluation.facts, ...buildGrantFacts(grants)],
|
||||||
|
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
|
||||||
};
|
};
|
||||||
if (account.displayName !== undefined) {
|
if (account.displayName !== undefined) {
|
||||||
issueInput.displayName = account.displayName;
|
issueInput.displayName = account.displayName;
|
||||||
|
|
@ -1104,6 +1110,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||||
store,
|
store,
|
||||||
input: issueInput,
|
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({
|
await store.createAuditEvent({
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
|
|
@ -1143,7 +1157,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||||
entitlements: entitlementEvaluation,
|
entitlements: entitlementEvaluation,
|
||||||
returnTo: statePayload.return_to,
|
returnTo: statePayload.return_to,
|
||||||
connection: statePayload.connection,
|
connection: statePayload.connection,
|
||||||
...issued,
|
...delivered,
|
||||||
};
|
};
|
||||||
if (handoff.kind === "backend_callback") {
|
if (handoff.kind === "backend_callback") {
|
||||||
await maybeDeliverBackendHandoff(handoff, {
|
await maybeDeliverBackendHandoff(handoff, {
|
||||||
|
|
@ -1159,7 +1173,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||||
connection: statePayload.connection,
|
connection: statePayload.connection,
|
||||||
account: accountSummary,
|
account: accountSummary,
|
||||||
entitlements: entitlementEvaluation,
|
entitlements: entitlementEvaluation,
|
||||||
...issued,
|
...delivered,
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.createAuditEvent({
|
await store.createAuditEvent({
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,13 @@ interface BackendHandoffBase {
|
||||||
connection: OAuthConnectionBinding | null;
|
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";
|
status: "success";
|
||||||
mode: OAuthMode;
|
mode: OAuthMode;
|
||||||
account: AccountSummary;
|
account: AccountSummary;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ export interface IssueAccessClaimInput {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
linkedIdentities: LinkedIdentityInput[];
|
linkedIdentities: LinkedIdentityInput[];
|
||||||
facts: string[];
|
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;
|
sessionId?: string;
|
||||||
accessRevision?: number;
|
accessRevision?: number;
|
||||||
ttlSeconds?: number;
|
ttlSeconds?: number;
|
||||||
|
|
@ -80,13 +86,17 @@ export async function issueAccessClaim(options: {
|
||||||
// would name an app no verifier can resolve a profile for.
|
// 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}'.`);
|
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 withoutCapabilities = options.input.withoutCapabilities === true;
|
||||||
const factSet = new Set(facts);
|
const facts = withoutCapabilities
|
||||||
const sharedCapabilities = evaluateSharedCapabilities(profile, {
|
? []
|
||||||
accountId: options.input.accountId,
|
: normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts);
|
||||||
facts: factSet,
|
const sharedCapabilities = withoutCapabilities
|
||||||
identities: options.input.linkedIdentities,
|
? []
|
||||||
});
|
: evaluateSharedCapabilities(profile, {
|
||||||
|
accountId: options.input.accountId,
|
||||||
|
facts: new Set(facts),
|
||||||
|
identities: options.input.linkedIdentities,
|
||||||
|
});
|
||||||
|
|
||||||
const issuedAt = nowEpochSeconds();
|
const issuedAt = nowEpochSeconds();
|
||||||
const ttlSeconds = clampTtlSeconds(
|
const ttlSeconds = clampTtlSeconds(
|
||||||
|
|
@ -176,6 +186,8 @@ export async function issueAccessClaim(options: {
|
||||||
kid: options.keys.kid,
|
kid: options.keys.kid,
|
||||||
},
|
},
|
||||||
sharedCapabilities,
|
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 { afterEach, describe, expect, it } from "vitest";
|
||||||
import { type FastifyInstance } from "fastify";
|
import { type FastifyInstance } from "fastify";
|
||||||
import { buildApp, errorIdentity } from "../src/app.js";
|
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 { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js";
|
||||||
import { type HeimdallConfig, loadConfig } from "../src/config.js";
|
import { type HeimdallConfig, loadConfig } from "../src/config.js";
|
||||||
import { entitlementFacts, identityFacts } from "../src/facts.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", () => {
|
describe("updateLinkedIdentityCredential", () => {
|
||||||
it("returns null for a missing id and does not call decide", async () => {
|
it("returns null for a missing id and does not call decide", async () => {
|
||||||
const rig = await makeRig("twitch");
|
const rig = await makeRig("twitch");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue