Heimdall/src/app.ts

1868 lines
67 KiB
TypeScript
Raw Normal View History

Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
import { createHmac, randomUUID } from "node:crypto";
2026-04-26 12:25:11 +01:00
import cors from "@fastify/cors";
import Fastify, { type FastifyError, type FastifyInstance } from "fastify";
import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js";
2026-04-26 17:00:57 +01:00
import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js";
2026-04-26 12:25:11 +01:00
import {
appSlugs,
connectionKinds,
2026-04-26 17:00:57 +01:00
oauthHandoffKinds,
2026-04-26 12:25:11 +01:00
oauthModes,
providers,
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
isAppSlug,
2026-04-26 12:25:11 +01:00
type AppSlug,
2026-04-26 17:00:57 +01:00
type OAuthHandoff,
2026-04-29 13:22:01 +01:00
type OAuthEntitlementPolicy,
2026-04-26 12:50:07 +01:00
type OAuthMode,
2026-04-26 12:25:11 +01:00
type OAuthConnectionBinding,
type RedeemAuthCompletionRequest,
type RefreshSessionRequest,
type RefreshTokenPayload,
2026-04-26 12:50:07 +01:00
type OAuthStatePayload,
2026-04-26 12:25:11 +01:00
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";
2026-04-26 12:25:11 +01:00
import { type HeimdallConfig, loadConfig } from "./config.js";
import { createTokenCustody, type TokenCustody } from "./custody.js";
import { grantFact } from "./facts.js";
2026-06-09 20:22:58 +01:00
import {
createOAuthRuntimeRegistry,
fetchPatreonIdentity,
summarizePatreonMemberships,
type OAuthRuntimeRegistry,
} from "./oauth.js";
import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js";
2026-04-26 12:25:11 +01:00
import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
2026-04-26 12:50:07 +01:00
import { createStore, type HeimdallStore } from "./store/index.js";
import {
type CreateAccountInput,
type StoredCapabilityGrant,
type StoredLinkedIdentity,
type UpsertLinkedIdentityInput,
} from "./store/types.js";
export interface BuildAppOptions {
2026-04-26 12:25:11 +01:00
config?: HeimdallConfig;
2026-04-26 12:50:07 +01:00
store?: HeimdallStore;
oauthRuntimes?: Partial<OAuthRuntimeRegistry>;
tokenCustody?: TokenCustody;
2026-04-26 12:25:11 +01:00
}
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;
}
2026-04-26 12:25:11 +01:00
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,
})),
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
apps: Object.values(builtInAppProfiles).map(serializeAppProfile),
2026-04-26 12:25:11 +01:00
};
}
2026-04-26 12:50:07 +01:00
function nowEpochSeconds(): number {
return Math.floor(Date.now() / 1000);
}
2026-04-26 12:25:11 +01:00
function buildOAuthStateToken(options: {
config: HeimdallConfig;
provider: Provider;
appSlug: AppSlug;
2026-04-26 12:50:07 +01:00
mode: OAuthMode;
2026-04-26 12:25:11 +01:00
returnTo: string;
connection: OAuthConnectionBinding | undefined;
initiatorAccountId: string | undefined;
2026-04-26 17:00:57 +01:00
handoff: OAuthHandoff;
2026-04-29 13:22:01 +01:00
entitlementPolicy: OAuthEntitlementPolicy | undefined;
2026-04-26 12:25:11 +01:00
keys: ReturnType<typeof createRuntimeKeyMaterial>;
}): { token: string; expiresAt: string } {
const issuedAt = nowEpochSeconds();
const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds;
2026-04-26 12:50:07 +01:00
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,
2026-04-26 17:00:57 +01:00
handoff: options.handoff,
2026-04-29 13:22:01 +01:00
entitlement_policy: options.entitlementPolicy ?? null,
2026-04-26 12:50:07 +01:00
};
const token = signJwt(payload as unknown as Record<string, unknown>, options.keys);
2026-04-26 12:25:11 +01:00
return {
token,
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
};
}
2026-04-26 12:50:07 +01:00
function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePayload | null {
2026-04-26 17:00:57 +01:00
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")) ||
2026-04-26 17:00:57 +01:00
((handoff as Record<string, unknown>).kind === "backend_callback" &&
typeof (handoff as Record<string, unknown>).attemptId === "string" &&
typeof (handoff as Record<string, unknown>).callbackUrl === "string")));
2026-04-29 13:22:01 +01:00
const entitlementPolicy = payload.entitlement_policy;
const entitlementPolicyIsValid =
entitlementPolicy === null ||
entitlementPolicy === undefined ||
2026-04-29 14:36:03 +01:00
((typeof entitlementPolicy === "object" &&
2026-04-29 13:22:01 +01:00
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"
2026-04-29 14:36:03 +01:00
)) ||
(typeof entitlementPolicy === "object" &&
entitlementPolicy !== null &&
(entitlementPolicy as Record<string, unknown>).kind === "patreon_membership_access" &&
typeof (entitlementPolicy as Record<string, unknown>).requiredTierTitle === "string"));
2026-04-26 12:50:07 +01:00
if (
payload.typ !== "heimdall_oauth_state" ||
typeof payload.provider !== "string" ||
!providers.includes(payload.provider as Provider) ||
typeof payload.app_slug !== "string" ||
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
!isAppSlug(payload.app_slug) ||
2026-04-26 12:50:07 +01:00
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" ||
2026-04-26 17:00:57 +01:00
typeof payload.exp !== "number" ||
2026-04-29 13:22:01 +01:00
!handoffIsValid ||
!entitlementPolicyIsValid ||
!(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string")
2026-04-26 12:50:07 +01:00
) {
return null;
}
2026-04-26 17:00:57 +01:00
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;
}
2026-04-29 13:22:01 +01:00
if (!normalizedPayload.entitlement_policy) {
normalizedPayload.entitlement_policy = null;
}
2026-04-26 17:00:57 +01:00
return normalizedPayload;
2026-04-26 12:50:07 +01:00
}
function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] {
return grants.map((grant) => grantFact(grant.capability));
2026-04-26 12:50:07 +01:00
}
function parseRefreshTokenPayload(payload: Record<string, unknown>): RefreshTokenPayload | null {
if (
payload.typ !== "heimdall_refresh" ||
typeof payload.iss !== "string" ||
typeof payload.aud !== "string" ||
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
!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,
};
}
2026-04-26 12:50:07 +01:00
function prefersHtml(acceptHeader: string | undefined): boolean {
if (!acceptHeader) {
return false;
}
return acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml");
}
R21.1: bind the browser_completion attempt handle to the private plane The public /v1/oauth/:provider/start route read handoff.attemptId straight out of the request body and folded it into the signed state token unconditionally. Since the private command plane's beginAuth mints an attempt handle server-side and later redeems by that handle (consumeAuthCompletionByAttempt), anyone who learned a pending handle could carry it through their own OAuth flow and bind their own identity to it -- the app's completeAuth(H) would then authenticate the victim's session as the attacker. Same family as the takeover chain closed in the prior pass: a caller-chosen identifier trusted as authority. normalizeOAuthHandoff now always strips attemptId from a browser_completion handoff; a public caller has no attempt to correlate and gets its completion code directly. The only path an attempt handle can reach the state token is startOAuthFlow's new trustedBrowserAttemptId option, which is a TypeScript function argument set by beginAuth from the handle it just minted -- never a value parsed out of a request body. Also adds the uniqueness constraint the by-attempt redemption needed: a partial unique index on (app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL, mirrored in InMemoryStore. Without it, consumeAuthCompletionByAttempt's UPDATE could match more than one row and silently discard all but the one it returned. Flips Soul's attempt-handle-binding it.fails to a passing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:43:33 +02:00
/**
* `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.
*/
2026-04-26 17:00:57 +01:00
function normalizeOAuthHandoff(value: OAuthStartRequest["handoff"]): OAuthHandoff {
if (!value) {
return { kind: "browser_completion" };
}
if (value.kind === "browser_completion") {
R21.1: bind the browser_completion attempt handle to the private plane The public /v1/oauth/:provider/start route read handoff.attemptId straight out of the request body and folded it into the signed state token unconditionally. Since the private command plane's beginAuth mints an attempt handle server-side and later redeems by that handle (consumeAuthCompletionByAttempt), anyone who learned a pending handle could carry it through their own OAuth flow and bind their own identity to it -- the app's completeAuth(H) would then authenticate the victim's session as the attacker. Same family as the takeover chain closed in the prior pass: a caller-chosen identifier trusted as authority. normalizeOAuthHandoff now always strips attemptId from a browser_completion handoff; a public caller has no attempt to correlate and gets its completion code directly. The only path an attempt handle can reach the state token is startOAuthFlow's new trustedBrowserAttemptId option, which is a TypeScript function argument set by beginAuth from the handle it just minted -- never a value parsed out of a request body. Also adds the uniqueness constraint the by-attempt redemption needed: a partial unique index on (app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL, mirrored in InMemoryStore. Without it, consumeAuthCompletionByAttempt's UPDATE could match more than one row and silently discard all but the one it returned. Flips Soul's attempt-handle-binding it.fails to a passing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:43:33 +02:00
return { kind: "browser_completion" };
2026-04-26 17:00:57 +01:00
}
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.");
}
2026-04-29 13:22:01 +01:00
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,
};
}
}
2026-04-29 14:36:03 +01:00
if (
value.kind === "patreon_membership_access" &&
typeof value.requiredTierTitle === "string" &&
value.requiredTierTitle.trim()
) {
return {
kind: "patreon_membership_access",
requiredTierTitle: value.requiredTierTitle.trim(),
};
}
2026-04-29 13:22:01 +01:00
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";
}
2026-06-09 20:22:58 +01:00
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[]> {
R21.3: delete the unreachable registered-app resolution path The runtime app-registration writer (POST /v1/apps) was deleted in an earlier pass because it minted a client_secret nobody verified. This pass kept resolveAppProfile's registered-app fallback and listAppProfiles on the theory that AppSlug is an open string and dynamic registration was a live extension point. Soul falsified that: every HTTP route enumerates the four built-in slugs in its own JSON schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body field), so a registered row could never be resolved from a request. With the writer already gone, this was unreachable code behind a dead writer -- delete it rather than keep authenticating a door nobody can open. Deleted: src/app-registry.ts entirely (resolveAppProfile, listAppProfiles, isBuiltInApp, registeredAppToProfile, returnOriginsFromRedirectUris); StoredRegisteredApp and the findRegisteredApp/listRegisteredApps HeimdallStore methods (store/types.ts); their InMemoryStore and PostgresStore implementations, including seedRegisteredApp and mapRegisteredApp; the registered_apps table (store/schema.ts now drops it on any store built before this schema); tests/app-registry.test.ts. Every call site (app.ts x5, claims.ts, private-command-plane.ts) now calls the existing built-in-only getAppProfile (app-profiles.ts) directly -- sync, no store needed. If dynamic app registration is wanted later it returns as a designed, authenticated feature, not by resurrecting this vestige. Net -305 lines against the prior commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
const profile = getAppProfile(options.appSlug);
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
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 {
2026-04-29 13:22:01 +01:00
if (handoff.kind !== "backend_callback") {
return false;
}
return (config.appBackendCallbacks[appSlug] ?? []).includes(handoff.callbackUrl);
2026-04-29 13:22:01 +01:00
}
2026-04-26 17:00:57 +01:00
async function maybeDeliverBackendHandoff(handoff: OAuthHandoff, payload: BackendHandoffPayload): Promise<void> {
if (handoff.kind !== "backend_callback") {
return;
}
await deliverBackendHandoff(handoff.callbackUrl, payload);
}
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
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,
R21.1: bind the browser_completion attempt handle to the private plane The public /v1/oauth/:provider/start route read handoff.attemptId straight out of the request body and folded it into the signed state token unconditionally. Since the private command plane's beginAuth mints an attempt handle server-side and later redeems by that handle (consumeAuthCompletionByAttempt), anyone who learned a pending handle could carry it through their own OAuth flow and bind their own identity to it -- the app's completeAuth(H) would then authenticate the victim's session as the attacker. Same family as the takeover chain closed in the prior pass: a caller-chosen identifier trusted as authority. normalizeOAuthHandoff now always strips attemptId from a browser_completion handoff; a public caller has no attempt to correlate and gets its completion code directly. The only path an attempt handle can reach the state token is startOAuthFlow's new trustedBrowserAttemptId option, which is a TypeScript function argument set by beginAuth from the handle it just minted -- never a value parsed out of a request body. Also adds the uniqueness constraint the by-attempt redemption needed: a partial unique index on (app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL, mirrored in InMemoryStore. Without it, consumeAuthCompletionByAttempt's UPDATE could match more than one row and silently discard all but the one it returned. Flips Soul's attempt-handle-binding it.fails to a passing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:43:33 +02:00
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;
}
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
): Promise<HandlerResult> {
R21.3: delete the unreachable registered-app resolution path The runtime app-registration writer (POST /v1/apps) was deleted in an earlier pass because it minted a client_secret nobody verified. This pass kept resolveAppProfile's registered-app fallback and listAppProfiles on the theory that AppSlug is an open string and dynamic registration was a live extension point. Soul falsified that: every HTTP route enumerates the four built-in slugs in its own JSON schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body field), so a registered row could never be resolved from a request. With the writer already gone, this was unreachable code behind a dead writer -- delete it rather than keep authenticating a door nobody can open. Deleted: src/app-registry.ts entirely (resolveAppProfile, listAppProfiles, isBuiltInApp, registeredAppToProfile, returnOriginsFromRedirectUris); StoredRegisteredApp and the findRegisteredApp/listRegisteredApps HeimdallStore methods (store/types.ts); their InMemoryStore and PostgresStore implementations, including seedRegisteredApp and mapRegisteredApp; the registered_apps table (store/schema.ts now drops it on any store built before this schema); tests/app-registry.test.ts. Every call site (app.ts x5, claims.ts, private-command-plane.ts) now calls the existing built-in-only getAppProfile (app-profiles.ts) directly -- sync, no store needed. If dynamic app registration is wanted later it returns as a designed, authenticated feature, not by resurrecting this vestige. Net -305 lines against the prior commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
const { config, keys } = ctx;
const profile = getAppProfile(input.appSlug);
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
if (!profile) {
return { statusCode: 404, body: { error: "unknown_app", detail: "No app is registered under that slug." } };
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
}
if (!admitsProviderForMode(profile, provider, input.mode)) {
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
return {
statusCode: 400,
body: { error: "provider_not_supported_for_app", provider, mode: input.mode, appSlug: profile.slug },
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
};
}
// 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.` },
};
}
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
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.",
},
};
}
R21.1: bind the browser_completion attempt handle to the private plane The public /v1/oauth/:provider/start route read handoff.attemptId straight out of the request body and folded it into the signed state token unconditionally. Since the private command plane's beginAuth mints an attempt handle server-side and later redeems by that handle (consumeAuthCompletionByAttempt), anyone who learned a pending handle could carry it through their own OAuth flow and bind their own identity to it -- the app's completeAuth(H) would then authenticate the victim's session as the attacker. Same family as the takeover chain closed in the prior pass: a caller-chosen identifier trusted as authority. normalizeOAuthHandoff now always strips attemptId from a browser_completion handoff; a public caller has no attempt to correlate and gets its completion code directly. The only path an attempt handle can reach the state token is startOAuthFlow's new trustedBrowserAttemptId option, which is a TypeScript function argument set by beginAuth from the handle it just minted -- never a value parsed out of a request body. Also adds the uniqueness constraint the by-attempt redemption needed: a partial unique index on (app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL, mirrored in InMemoryStore. Without it, consumeAuthCompletionByAttempt's UPDATE could match more than one row and silently discard all but the one it returned. Flips Soul's attempt-handle-binding it.fails to a passing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:43:33 +02:00
if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") {
handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId };
}
if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) {
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
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,
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
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)) {
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
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 };
}
2026-04-26 12:25:11 +01:00
export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
const config = options.config ?? loadConfig();
2026-04-26 12:50:07 +01:00
const store = options.store ?? (await createStore(config));
2026-04-26 12:25:11 +01:00
const keys = createRuntimeKeyMaterial(config);
const tokenCustody = options.tokenCustody ?? createTokenCustody(config);
2026-04-26 12:50:07 +01:00
const oauthRuntimes: OAuthRuntimeRegistry = {
...createOAuthRuntimeRegistry(),
...(options.oauthRuntimes ?? {}),
};
2026-04-26 12:25:11 +01:00
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 });
2026-04-26 12:50:07 +01:00
app.addHook("onClose", async () => {
await store.close();
});
2026-04-26 12:25:11 +01:00
await app.register(cors, {
origin: true,
credentials: true,
});
app.get("/healthz", async () => ({
ok: true,
service: config.serviceName,
issuer: config.issuer,
2026-04-26 12:50:07 +01:00
storageBackend: config.storage.backend,
cultCachePath: config.cultCachePath,
idunnDaemon: config.daemonId,
idunnHealthContract: config.idunnHealthContract,
idunnRudpHealth: config.idunnRudpHealth ?? null,
signingKeySource: keys.source,
tokenCustodySource: tokenCustody.source,
2026-04-26 12:25:11 +01:00
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 () => ({
R21.3: delete the unreachable registered-app resolution path The runtime app-registration writer (POST /v1/apps) was deleted in an earlier pass because it minted a client_secret nobody verified. This pass kept resolveAppProfile's registered-app fallback and listAppProfiles on the theory that AppSlug is an open string and dynamic registration was a live extension point. Soul falsified that: every HTTP route enumerates the four built-in slugs in its own JSON schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body field), so a registered row could never be resolved from a request. With the writer already gone, this was unreachable code behind a dead writer -- delete it rather than keep authenticating a door nobody can open. Deleted: src/app-registry.ts entirely (resolveAppProfile, listAppProfiles, isBuiltInApp, registeredAppToProfile, returnOriginsFromRedirectUris); StoredRegisteredApp and the findRegisteredApp/listRegisteredApps HeimdallStore methods (store/types.ts); their InMemoryStore and PostgresStore implementations, including seedRegisteredApp and mapRegisteredApp; the registered_apps table (store/schema.ts now drops it on any store built before this schema); tests/app-registry.test.ts. Every call site (app.ts x5, claims.ts, private-command-plane.ts) now calls the existing built-in-only getAppProfile (app-profiles.ts) directly -- sync, no store needed. If dynamic app registration is wanted later it returns as a designed, authenticated feature, not by resurrecting this vestige. Net -305 lines against the prior commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
apps: Object.values(builtInAppProfiles).map(serializeAppProfile),
2026-04-26 12:25:11 +01:00
}));
app.get<{ Params: { appSlug: AppSlug } }>(
"/v1/apps/:appSlug",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: {
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
appSlug: { type: "string" },
2026-04-26 12:25:11 +01:00
},
},
},
},
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
async (request, reply) => {
R21.3: delete the unreachable registered-app resolution path The runtime app-registration writer (POST /v1/apps) was deleted in an earlier pass because it minted a client_secret nobody verified. This pass kept resolveAppProfile's registered-app fallback and listAppProfiles on the theory that AppSlug is an open string and dynamic registration was a live extension point. Soul falsified that: every HTTP route enumerates the four built-in slugs in its own JSON schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body field), so a registered row could never be resolved from a request. With the writer already gone, this was unreachable code behind a dead writer -- delete it rather than keep authenticating a door nobody can open. Deleted: src/app-registry.ts entirely (resolveAppProfile, listAppProfiles, isBuiltInApp, registeredAppToProfile, returnOriginsFromRedirectUris); StoredRegisteredApp and the findRegisteredApp/listRegisteredApps HeimdallStore methods (store/types.ts); their InMemoryStore and PostgresStore implementations, including seedRegisteredApp and mapRegisteredApp; the registered_apps table (store/schema.ts now drops it on any store built before this schema); tests/app-registry.test.ts. Every call site (app.ts x5, claims.ts, private-command-plane.ts) now calls the existing built-in-only getAppProfile (app-profiles.ts) directly -- sync, no store needed. If dynamic app registration is wanted later it returns as a designed, authenticated feature, not by resurrecting this vestige. Net -305 lines against the prior commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
const profile = getAppProfile(request.params.appSlug);
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
if (!profile) {
reply.code(404);
return { error: "unknown_app", detail: "No app is registered under that slug." };
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
}
return serializeAppProfile(profile);
}
2026-04-26 12:25:11 +01:00
);
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 },
2026-04-26 17:00:57 +01:00
handoff: {
type: "object",
required: ["kind"],
additionalProperties: false,
properties: {
kind: { type: "string", enum: [...oauthHandoffKinds] },
attemptId: { type: "string", minLength: 1 },
callbackUrl: { type: "string", format: "uri" },
},
},
2026-04-29 13:22:01 +01:00
entitlementPolicy: {
2026-04-29 14:36:03 +01:00
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,
},
},
2026-04-29 13:22:01 +01:00
},
2026-04-29 14:36:03 +01:00
{
type: "object",
required: ["kind", "requiredTierTitle"],
additionalProperties: false,
properties: {
kind: { type: "string", const: "patreon_membership_access" },
requiredTierTitle: { type: "string", minLength: 1 },
},
},
],
2026-04-29 13:22:01 +01:00
},
2026-04-26 12:25:11 +01:00
},
},
},
},
async (request, reply) => {
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
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;
2026-04-26 12:25:11 +01:00
}
);
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 };
}
2026-04-26 12:50:07 +01:00
const statePayload = parseOAuthStatePayload(verification.payload);
if (!statePayload || statePayload.provider !== request.params.provider) {
2026-04-26 12:25:11 +01:00
reply.code(400);
return { error: "state_provider_mismatch" };
}
2026-04-26 17:00:57 +01:00
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,
});
};
2026-04-26 12:25:11 +01:00
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");
2026-04-26 17:00:57 +01:00
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,
2026-04-29 15:00:55 +01:00
connection: statePayload.connection,
2026-04-26 17:00:57 +01:00
error: "provider_error",
...relayedDescriptionField,
2026-04-26 17:00:57 +01:00
});
}
2026-04-26 12:50:07 +01:00
if (prefersHtml(request.headers.accept)) {
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
2026-04-26 17:00:57 +01:00
handoffKind: handoff.kind,
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
2026-04-26 17:00:57 +01:00
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
error: "provider_error",
...relayedDescriptionField,
})
);
2026-04-26 12:50:07 +01:00
}
2026-04-26 12:25:11 +01:00
reply.code(400);
return {
error: "provider_error",
provider: request.params.provider,
providerError,
providerErrorDescription,
returnTo: statePayload.return_to,
2026-04-26 12:25:11 +01:00
};
}
if (!request.query.code) {
await denyBrowserAttempt("missing_code");
2026-04-26 17:00:57 +01:00
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,
2026-04-29 15:00:55 +01:00
connection: statePayload.connection,
2026-04-26 17:00:57 +01:00
error: "missing_code",
});
}
2026-04-26 12:50:07 +01:00
if (prefersHtml(request.headers.accept)) {
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
2026-04-26 17:00:57 +01:00
handoffKind: handoff.kind,
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
2026-04-26 17:00:57 +01:00
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
error: "missing_code",
})
);
2026-04-26 12:50:07 +01:00
}
2026-04-26 12:25:11 +01:00
reply.code(400);
return { error: "missing_code", returnTo: statePayload.return_to };
2026-04-26 12:25:11 +01:00
}
2026-04-26 12:50:07 +01:00
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.");
}
2026-04-26 12:50:07 +01:00
const tokenSet = await runtime.exchangeAuthorizationCode({
config,
code: request.query.code,
redirectUri: callbackUrl,
});
const identity = await runtime.resolveIdentity({
2026-04-29 15:00:55 +01:00
config,
2026-04-26 12:50:07 +01:00
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.");
}
}
}
2026-04-26 12:50:07 +01:00
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") {
2026-04-26 12:50:07 +01:00
accountUpdates.displayName = displayName;
}
if (identity.primaryEmail !== undefined && statePayload.mode !== "link") {
2026-04-26 12:50:07 +01:00
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);
2026-04-26 12:50:07 +01:00
if (tokenSet.refreshToken !== undefined) {
linkedIdentityInput.refreshTokenEncrypted = tokenCustody.encrypt(tokenSet.refreshToken);
2026-04-26 12:50:07 +01:00
}
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,
2026-04-29 13:22:01 +01:00
entitlementPolicy: statePayload.entitlement_policy,
2026-04-26 12:50:07 +01:00
},
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 } : {}),
2026-04-26 12:50:07 +01:00
};
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 };
2026-04-26 12:50:07 +01:00
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,
2026-04-29 15:00:55 +01:00
connection: statePayload.connection,
...delivered,
};
2026-04-26 17:00:57 +01:00
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,
2026-04-29 15:00:55 +01:00
connection: statePayload.connection,
2026-04-26 17:00:57 +01:00
account: accountSummary,
entitlements: entitlementEvaluation,
...delivered,
2026-04-26 17:00:57 +01:00
});
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,
2026-04-29 15:00:55 +01:00
connection: statePayload.connection,
2026-04-26 17:00:57 +01:00
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,
2026-04-26 12:50:07 +01:00
});
if (prefersHtml(request.headers.accept)) {
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
2026-04-26 17:00:57 +01:00
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 }
: {}),
})
);
2026-04-26 12:50:07 +01:00
}
reply.code(201);
return {
completion: {
code: completion.code,
expiresAt: completion.expiresAt,
redeemEndpoint: `${config.publicBaseUrl}/v1/apps/${statePayload.app_slug}/auth-completions/redeem`,
2026-04-26 12:50:07 +01:00
},
...completionPayload,
2026-04-26 12:50:07 +01:00
};
} 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.";
2026-04-26 12:50:07 +01:00
const nowIso = new Date().toISOString();
await denyBrowserAttempt(failureCode);
2026-04-26 12:50:07 +01:00
await store.createAuditEvent({
appSlug: statePayload.app_slug,
eventType: refusal ? "oauth_link_refused" : "oauth_callback_failed",
2026-04-26 12:50:07 +01:00
eventPayloadJson: {
provider: request.params.provider,
mode: statePayload.mode,
...errorIdentity(error),
providerStatus: typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599 ? status : null,
2026-04-26 12:50:07 +01:00
},
createdAt: nowIso,
});
2026-04-26 17:00:57 +01:00
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,
2026-04-29 15:00:55 +01:00
connection: statePayload.connection,
error: failureCode,
errorDescription: failure,
2026-04-26 17:00:57 +01:00
});
} catch {}
}
2026-04-26 12:50:07 +01:00
if (prefersHtml(request.headers.accept)) {
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
2026-04-26 17:00:57 +01:00
handoffKind: handoff.kind,
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
2026-04-26 17:00:57 +01:00
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
error: failureCode,
errorDescription: failure,
})
);
2026-04-26 12:50:07 +01:00
}
reply.code(refusal ? 403 : 502);
2026-04-26 12:50:07 +01:00
return {
error: failureCode,
detail: failure,
2026-04-26 12:50:07 +01:00
provider: request.params.provider,
appSlug: statePayload.app_slug,
returnTo: statePayload.return_to,
2026-04-26 12:50:07 +01:00
};
}
2026-04-26 12:25:11 +01:00
}
);
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) => {
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
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;
}
);
2026-06-09 20:22:58 +01:00
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) => {
R21.3: delete the unreachable registered-app resolution path The runtime app-registration writer (POST /v1/apps) was deleted in an earlier pass because it minted a client_secret nobody verified. This pass kept resolveAppProfile's registered-app fallback and listAppProfiles on the theory that AppSlug is an open string and dynamic registration was a live extension point. Soul falsified that: every HTTP route enumerates the four built-in slugs in its own JSON schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body field), so a registered row could never be resolved from a request. With the writer already gone, this was unreachable code behind a dead writer -- delete it rather than keep authenticating a door nobody can open. Deleted: src/app-registry.ts entirely (resolveAppProfile, listAppProfiles, isBuiltInApp, registeredAppToProfile, returnOriginsFromRedirectUris); StoredRegisteredApp and the findRegisteredApp/listRegisteredApps HeimdallStore methods (store/types.ts); their InMemoryStore and PostgresStore implementations, including seedRegisteredApp and mapRegisteredApp; the registered_apps table (store/schema.ts now drops it on any store built before this schema); tests/app-registry.test.ts. Every call site (app.ts x5, claims.ts, private-command-plane.ts) now calls the existing built-in-only getAppProfile (app-profiles.ts) directly -- sync, no store needed. If dynamic app registration is wanted later it returns as a designed, authenticated feature, not by resurrecting this vestige. Net -305 lines against the prior commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
const profile = getAppProfile(request.params.appSlug);
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
if (!profile) {
reply.code(404);
return { error: "unknown_app", detail: "No app is registered under that slug." };
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
}
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
if (!resolveAppCaller(config, profile.slug, request.headers)) {
2026-06-09 20:22:58 +01:00
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) {
2026-06-09 20:22:58 +01:00
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) {
2026-06-09 20:22:58 +01:00
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();
2026-06-09 20:22:58 +01:00
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.",
2026-06-09 20:22:58 +01:00
};
}
return {
status: "synced",
supportFact,
bifrostStatus: bifrostResponse.status,
2026-06-09 20:22:58 +01:00
};
}
);
2026-04-29 15:00:55 +01:00
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) => {
R21.3: delete the unreachable registered-app resolution path The runtime app-registration writer (POST /v1/apps) was deleted in an earlier pass because it minted a client_secret nobody verified. This pass kept resolveAppProfile's registered-app fallback and listAppProfiles on the theory that AppSlug is an open string and dynamic registration was a live extension point. Soul falsified that: every HTTP route enumerates the four built-in slugs in its own JSON schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body field), so a registered row could never be resolved from a request. With the writer already gone, this was unreachable code behind a dead writer -- delete it rather than keep authenticating a door nobody can open. Deleted: src/app-registry.ts entirely (resolveAppProfile, listAppProfiles, isBuiltInApp, registeredAppToProfile, returnOriginsFromRedirectUris); StoredRegisteredApp and the findRegisteredApp/listRegisteredApps HeimdallStore methods (store/types.ts); their InMemoryStore and PostgresStore implementations, including seedRegisteredApp and mapRegisteredApp; the registered_apps table (store/schema.ts now drops it on any store built before this schema); tests/app-registry.test.ts. Every call site (app.ts x5, claims.ts, private-command-plane.ts) now calls the existing built-in-only getAppProfile (app-profiles.ts) directly -- sync, no store needed. If dynamic app registration is wanted later it returns as a designed, authenticated feature, not by resurrecting this vestige. Net -305 lines against the prior commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:23 +02:00
const profile = getAppProfile(request.params.appSlug);
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
if (!profile) {
reply.code(404);
return { error: "unknown_app", detail: "No app is registered under that slug." };
Register apps at runtime instead of at build time App slugs were a compile-time union of four names. That was fine while every app was ours and it blocks what Heimdall is for: an app it has never heard of, running on someone else's machine, registering against a self-hosted instance without forking and rebuilding the auth authority. A creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server forever. AppSlug is now a string, which it always was on the wire; the union was a narrowing the type system could enforce only because the set was closed. Membership moved from appSlugs.includes to the registry, and where no store is reachable — the offline token verifier is the case that matters — it is a shape check, isAppSlug, rather than a false claim to know what exists. A host app verifying its own tokens should compare aud against its own slug, which it knows and Heimdall's built-in list does not. Registration is data end to end. Storage carries the profile and a hash of the client secret rather than the secret. Built-in profiles resolve first and their slugs are refused at registration, in both places, so a registration cannot shadow a shipped app and inherit its audience. POST /v1/apps answers in the RFC 7591 shape so an ordinary OAuth client library can read it, returning client_secret exactly once. It is gated on an operator secret and closed entirely until one is configured: an open registration endpoint on an OAuth provider lets anyone create a client that starts Discord and Patreon flows under the instance's identity, which is a phishing surface wearing the operator's name. A fresh instance is not accidentally open. Redirect URIs are validated rather than stored as given. They are where an authorization code is delivered, so a permissive one is a token-theft primitive: absolute, no fragment, https unless loopback. Six call sites assumed an app profile always exists. They now answer 404 for an app nobody registered, and claim issuance refuses outright rather than signing a token whose audience no verifier can resolve. 108 tests pass, 34 of them new. Zero type errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:11:41 +02:00
}
Add the single caller-identity organ; delete the five ad hoc predicates it replaces src/app-caller.ts::resolveAppCaller(config, appSlug, headers) is now the only code that decides 'this HTTP request is app X'. It reads one header (x-heimdall-app-secret) and one table (config.appSharedSecrets[appSlug]) and compares with the existing timing-safe secretMatches, moved here unchanged. Deleted, not gated: the slug-name trust (appSlug === 'repixelizer'), the handoff-kind trust (handoff.kind === 'backend_callback' implying identity), trustedGhostlightRefresh, and the four inline secret reads in app.ts. Every site that used to answer 'may this caller supply entitlement policy' differently now asks resolveAppCaller and produces the same 401 app_auth_required on null -- one predicate, one failure shape, applied uniformly across OAuth start, session refresh, patron-support/sync, and managed-credentials/resolve, regardless of handoff kind or app slug (R5, fork 1 option B). startOAuthFlow and refreshAppSession are extracted from the route handlers (a move, not new logic) so the private command plane can reach the same authority without an HTTP self-call: it already authenticates a command as appSlug by opening the envelope with that app's shared secret and checking sourceRuntimeId, so it constructs the AppCaller directly and calls the handler function. The two forged app.inject calls that set x-heimdall-app-secret from inside the process are gone; the plane's auth-completions/redeem self-call is untouched (that route's authority is the one-time completion code, no app secret involved).
2026-09-08 13:26:55 +02:00
if (!resolveAppCaller(config, profile.slug, request.headers)) {
2026-04-29 15:00:55 +01:00
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 };
2026-04-29 15:00:55 +01:00
}
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;
}
);
2026-04-26 12:25:11 +01:00
return app;
}