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