Heimdall/src/app.ts
Heimdall 84c54fbb5e Refuse a connection target on sign_in start
Connection targets ride only on connect and link starts, which carry
x-heimdall-app-secret. A sign_in start is open to any browser; with a
connection it now returns 400 connection_not_allowed_in_sign_in.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 13:30:22 +00:00

1868 lines
67 KiB
TypeScript

import { createHmac, randomUUID } from "node:crypto";
import cors from "@fastify/cors";
import Fastify, { type FastifyError, type FastifyInstance } from "fastify";
import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js";
import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js";
import {
appSlugs,
connectionKinds,
oauthHandoffKinds,
oauthModes,
providers,
isAppSlug,
type AppSlug,
type OAuthHandoff,
type OAuthEntitlementPolicy,
type OAuthMode,
type OAuthConnectionBinding,
type RedeemAuthCompletionRequest,
type RefreshSessionRequest,
type RefreshTokenPayload,
type OAuthStatePayload,
type OAuthStartRequest,
type Provider,
} from "./contracts.js";
import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.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";
import { grantFact } from "./facts.js";
import {
createOAuthRuntimeRegistry,
fetchPatreonIdentity,
summarizePatreonMemberships,
type OAuthRuntimeRegistry,
} from "./oauth.js";
import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js";
import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
import { createStore, type HeimdallStore } from "./store/index.js";
import {
type CreateAccountInput,
type StoredCapabilityGrant,
type StoredLinkedIdentity,
type UpsertLinkedIdentityInput,
} from "./store/types.js";
export interface BuildAppOptions {
config?: HeimdallConfig;
store?: HeimdallStore;
oauthRuntimes?: Partial<OAuthRuntimeRegistry>;
tokenCustody?: TokenCustody;
}
export interface HeimdallRuntimeContext {
config: HeimdallConfig;
keys: ReturnType<typeof createRuntimeKeyMaterial>;
store: HeimdallStore;
oauthRuntimes: OAuthRuntimeRegistry;
tokenCustody: TokenCustody;
}
export function getHeimdallRuntimeContext(app: FastifyInstance): HeimdallRuntimeContext {
return (app as FastifyInstance & { heimdallContext: HeimdallRuntimeContext }).heimdallContext;
}
function buildDiscovery(config: HeimdallConfig) {
return {
issuer: config.issuer,
jwksUri: `${config.publicBaseUrl}/.well-known/jwks.json`,
configurationUri: `${config.publicBaseUrl}/.well-known/heimdall-configuration`,
oauthStartEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/start`,
oauthCallbackEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/callback`,
supportedProviders: providers.map((provider) => ({
key: provider,
displayName: providerCatalog[provider].displayName,
roles: providerCatalog[provider].roles,
})),
apps: Object.values(builtInAppProfiles).map(serializeAppProfile),
};
}
function nowEpochSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function buildOAuthStateToken(options: {
config: HeimdallConfig;
provider: Provider;
appSlug: AppSlug;
mode: OAuthMode;
returnTo: string;
connection: OAuthConnectionBinding | undefined;
initiatorAccountId: string | undefined;
handoff: OAuthHandoff;
entitlementPolicy: OAuthEntitlementPolicy | undefined;
keys: ReturnType<typeof createRuntimeKeyMaterial>;
}): { token: string; expiresAt: string } {
const issuedAt = nowEpochSeconds();
const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds;
const payload: OAuthStatePayload = {
iss: options.config.issuer,
sub: options.appSlug,
aud: options.provider,
jti: randomUUID(),
iat: issuedAt,
nbf: issuedAt,
exp: expiresAtEpoch,
typ: "heimdall_oauth_state",
provider: options.provider,
app_slug: options.appSlug,
mode: options.mode,
return_to: options.returnTo,
connection: options.connection ?? null,
initiator_account_id: options.initiatorAccountId ?? null,
handoff: options.handoff,
entitlement_policy: options.entitlementPolicy ?? null,
};
const token = signJwt(payload as unknown as Record<string, unknown>, options.keys);
return {
token,
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
};
}
function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePayload | null {
const handoff = payload.handoff;
const handoffIsValid =
handoff === null ||
handoff === undefined ||
(typeof handoff === "object" &&
handoff !== null &&
(((handoff as Record<string, unknown>).kind === "browser_completion" &&
((handoff as Record<string, unknown>).attemptId === undefined ||
typeof (handoff as Record<string, unknown>).attemptId === "string")) ||
((handoff as Record<string, unknown>).kind === "backend_callback" &&
typeof (handoff as Record<string, unknown>).attemptId === "string" &&
typeof (handoff as Record<string, unknown>).callbackUrl === "string")));
const entitlementPolicy = payload.entitlement_policy;
const entitlementPolicyIsValid =
entitlementPolicy === null ||
entitlementPolicy === undefined ||
((typeof entitlementPolicy === "object" &&
entitlementPolicy !== null &&
(entitlementPolicy as Record<string, unknown>).kind === "discord_role_access" &&
typeof (entitlementPolicy as Record<string, unknown>).guildId === "string" &&
Array.isArray((entitlementPolicy as Record<string, unknown>).allowedRoleIds) &&
((entitlementPolicy as Record<string, unknown>).allowedRoleIds as unknown[]).every(
(roleId) => typeof roleId === "string"
)) ||
(typeof entitlementPolicy === "object" &&
entitlementPolicy !== null &&
(entitlementPolicy as Record<string, unknown>).kind === "patreon_membership_access" &&
typeof (entitlementPolicy as Record<string, unknown>).requiredTierTitle === "string"));
if (
payload.typ !== "heimdall_oauth_state" ||
typeof payload.provider !== "string" ||
!providers.includes(payload.provider as Provider) ||
typeof payload.app_slug !== "string" ||
!isAppSlug(payload.app_slug) ||
typeof payload.mode !== "string" ||
!oauthModes.includes(payload.mode as OAuthMode) ||
typeof payload.return_to !== "string" ||
typeof payload.iss !== "string" ||
typeof payload.sub !== "string" ||
typeof payload.aud !== "string" ||
typeof payload.jti !== "string" ||
typeof payload.iat !== "number" ||
typeof payload.nbf !== "number" ||
typeof payload.exp !== "number" ||
!handoffIsValid ||
!entitlementPolicyIsValid ||
!(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string")
) {
return null;
}
const normalizedPayload = payload as unknown as OAuthStatePayload;
if (!normalizedPayload.handoff) {
normalizedPayload.handoff = { kind: "browser_completion" };
}
if (!normalizedPayload.initiator_account_id) {
normalizedPayload.initiator_account_id = null;
}
if (!normalizedPayload.entitlement_policy) {
normalizedPayload.entitlement_policy = null;
}
return normalizedPayload;
}
function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] {
return grants.map((grant) => grantFact(grant.capability));
}
function parseRefreshTokenPayload(payload: Record<string, unknown>): RefreshTokenPayload | null {
if (
payload.typ !== "heimdall_refresh" ||
typeof payload.iss !== "string" ||
typeof payload.aud !== "string" ||
!isAppSlug(payload.aud) ||
typeof payload.sub !== "string" ||
typeof payload.sid !== "string" ||
typeof payload.jti !== "string" ||
typeof payload.iat !== "number" ||
typeof payload.nbf !== "number" ||
typeof payload.exp !== "number" ||
typeof payload.account_id !== "string" ||
typeof payload.access_revision !== "number" ||
!payload.app ||
typeof payload.app !== "object" ||
(payload.app as Record<string, unknown>).slug !== payload.aud ||
typeof (payload.app as Record<string, unknown>).profile_version !== "string"
) {
return null;
}
return payload as unknown as RefreshTokenPayload;
}
export function verifyRefreshToken(
token: string,
appSlug: AppSlug,
config: HeimdallConfig,
keys: ReturnType<typeof createRuntimeKeyMaterial>
): RefreshTokenPayload | null {
const verification = verifyJwt(token, keys.publicKey);
if (!verification.valid) {
return null;
}
const payload = parseRefreshTokenPayload(verification.payload);
if (!payload || payload.iss !== config.issuer || payload.aud !== appSlug || payload.app.slug !== appSlug) {
return null;
}
const now = nowEpochSeconds();
if (payload.nbf > now + 30 || payload.exp <= now - 30) {
return null;
}
return payload;
}
/**
* A provider's `error` and `error_description` as Heimdall relays them. They
* arrive as query parameters anyone can write, so only the characters RFC 6749
* (4.1.2.1) allows in them survive, and each is cut to a bound. The browser
* page escapes what is left for its own contexts.
*/
function relayedProviderText(value: string | undefined, bound: number): string | undefined {
const printable = value?.replace(/[^\x20-\x21\x23-\x5b\x5d-\x7e]/g, "").slice(0, bound);
return printable ? printable : undefined;
}
/**
* What may be recorded or answered about a caught error: its class and its
* code, each only when it is a short identifier. Never its message, which
* carries whatever the thrower quoted (a host, a database, a provider's body).
*/
/** A link callback the signed state's initiator does not license. Its message is static and safe to relay. */
class LinkRefusal extends Error {
constructor(
readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown",
message: string
) {
super(message);
}
}
export function errorIdentity(error: unknown): { errorClass: string; errorCode: string | null } {
const code = (error as { code?: unknown } | null)?.code;
return {
errorClass: error instanceof Error && /^[A-Za-z]{1,64}$/.test(error.name) ? error.name : "Error",
errorCode: typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code) ? code : null,
};
}
function prefersHtml(acceptHeader: string | undefined): boolean {
if (!acceptHeader) {
return false;
}
return acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml");
}
/**
* `browser_completion.attemptId` is never accepted from a request body. It
* exists only to correlate a completion with the attempt the private command
* plane's `beginAuth` created server-side (see `startOAuthFlow`'s
* `trustedBrowserAttemptId` parameter) — a handle a public caller could name
* here would let it complete someone else's pending attempt (R21.1). A public
* `browser_completion` caller has no attempt to correlate: it gets the
* completion code directly in the callback response or handoff page.
*/
function normalizeOAuthHandoff(value: OAuthStartRequest["handoff"]): OAuthHandoff {
if (!value) {
return { kind: "browser_completion" };
}
if (value.kind === "browser_completion") {
return { kind: "browser_completion" };
}
if (
value.kind === "backend_callback" &&
typeof value.attemptId === "string" &&
value.attemptId.trim() &&
typeof value.callbackUrl === "string" &&
value.callbackUrl.trim()
) {
return {
kind: "backend_callback",
attemptId: value.attemptId.trim(),
callbackUrl: value.callbackUrl.trim(),
};
}
throw new Error("Invalid OAuth handoff definition.");
}
function normalizeEntitlementPolicy(value: OAuthStartRequest["entitlementPolicy"]): OAuthEntitlementPolicy | undefined {
if (!value) {
return undefined;
}
if (
value.kind === "discord_role_access" &&
typeof value.guildId === "string" &&
value.guildId.trim() &&
Array.isArray(value.allowedRoleIds)
) {
const allowedRoleIds = value.allowedRoleIds.map((roleId) => roleId.trim()).filter(Boolean);
if (allowedRoleIds.length) {
return {
kind: "discord_role_access",
guildId: value.guildId.trim(),
allowedRoleIds,
};
}
}
if (
value.kind === "patreon_membership_access" &&
typeof value.requiredTierTitle === "string" &&
value.requiredTierTitle.trim()
) {
return {
kind: "patreon_membership_access",
requiredTierTitle: value.requiredTierTitle.trim(),
};
}
throw new Error("Invalid entitlement policy definition.");
}
function normalizeEntitlementPolicies(value: RefreshSessionRequest["entitlementPolicies"]): OAuthEntitlementPolicy[] {
if (!value) {
return [];
}
return value.map((entry) => {
const policy = normalizeEntitlementPolicy(entry);
if (!policy) {
throw new Error("Invalid entitlement policy definition.");
}
return policy;
});
}
function entitlementPolicyProvider(policy: OAuthEntitlementPolicy): Provider {
return policy.kind === "discord_role_access" ? "discord" : "patreon";
}
function signBifrostPatronSupportPayload(secret: string, body: string): string {
const signature = createHmac("sha256", secret).update(body).digest("hex");
return `sha256=${signature}`;
}
function centsToDecimalString(cents: number): string {
return (cents / 100).toFixed(2);
}
function storedIdentityToResolvedIdentity(identity: StoredLinkedIdentity) {
const resolved = {
provider: identity.provider,
providerUserId: identity.providerUserId,
profile: identity.profileJson,
};
if (identity.username !== undefined) {
Object.assign(resolved, { username: identity.username });
}
if (identity.displayName !== undefined) {
Object.assign(resolved, { displayName: identity.displayName });
}
if (identity.primaryEmail !== undefined) {
Object.assign(resolved, { primaryEmail: identity.primaryEmail });
}
return resolved;
}
async function evaluateStoredEntitlements(options: {
config: HeimdallConfig;
store: HeimdallStore;
tokenCustody: TokenCustody;
oauthRuntimes: OAuthRuntimeRegistry;
appSlug: AppSlug;
accountId: string;
entitlementPolicies: OAuthEntitlementPolicy[];
}): Promise<string[]> {
const profile = getAppProfile(options.appSlug);
if (!profile) {
throw new Error(`Unknown app '${options.appSlug}'.`);
}
const linkedIdentities = await options.store.listStoredLinkedIdentitiesForAccount(options.accountId);
const facts: string[] = [];
for (const storedIdentity of linkedIdentities) {
if (!profile.entitlementSources.includes(storedIdentity.provider)) {
continue;
}
const refreshed = await refreshManagedCredential(options, storedIdentity);
const linkedIdentity = refreshed.identity;
if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
continue;
}
const runtime = options.oauthRuntimes[linkedIdentity.provider];
const entitlementPolicy =
options.entitlementPolicies.find((policy) => entitlementPolicyProvider(policy) === linkedIdentity.provider) ?? null;
const tokenSet = {
accessToken: options.tokenCustody.decrypt(linkedIdentity.accessTokenEncrypted),
tokenType: "Bearer",
scope: linkedIdentity.scopes,
raw: {},
};
if (linkedIdentity.tokenExpiresAt !== undefined) {
Object.assign(tokenSet, { expiresAt: linkedIdentity.tokenExpiresAt });
}
const evaluation = await runtime.evaluateEntitlements({
config: options.config,
callback: {
appSlug: options.appSlug,
accountId: options.accountId,
connection: null,
entitlementPolicy,
},
identity: storedIdentityToResolvedIdentity(linkedIdentity),
tokenSet,
});
for (const snapshot of evaluation.snapshots) {
await options.store.upsertEntitlementSnapshot(snapshot);
}
facts.push(...evaluation.facts);
}
return [...new Set(facts)];
}
function acceptsBackendCallback(config: HeimdallConfig, appSlug: AppSlug, handoff: OAuthHandoff): boolean {
if (handoff.kind !== "backend_callback") {
return false;
}
return (config.appBackendCallbacks[appSlug] ?? []).includes(handoff.callbackUrl);
}
async function maybeDeliverBackendHandoff(handoff: OAuthHandoff, payload: BackendHandoffPayload): Promise<void> {
if (handoff.kind !== "backend_callback") {
return;
}
await deliverBackendHandoff(handoff.callbackUrl, payload);
}
export interface HandlerResult {
statusCode: number;
body: unknown;
}
/**
* OAuth start, extracted so the HTTP route and the private command plane's
* `beginAuth` reach the same authority through the same function instead of
* the plane forging an HTTP self-call with a header only it can set.
*/
export async function startOAuthFlow(
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
provider: Provider,
caller: AppCaller | null,
input: OAuthStartRequest,
options?: {
/**
* The attempt handle to bind into the state token's `browser_completion`
* handoff. Set only by `beginAuth` (private-command-plane.ts), which
* mints the handle itself via `store.createAuthAttempt` before calling
* here — it is a TypeScript function argument, never a value parsed out
* of `input`/the request body, so a public HTTP caller has no way to
* populate it (R21.1). `normalizeOAuthHandoff` already strips any
* body-supplied `attemptId` for this reason; this is the only path by
* which one reaches the signed state token.
*/
trustedBrowserAttemptId?: string;
}
): Promise<HandlerResult> {
const { config, keys } = ctx;
const profile = getAppProfile(input.appSlug);
if (!profile) {
return { statusCode: 404, body: { error: "unknown_app", detail: "No app is registered under that slug." } };
}
if (!admitsProviderForMode(profile, provider, input.mode)) {
return {
statusCode: 400,
body: { error: "provider_not_supported_for_app", provider, mode: input.mode, appSlug: profile.slug },
};
}
// A connection target is app-backend authority: it rides only on connect
// and link starts, which are the flows gated by x-heimdall-app-secret.
// A sign_in start is open to any browser and must not carry one.
if (input.mode === "sign_in" && input.connection !== undefined) {
return {
statusCode: 400,
body: { error: "connection_not_allowed_in_sign_in", detail: "sign_in start must not carry a connection target." },
};
}
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.`,
},
};
}
// Link mode binds an identity to an existing account, so the account must
// be named by someone Heimdall can believe: the authenticated app backend.
// The name rides in the signed state and the callback holds the provider's
// identity to it. No other mode carries an initiator.
if (input.mode === "link") {
if (!isAppCaller(caller) || caller.appSlug !== profile.slug) {
return { statusCode: 401, body: { error: "app_auth_required" } };
}
if (!input.initiatingAccountId) {
return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } };
}
if (!(await ctx.store.findAccountById(input.initiatingAccountId))) {
return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } };
}
} else if (input.initiatingAccountId !== undefined) {
return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } };
}
if (!isAllowedReturnOrigin(profile, input.returnTo)) {
return {
statusCode: 400,
body: { error: "return_to_not_allowed", detail: `returnTo must land on an origin ${profile.slug} owns.` },
};
}
let handoff: OAuthHandoff;
try {
handoff = normalizeOAuthHandoff(input.handoff);
} catch (error) {
return {
statusCode: 400,
body: {
error: "invalid_handoff",
detail: error instanceof Error ? error.message : "Invalid OAuth handoff definition.",
},
};
}
let entitlementPolicy: OAuthEntitlementPolicy | undefined;
try {
entitlementPolicy = normalizeEntitlementPolicy(input.entitlementPolicy);
} catch (error) {
return {
statusCode: 400,
body: {
error: "invalid_entitlement_policy",
detail: error instanceof Error ? error.message : "Invalid entitlement policy definition.",
},
};
}
if (handoff.kind === "backend_callback" && !acceptsBackendCallback(config, profile.slug, handoff)) {
return {
statusCode: 400,
body: {
error: "untrusted_backend_callback",
detail: "Backend callback handoffs are only accepted for configured app callback URLs.",
},
};
}
if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") {
handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId };
}
if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) {
return { statusCode: 401, body: { error: "app_auth_required" } };
}
const providerConfig = config.providers[provider];
if (!providerConfig.clientId) {
return {
statusCode: 503,
body: { error: "provider_not_configured", provider, expectedEnv: providerExpectedEnv(provider) },
};
}
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
const state = buildOAuthStateToken({
config,
provider,
appSlug: profile.slug,
mode: input.mode,
returnTo: input.returnTo,
connection: input.connection,
initiatorAccountId: input.initiatingAccountId,
handoff,
entitlementPolicy,
keys,
});
return {
statusCode: 201,
body: {
provider,
appSlug: profile.slug,
mode: input.mode,
callbackUrl,
authorizationUrl: buildAuthorizationUrl({
provider,
clientId: providerConfig.clientId,
redirectUri: callbackUrl,
state: state.token,
requestedScopes: input.requestedScopes,
}),
handoff,
stateToken: state.token,
stateExpiresAt: state.expiresAt,
},
};
}
/**
* Session refresh, extracted for the same reason as startOAuthFlow: the HTTP
* route and the plane's `refreshAuth` (which owns Ghostlight identity) call
* this directly with an envelope-derived AppCaller instead of the plane
* forging an HTTP self-call.
*/
export async function refreshAppSession(
ctx: HeimdallRuntimeContext,
appSlug: AppSlug,
caller: AppCaller | null,
input: RefreshSessionRequest
): Promise<HandlerResult> {
const { config, keys, store, tokenCustody, oauthRuntimes } = ctx;
let entitlementPolicies: OAuthEntitlementPolicy[];
try {
entitlementPolicies = normalizeEntitlementPolicies(input.entitlementPolicies);
const legacyPolicy = normalizeEntitlementPolicy(input.entitlementPolicy);
if (legacyPolicy) {
entitlementPolicies.push(legacyPolicy);
}
} catch (error) {
return {
statusCode: 400,
body: {
error: "invalid_entitlement_policy",
detail: error instanceof Error ? error.message : "Invalid entitlement policy definition.",
},
};
}
if (entitlementPolicies.length > 0 && (!isAppCaller(caller) || caller.appSlug !== appSlug)) {
return { statusCode: 401, body: { error: "app_auth_required" } };
}
const refreshClaim = verifyRefreshToken(input.refreshToken, appSlug, config, keys);
if (!refreshClaim) {
return { statusCode: 401, body: { error: "invalid_or_expired_refresh_token" } };
}
const nowIso = new Date().toISOString();
const persistedSession = await store.findSession(appSlug, refreshClaim.sid);
if (
!persistedSession ||
persistedSession.accountId !== refreshClaim.account_id ||
persistedSession.accessRevision !== refreshClaim.access_revision ||
Date.parse(persistedSession.expiresAt) <= Date.parse(nowIso)
) {
return { statusCode: 401, body: { error: "revoked_or_stale_refresh_token" } };
}
const account = await store.findAccountById(refreshClaim.account_id);
const linkedIdentities = await store.listLinkedIdentitiesForAccount(refreshClaim.account_id);
const grants = await store.listActiveGrants(refreshClaim.account_id, appSlug, nowIso);
const entitlementFactList = await evaluateStoredEntitlements({
config,
store,
tokenCustody,
oauthRuntimes,
appSlug,
accountId: refreshClaim.account_id,
entitlementPolicies,
});
const issueInput: IssueAccessClaimInput = {
appSlug,
accountId: refreshClaim.account_id,
sessionId: refreshClaim.sid,
accessRevision: refreshClaim.access_revision,
linkedIdentities,
facts: [...entitlementFactList, ...buildGrantFacts(grants)],
};
if (account?.displayName !== undefined) {
issueInput.displayName = account.displayName;
}
const issued = await issueAccessClaim({
config,
keys,
store,
input: issueInput,
});
await store.createAuditEvent({
accountId: refreshClaim.account_id,
sessionId: refreshClaim.sid,
appSlug,
eventType: "session_refreshed",
eventPayloadJson: {
grantedFacts: issued.claimSet.facts,
sharedCapabilities: issued.sharedCapabilities,
},
createdAt: nowIso,
});
return { statusCode: 201, body: issued };
}
export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
const config = options.config ?? loadConfig();
const store = options.store ?? (await createStore(config));
const keys = createRuntimeKeyMaterial(config);
const tokenCustody = options.tokenCustody ?? createTokenCustody(config);
const oauthRuntimes: OAuthRuntimeRegistry = {
...createOAuthRuntimeRegistry(),
...(options.oauthRuntimes ?? {}),
};
const app = Fastify({ logger: false });
// Fastify's default handlers answer with the error's message, and messages
// carry input: pg and Node name the database host or the database (which a
// misbound URL fills with part of a secret), the default not-found answer
// quotes the path, and any error thrown with a 4xx status says whatever its
// thrower wrote. So every framework answer here is built from fixed text,
// the error code and the route's own schema, never from the message.
app.setErrorHandler((error: FastifyError, request, reply) => {
const status = error.statusCode !== undefined && error.statusCode >= 400 && error.statusCode < 600 ? error.statusCode : 500;
const code = typeof error.code === "string" ? error.code : "error";
reply.code(status);
if (status >= 500) {
console.error(`Heimdall ${request.method} ${request.routeOptions.url ?? "(no route)"} failed (${code}).`);
return { error: "internal_error" };
}
if (error.validation) {
// Ajv's messages and schema paths come from the route's schema, not the request.
const failures = error.validation.map((failure) => `${failure.schemaPath} ${failure.message ?? failure.keyword}`);
return { error: "invalid_request", detail: `${error.validationContext ?? "request"}: ${failures.join("; ")}` };
}
return { error: "invalid_request", code };
});
app.setNotFoundHandler((_request, reply) => {
reply.code(404);
return { error: "not_found" };
});
app.decorate("heimdallContext", { config, keys, store, oauthRuntimes, tokenCustody });
app.addHook("onClose", async () => {
await store.close();
});
await app.register(cors, {
origin: true,
credentials: true,
});
app.get("/healthz", async () => ({
ok: true,
service: config.serviceName,
issuer: config.issuer,
storageBackend: config.storage.backend,
cultCachePath: config.cultCachePath,
idunnDaemon: config.daemonId,
idunnHealthContract: config.idunnHealthContract,
idunnRudpHealth: config.idunnRudpHealth ?? null,
signingKeySource: keys.source,
tokenCustodySource: tokenCustody.source,
now: new Date().toISOString(),
}));
app.get("/.well-known/jwks.json", async () => ({
keys: [keys.publicJwk],
}));
app.get("/.well-known/heimdall-configuration", async () => buildDiscovery(config));
app.get("/v1/apps", async () => ({
apps: Object.values(builtInAppProfiles).map(serializeAppProfile),
}));
app.get<{ Params: { appSlug: AppSlug } }>(
"/v1/apps/:appSlug",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: {
appSlug: { type: "string" },
},
},
},
},
async (request, reply) => {
const profile = getAppProfile(request.params.appSlug);
if (!profile) {
reply.code(404);
return { error: "unknown_app", detail: "No app is registered under that slug." };
}
return serializeAppProfile(profile);
}
);
app.post<{ Params: { provider: Provider }; Body: OAuthStartRequest }>(
"/v1/oauth/:provider/start",
{
schema: {
params: {
type: "object",
required: ["provider"],
additionalProperties: false,
properties: {
provider: { type: "string", enum: [...providers] },
},
},
body: {
type: "object",
required: ["appSlug", "mode", "returnTo"],
additionalProperties: false,
properties: {
appSlug: { type: "string", enum: [...appSlugs] },
mode: { type: "string", enum: [...oauthModes] },
returnTo: { type: "string", format: "uri" },
requestedScopes: {
type: "array",
items: { type: "string" },
uniqueItems: true,
},
connection: {
type: "object",
required: ["kind", "targetId"],
additionalProperties: false,
properties: {
kind: { type: "string", enum: [...connectionKinds] },
targetId: { type: "string", minLength: 1 },
summary: { type: "string" },
},
},
initiatingAccountId: { type: "string", minLength: 1 },
handoff: {
type: "object",
required: ["kind"],
additionalProperties: false,
properties: {
kind: { type: "string", enum: [...oauthHandoffKinds] },
attemptId: { type: "string", minLength: 1 },
callbackUrl: { type: "string", format: "uri" },
},
},
entitlementPolicy: {
anyOf: [
{
type: "object",
required: ["kind", "guildId", "allowedRoleIds"],
additionalProperties: false,
properties: {
kind: { type: "string", const: "discord_role_access" },
guildId: { type: "string", minLength: 1 },
allowedRoleIds: {
type: "array",
minItems: 1,
items: { type: "string", minLength: 1 },
uniqueItems: true,
},
},
},
{
type: "object",
required: ["kind", "requiredTierTitle"],
additionalProperties: false,
properties: {
kind: { type: "string", const: "patreon_membership_access" },
requiredTierTitle: { type: "string", minLength: 1 },
},
},
],
},
},
},
},
},
async (request, reply) => {
const caller = resolveAppCaller(config, request.body.appSlug, request.headers);
const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body);
reply.code(result.statusCode);
return result.body;
}
);
app.get<{
Params: { provider: Provider };
Querystring: { code?: string; state?: string; error?: string; error_description?: string };
}>(
"/v1/oauth/:provider/callback",
{
schema: {
params: {
type: "object",
required: ["provider"],
additionalProperties: false,
properties: {
provider: { type: "string", enum: [...providers] },
},
},
querystring: {
type: "object",
additionalProperties: false,
properties: {
code: { type: "string" },
state: { type: "string" },
error: { type: "string" },
error_description: { type: "string" },
},
},
},
},
async (request, reply) => {
if (!request.query.state) {
reply.code(400);
return { error: "missing_state" };
}
const verification = verifyJwt(request.query.state, keys.publicKey);
if (!verification.valid) {
reply.code(400);
return { error: "invalid_state", detail: verification.error };
}
const statePayload = parseOAuthStatePayload(verification.payload);
if (!statePayload || statePayload.provider !== request.params.provider) {
reply.code(400);
return { error: "state_provider_mismatch" };
}
const handoff = statePayload.handoff ?? { kind: "browser_completion" };
const denyBrowserAttempt = async (denialCode: string): Promise<void> => {
if (handoff.kind !== "browser_completion" || !handoff.attemptId) return;
await store.updateAuthAttempt(statePayload.app_slug, handoff.attemptId, {
status: "denied",
at: new Date().toISOString(),
denialCode,
});
};
if (request.query.error) {
const providerError = relayedProviderText(request.query.error, 64);
const providerErrorDescription = relayedProviderText(request.query.error_description, 256);
const relayedDescription = providerErrorDescription ?? providerError;
const relayedDescriptionField = relayedDescription ? { errorDescription: relayedDescription } : {};
await denyBrowserAttempt("provider_error");
if (handoff.kind === "backend_callback") {
await maybeDeliverBackendHandoff(handoff, {
source: "heimdall",
kind: "oauth_result",
handoffKind: "backend_callback",
attemptId: handoff.attemptId,
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
connection: statePayload.connection,
error: "provider_error",
...relayedDescriptionField,
});
}
if (prefersHtml(request.headers.accept)) {
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
handoffKind: handoff.kind,
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
error: "provider_error",
...relayedDescriptionField,
})
);
}
reply.code(400);
return {
error: "provider_error",
provider: request.params.provider,
providerError,
providerErrorDescription,
returnTo: statePayload.return_to,
};
}
if (!request.query.code) {
await denyBrowserAttempt("missing_code");
if (handoff.kind === "backend_callback") {
await maybeDeliverBackendHandoff(handoff, {
source: "heimdall",
kind: "oauth_result",
handoffKind: "backend_callback",
attemptId: handoff.attemptId,
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
connection: statePayload.connection,
error: "missing_code",
});
}
if (prefersHtml(request.headers.accept)) {
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
handoffKind: handoff.kind,
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
error: "missing_code",
})
);
}
reply.code(400);
return { error: "missing_code", returnTo: statePayload.return_to };
}
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${request.params.provider}/callback`;
const runtime = oauthRuntimes[request.params.provider];
try {
const callbackProfile = getAppProfile(statePayload.app_slug);
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.");
}
const tokenSet = await runtime.exchangeAuthorizationCode({
config,
code: request.query.code,
redirectUri: callbackUrl,
});
const identity = await runtime.resolveIdentity({
config,
accessToken: tokenSet.accessToken,
});
const nowIso = new Date().toISOString();
let account = await store.findAccountByLinkedIdentity(identity.provider, identity.providerUserId);
// Link mode attaches the provider identity to the account the app
// named at start, and to no other: an identity already held by
// another account is a refusal, never a quiet sign-in as that account.
const linkInitiatorId = statePayload.mode === "link" ? statePayload.initiator_account_id : null;
if (statePayload.mode === "link") {
if (!linkInitiatorId) {
throw new LinkRefusal("link_initiator_missing", "The signed link state names no initiating account.");
}
if (account && account.id !== linkInitiatorId) {
throw new LinkRefusal("link_initiator_mismatch", "This identity belongs to a different account than the one that started the link.");
}
if (!account) {
account = await store.findAccountById(linkInitiatorId);
if (!account) {
throw new LinkRefusal("link_initiator_unknown", "The initiating account no longer exists.");
}
}
}
if (!account) {
const createAccountInput: CreateAccountInput = {
createdAt: nowIso,
lastSeenAt: nowIso,
};
const displayName = identity.displayName ?? identity.username;
if (displayName !== undefined) {
createAccountInput.displayName = displayName;
}
if (identity.primaryEmail !== undefined) {
createAccountInput.primaryEmail = identity.primaryEmail;
}
account = await store.createAccount(createAccountInput);
} else {
const accountUpdates: { displayName?: string; primaryEmail?: string } = {};
const displayName = identity.displayName ?? identity.username;
// A link adds an identity; it does not rewrite the account's profile.
if (displayName !== undefined && statePayload.mode !== "link") {
accountUpdates.displayName = displayName;
}
if (identity.primaryEmail !== undefined && statePayload.mode !== "link") {
accountUpdates.primaryEmail = identity.primaryEmail;
}
await store.touchAccount(account.id, nowIso, accountUpdates);
}
const linkedIdentityInput: UpsertLinkedIdentityInput = {
accountId: account.id,
provider: identity.provider,
providerUserId: identity.providerUserId,
scopes: tokenSet.scope,
profileJson: identity.profile,
createdAt: nowIso,
updatedAt: nowIso,
};
if (identity.username !== undefined) {
linkedIdentityInput.username = identity.username;
}
if (identity.displayName !== undefined) {
linkedIdentityInput.displayName = identity.displayName;
}
if (identity.primaryEmail !== undefined) {
linkedIdentityInput.primaryEmail = identity.primaryEmail;
}
linkedIdentityInput.accessTokenEncrypted = tokenCustody.encrypt(tokenSet.accessToken);
if (tokenSet.refreshToken !== undefined) {
linkedIdentityInput.refreshTokenEncrypted = tokenCustody.encrypt(tokenSet.refreshToken);
}
if (tokenSet.expiresAt !== undefined) {
linkedIdentityInput.tokenExpiresAt = tokenSet.expiresAt;
}
await store.upsertLinkedIdentity(linkedIdentityInput);
const entitlementEvaluation = await runtime.evaluateEntitlements({
config,
callback: {
appSlug: statePayload.app_slug,
accountId: account.id,
connection: statePayload.connection,
entitlementPolicy: statePayload.entitlement_policy,
},
identity,
tokenSet,
});
for (const snapshot of entitlementEvaluation.snapshots) {
await store.upsertEntitlementSnapshot(snapshot);
}
const grants = await store.listActiveGrants(account.id, statePayload.app_slug, nowIso);
const issueInput: IssueAccessClaimInput = {
appSlug: statePayload.app_slug,
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;
}
const issued = await issueAccessClaim({
config,
keys,
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,
sessionId: issued.session.sessionId,
appSlug: statePayload.app_slug,
eventType: "oauth_callback_succeeded",
eventPayloadJson: {
provider: request.params.provider,
mode: statePayload.mode,
returnTo: statePayload.return_to,
grantedFacts: issued.claimSet.facts,
sharedCapabilities: issued.sharedCapabilities,
},
createdAt: nowIso,
});
const accountSummary: {
id: string;
displayName?: string;
primaryEmail?: string;
} = {
id: account.id,
};
if (account.displayName !== undefined) {
accountSummary.displayName = account.displayName;
}
if (account.primaryEmail !== undefined) {
accountSummary.primaryEmail = account.primaryEmail;
}
const completionPayload = {
status: "success",
provider: request.params.provider,
mode: statePayload.mode,
appSlug: statePayload.app_slug,
account: accountSummary,
entitlements: entitlementEvaluation,
returnTo: statePayload.return_to,
connection: statePayload.connection,
...delivered,
};
if (handoff.kind === "backend_callback") {
await maybeDeliverBackendHandoff(handoff, {
source: "heimdall",
kind: "oauth_result",
handoffKind: "backend_callback",
attemptId: handoff.attemptId,
status: "success",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
connection: statePayload.connection,
account: accountSummary,
entitlements: entitlementEvaluation,
...delivered,
});
await store.createAuditEvent({
accountId: account.id,
sessionId: issued.session.sessionId,
appSlug: statePayload.app_slug,
eventType: "backend_handoff_delivered",
eventPayloadJson: {
provider: request.params.provider,
mode: statePayload.mode,
attemptId: handoff.attemptId,
connection: statePayload.connection,
callbackUrl: handoff.callbackUrl,
},
createdAt: nowIso,
});
if (prefersHtml(request.headers.accept)) {
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
handoffKind: "backend_callback",
status: "success",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
attemptId: handoff.attemptId,
})
);
}
reply.code(201);
return {
delivery: {
kind: "backend_callback",
attemptId: handoff.attemptId,
callbackUrl: handoff.callbackUrl,
status: "delivered",
},
account: accountSummary,
session: issued.session,
verification: issued.verification,
};
}
const completionExpiresAt = new Date(Date.now() + config.completionTtlSeconds * 1000).toISOString();
// The completion code is always minted here, never taken from the
// caller. handoff.attemptId is a browser-carried correlation handle,
// not a secret: before this cut it was assigned verbatim as the code
// (createAuthCompletion({ code: handoff.attemptId })), so anyone who
// started the flow could fix the redemption code in advance and
// redeem a victim's signed tokens without ever touching the app
// secret. It is stored on `attemptId` below purely for correlation.
const completion = await store.createAuthCompletion({
...(handoff.kind === "browser_completion" && handoff.attemptId ? { attemptId: handoff.attemptId } : {}),
appSlug: statePayload.app_slug,
provider: request.params.provider,
mode: statePayload.mode,
accountId: account.id,
sessionId: issued.session.sessionId,
returnTo: statePayload.return_to,
...(statePayload.connection ? { targetId: statePayload.connection.targetId } : {}),
createdAt: nowIso,
expiresAt: completionExpiresAt,
payloadJson: completionPayload as unknown as Record<string, unknown>,
});
if (handoff.kind === "browser_completion" && handoff.attemptId) {
await store.updateAuthAttempt(statePayload.app_slug, handoff.attemptId, {
status: "completed",
at: nowIso,
});
}
await store.createAuditEvent({
accountId: account.id,
sessionId: issued.session.sessionId,
appSlug: statePayload.app_slug,
eventType: "auth_completion_created",
// Never the completion code: until it expires, the unauthenticated
// redeem route turns it into the session. The event's sessionId
// already names the completion, one per callback.
eventPayloadJson: {
provider: request.params.provider,
mode: statePayload.mode,
expiresAt: completion.expiresAt,
},
createdAt: nowIso,
});
if (prefersHtml(request.headers.accept)) {
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
handoffKind: "browser_completion",
status: "success",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
completionCode: completion.code,
...(handoff.kind === "browser_completion" && handoff.attemptId
? { attemptId: handoff.attemptId }
: {}),
})
);
}
reply.code(201);
return {
completion: {
code: completion.code,
expiresAt: completion.expiresAt,
redeemEndpoint: `${config.publicBaseUrl}/v1/apps/${statePayload.app_slug}/auth-completions/redeem`,
},
...completionPayload,
};
} catch (error) {
// Neither the answer nor the audit event carries the error's text.
// This path catches store and provider failures: a Postgres or
// resolver error names a constraint, the host or the database, and
// JSON.parse quotes the provider's response body. The audit event
// keeps the error's class, its code, and a provider's HTTP status.
const status = (error as { status?: unknown }).status;
// A link refusal is a typed decision with a static message; every
// other failure keeps the opaque answer above.
const refusal = error instanceof LinkRefusal ? error : null;
const failureCode = refusal?.code ?? "oauth_callback_failed";
const failure = refusal?.message ?? "The provider callback could not be completed.";
const nowIso = new Date().toISOString();
await denyBrowserAttempt(failureCode);
await store.createAuditEvent({
appSlug: statePayload.app_slug,
eventType: refusal ? "oauth_link_refused" : "oauth_callback_failed",
eventPayloadJson: {
provider: request.params.provider,
mode: statePayload.mode,
...errorIdentity(error),
providerStatus: typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599 ? status : null,
},
createdAt: nowIso,
});
if (handoff.kind === "backend_callback") {
try {
await maybeDeliverBackendHandoff(handoff, {
source: "heimdall",
kind: "oauth_result",
handoffKind: "backend_callback",
attemptId: handoff.attemptId,
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
connection: statePayload.connection,
error: failureCode,
errorDescription: failure,
});
} catch {}
}
if (prefersHtml(request.headers.accept)) {
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
handoffKind: handoff.kind,
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
error: failureCode,
errorDescription: failure,
})
);
}
reply.code(refusal ? 403 : 502);
return {
error: failureCode,
detail: failure,
provider: request.params.provider,
appSlug: statePayload.app_slug,
returnTo: statePayload.return_to,
};
}
}
);
app.post<{ Params: { appSlug: AppSlug }; Body: RedeemAuthCompletionRequest }>(
"/v1/apps/:appSlug/auth-completions/redeem",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: {
appSlug: { type: "string", enum: [...appSlugs] },
},
},
body: {
type: "object",
required: ["completionCode"],
additionalProperties: false,
properties: {
completionCode: { type: "string", minLength: 1 },
targetId: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => {
const nowIso = new Date().toISOString();
// Sign-in and connect completions are redeemed by the browser flow, so
// the route stays open to it. A link completion was minted for an
// initiator the authenticated app named at start; only that app's
// backend redeems it. Redeem is also where the connection target is
// held to what the signed state named. Both refusals read the row
// without consuming it, so a wrong caller or target cannot burn the code.
const pending = await store.findAuthCompletion(request.params.appSlug, request.body.completionCode);
if (pending && !pending.consumedAt && pending.expiresAt > nowIso) {
if (pending.mode === "link" && !resolveAppCaller(config, request.params.appSlug, request.headers)) {
reply.code(401);
return { error: "app_auth_required" };
}
if (pending.targetId !== undefined && pending.targetId !== request.body.targetId) {
await store.createAuditEvent({
accountId: pending.accountId,
sessionId: pending.sessionId,
appSlug: pending.appSlug,
eventType: "auth_completion_target_refused",
eventPayloadJson: { provider: pending.provider, mode: pending.mode },
createdAt: nowIso,
});
reply.code(403);
return { error: "completion_target_mismatch" };
}
}
const completion = await store.consumeAuthCompletion(request.params.appSlug, request.body.completionCode, nowIso);
if (!completion) {
reply.code(410);
return { error: "invalid_or_expired_completion_code" };
}
await store.createAuditEvent({
accountId: completion.accountId,
sessionId: completion.sessionId,
appSlug: completion.appSlug,
eventType: "auth_completion_redeemed",
eventPayloadJson: {
provider: completion.provider,
mode: completion.mode,
},
createdAt: nowIso,
});
// completion.attemptId is the browser-carried correlation handle, kept
// separate from the redemption code itself (see createAuthCompletion
// above); this used to key off the completion code, which only ever
// matched an attempt by the coincidence of the pre-fix bug where the
// code *was* the caller-chosen attemptId.
if (completion.attemptId) {
await store.updateAuthAttempt(request.params.appSlug, completion.attemptId, {
status: "consumed",
at: nowIso,
});
}
reply.code(201);
return completion.payloadJson;
}
);
app.post<{ Params: { appSlug: AppSlug }; Body: RefreshSessionRequest }>(
"/v1/apps/:appSlug/sessions/refresh",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: {
appSlug: { type: "string", enum: [...appSlugs] },
},
},
body: {
type: "object",
required: ["refreshToken"],
additionalProperties: false,
properties: {
refreshToken: { type: "string", minLength: 1 },
entitlementPolicy: {
anyOf: [
{
type: "object",
required: ["kind", "guildId", "allowedRoleIds"],
additionalProperties: false,
properties: {
kind: { type: "string", const: "discord_role_access" },
guildId: { type: "string", minLength: 1 },
allowedRoleIds: {
type: "array",
minItems: 1,
items: { type: "string", minLength: 1 },
uniqueItems: true,
},
},
},
{
type: "object",
required: ["kind", "requiredTierTitle"],
additionalProperties: false,
properties: {
kind: { type: "string", const: "patreon_membership_access" },
requiredTierTitle: { type: "string", minLength: 1 },
},
},
],
},
entitlementPolicies: {
type: "array",
items: {
anyOf: [
{
type: "object",
required: ["kind", "guildId", "allowedRoleIds"],
additionalProperties: false,
properties: {
kind: { type: "string", const: "discord_role_access" },
guildId: { type: "string", minLength: 1 },
allowedRoleIds: {
type: "array",
minItems: 1,
items: { type: "string", minLength: 1 },
uniqueItems: true,
},
},
},
{
type: "object",
required: ["kind", "requiredTierTitle"],
additionalProperties: false,
properties: {
kind: { type: "string", const: "patreon_membership_access" },
requiredTierTitle: { type: "string", minLength: 1 },
},
},
],
},
},
},
},
},
},
async (request, reply) => {
const caller = resolveAppCaller(config, request.params.appSlug, request.headers);
const result = await refreshAppSession(
{ config, keys, store, tokenCustody, oauthRuntimes },
request.params.appSlug,
caller,
request.body
);
reply.code(result.statusCode);
return result.body;
}
);
app.post<{
Params: { appSlug: AppSlug };
Body: {
accountId: string;
requiredTierTitle: string;
currencyCode?: string;
supportedAtUtc?: string;
};
}>(
"/v1/apps/:appSlug/patron-support/sync",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: {
appSlug: { type: "string", enum: [...appSlugs] },
},
},
body: {
type: "object",
required: ["accountId", "requiredTierTitle"],
additionalProperties: false,
properties: {
accountId: { type: "string", minLength: 1 },
requiredTierTitle: { type: "string", minLength: 1 },
currencyCode: { type: "string", minLength: 3, maxLength: 3 },
supportedAtUtc: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => {
const profile = getAppProfile(request.params.appSlug);
if (!profile) {
reply.code(404);
return { error: "unknown_app", detail: "No app is registered under that slug." };
}
if (!resolveAppCaller(config, profile.slug, request.headers)) {
reply.code(401);
return { error: "app_auth_required" };
}
if (profile.slug !== "bifrost") {
reply.code(400);
return { error: "patron_support_sync_only_available_for_bifrost", appSlug: profile.slug };
}
if (!config.bifrostPatronSupportEndpoint || !config.bifrostPatronSupportSecret) {
reply.code(503);
return { error: "bifrost_patron_support_not_configured" };
}
const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, "patreon");
if (!storedLinkedIdentity) {
reply.code(404);
return { error: "patreon_identity_not_linked" };
}
const now = new Date().toISOString();
const refreshed = await refreshManagedCredential({ config, store, tokenCustody, oauthRuntimes }, storedLinkedIdentity);
const linkedIdentity = refreshed.identity;
if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
reply.code(404);
return { error: "patreon_identity_not_linked" };
}
const patreonProfile = await fetchPatreonIdentity(tokenCustody.decrypt(linkedIdentity.accessTokenEncrypted));
const memberships = summarizePatreonMemberships(patreonProfile);
const activeMembership = memberships.find(
(membership) =>
membership.isActivePaid &&
membership.currentlyEntitledAmountCents > 0 &&
membership.tierTitles.includes(request.body.requiredTierTitle)
);
if (!activeMembership) {
reply.code(404);
return {
error: "active_patreon_membership_not_found",
accountId: request.body.accountId,
requiredTierTitle: request.body.requiredTierTitle,
membershipCount: memberships.length,
};
}
const supportedAtUtc = request.body.supportedAtUtc ?? now;
const supportedDay = supportedAtUtc.slice(0, 10);
const currencyCode = activeMembership.campaignCurrency ?? request.body.currencyCode ?? "USD";
const supportFact = {
heimdallAccountId: request.body.accountId,
provider: "Patreon",
providerEventId: [
"patreon-membership-snapshot",
linkedIdentity.providerUserId,
activeMembership.memberId,
supportedDay,
activeMembership.currentlyEntitledAmountCents,
].join(":"),
kind: "RecurringSupportSnapshot",
amount: Number(centsToDecimalString(activeMembership.currentlyEntitledAmountCents)),
currencyCode,
externalSupportId: `patreon-member:${activeMembership.memberId}`,
supportedAtUtc,
isCurrentRecurringSupport: true,
providerPayerId: linkedIdentity.providerUserId,
providerSubscriptionId: activeMembership.memberId,
notes: `Verified active Patreon membership for tier ${request.body.requiredTierTitle}.`,
};
const body = JSON.stringify(supportFact);
const bifrostResponse = await fetch(config.bifrostPatronSupportEndpoint, {
method: "POST",
headers: {
"content-type": "application/json",
"x-heimdall-signature-256": signBifrostPatronSupportPayload(config.bifrostPatronSupportSecret, body),
},
body,
});
// Bifrost's answer is kept by its status alone, in the audit event and
// in the reply: another service's body can quote the signed fact or
// its own configuration, and both of those are stored and relayed.
await bifrostResponse.body?.cancel();
await store.createAuditEvent({
accountId: request.body.accountId,
appSlug: "bifrost",
eventType: bifrostResponse.ok ? "bifrost_patron_support_synced" : "bifrost_patron_support_sync_failed",
eventPayloadJson: {
provider: "patreon",
requiredTierTitle: request.body.requiredTierTitle,
providerEventId: supportFact.providerEventId,
bifrostStatus: bifrostResponse.status,
},
createdAt: now,
});
if (!bifrostResponse.ok) {
reply.code(502);
return {
error: "bifrost_patron_support_rejected",
statusCode: bifrostResponse.status,
detail: "Bifrost refused the patron support fact.",
};
}
return {
status: "synced",
supportFact,
bifrostStatus: bifrostResponse.status,
};
}
);
app.post<{
Params: { appSlug: AppSlug };
Body: { accountId: string; provider: Provider };
}>(
"/v1/apps/:appSlug/managed-credentials/resolve",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: {
appSlug: { type: "string", enum: [...appSlugs] },
},
},
body: {
type: "object",
required: ["accountId", "provider"],
additionalProperties: false,
properties: {
accountId: { type: "string", minLength: 1 },
provider: { type: "string", enum: [...providers] },
},
},
},
},
async (request, reply) => {
const profile = getAppProfile(request.params.appSlug);
if (!profile) {
reply.code(404);
return { error: "unknown_app", detail: "No app is registered under that slug." };
}
if (!resolveAppCaller(config, profile.slug, request.headers)) {
reply.code(401);
return { error: "app_auth_required" };
}
if (!profile.managedConnectionProviders.includes(request.body.provider)) {
reply.code(400);
return {
error: "provider_not_managed_for_app",
provider: request.body.provider,
appSlug: profile.slug,
};
}
const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, request.body.provider);
if (!storedLinkedIdentity) {
reply.code(404);
return { error: "managed_credential_not_found" };
}
let refreshed;
try {
refreshed = await refreshManagedCredential({ config, store, tokenCustody, oauthRuntimes }, storedLinkedIdentity);
} catch (error) {
if (error instanceof ManagedCredentialRefreshFailed) {
reply.code(502);
return { error: error.code, provider: error.provider };
}
throw error;
}
const linkedIdentity = refreshed.identity;
if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
reply.code(410);
return { error: "managed_credential_revoked", provider: linkedIdentity.provider };
}
const response: {
accountId: string;
provider: Provider;
providerUserId: string;
accessToken: string;
tokenExpiresAt?: string;
scopes: string[];
} = {
accountId: linkedIdentity.accountId,
provider: linkedIdentity.provider,
providerUserId: linkedIdentity.providerUserId,
accessToken: tokenCustody.decrypt(linkedIdentity.accessTokenEncrypted),
scopes: linkedIdentity.scopes,
};
if (linkedIdentity.tokenExpiresAt) {
response.tokenExpiresAt = linkedIdentity.tokenExpiresAt;
}
return response;
}
);
return app;
}