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