diff --git a/src/app-caller.ts b/src/app-caller.ts new file mode 100644 index 0000000..2239be9 --- /dev/null +++ b/src/app-caller.ts @@ -0,0 +1,52 @@ +// The single caller-identity authority for app-backend HTTP requests. +// +// Before this module existed, "is this caller app X" was decided ad hoc in +// five places: three inline secret reads in app.ts, a slug-name trust +// (`appSlug === "repixelizer"`), and a handoff-kind trust +// (`handoff.kind === "backend_callback"`) that let a caller assert its own +// identity by choosing a request shape. A handler now receives an AppCaller +// value or nothing; it never reads the header or the secret table itself. +// +// The loopback private command plane is the second, non-HTTP entry to this +// same authority: it opens its envelope with the same per-app secret and +// checks sourceRuntimeId, then constructs an AppCaller directly and calls the +// same handler functions app.ts uses — it does not re-enter HTTP. + +import { timingSafeEqual } from "node:crypto"; +import { type AppSlug } from "./contracts.js"; +import { type HeimdallConfig } from "./config.js"; + +export interface AppCaller { + appSlug: AppSlug; +} + +/** Timing-safe comparator. The only comparator anywhere a shared secret is checked. */ +export function secretMatches(expected: string | undefined, provided: string | undefined): boolean { + if (!expected || !provided) { + return false; + } + + const expectedBuffer = Buffer.from(expected); + const providedBuffer = Buffer.from(provided); + return expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer); +} + +function getSharedSecretHeader(headers: Record): string | undefined { + const header = headers["x-heimdall-app-secret"]; + return Array.isArray(header) ? header[0] : header; +} + +/** + * Decide whether an HTTP request is authenticated as `appSlug`. Reads exactly + * one header (`x-heimdall-app-secret`) and one table + * (`config.appSharedSecrets[appSlug]`, per-app only — there is no global + * fallback). Returns null rather than throwing: callers turn null into the + * one 401 `app_auth_required` shape. + */ +export function resolveAppCaller( + config: HeimdallConfig, + appSlug: AppSlug, + headers: Record +): AppCaller | null { + return secretMatches(config.appSharedSecrets[appSlug], getSharedSecretHeader(headers)) ? { appSlug } : null; +} diff --git a/src/app.ts b/src/app.ts index 81c1e20..6332dc0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ -import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import { createHmac, randomUUID } from "node:crypto"; import cors from "@fastify/cors"; import Fastify, { type FastifyInstance } from "fastify"; +import { type AppCaller, resolveAppCaller } from "./app-caller.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js"; import { appSlugs, @@ -326,21 +327,6 @@ function entitlementPolicyProvider(policy: OAuthEntitlementPolicy): Provider { return policy.kind === "discord_role_access" ? "discord" : "patreon"; } -function getSharedSecret(request: { headers: Record }): string | undefined { - const header = request.headers["x-heimdall-app-secret"]; - return Array.isArray(header) ? header[0] : header; -} - -function secretMatches(expected: string | undefined, provided: string | undefined): boolean { - if (!expected || !provided) { - return false; - } - - const expectedBuffer = Buffer.from(expected); - const providedBuffer = Buffer.from(provided); - return expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer); -} - function signBifrostPatronSupportPayload(secret: string, body: string): string { const signature = createHmac("sha256", secret).update(body).digest("hex"); return `sha256=${signature}`; @@ -507,6 +493,209 @@ async function maybeDeliverBackendHandoff(handoff: OAuthHandoff, payload: Backen 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, + provider: Provider, + caller: AppCaller | null, + input: OAuthStartRequest +): Promise { + const { config, keys, store } = ctx; + const profile = await resolveAppProfile(store, input.appSlug); + if (!profile) { + return { statusCode: 404, body: { error: "unknown_app", detail: `No app '${input.appSlug}' is registered.` } }; + } + + if (!supportsProvider(profile, provider)) { + return { + statusCode: 400, + body: { error: "provider_not_supported_for_app", provider, appSlug: profile.slug }, + }; + } + + 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 (entitlementPolicy && (!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 { + 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 && (!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 { const config = options.config ?? loadConfig(); const store = options.store ?? (await createStore(config)); @@ -653,98 +842,10 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { - const { provider } = request.params; - const profile = await resolveAppProfile(store, request.body.appSlug); - if (!profile) { - reply.code(404); - return { error: "unknown_app", detail: `No app '${request.body.appSlug}' is registered.` }; - } - - if (!supportsProvider(profile, provider)) { - reply.code(400); - return { - error: "provider_not_supported_for_app", - provider, - appSlug: profile.slug, - }; - } - - let handoff: OAuthHandoff; - try { - handoff = normalizeOAuthHandoff(request.body.handoff); - } catch (error) { - reply.code(400); - return { - error: "invalid_handoff", - detail: error instanceof Error ? error.message : "Invalid OAuth handoff definition.", - }; - } - let entitlementPolicy: OAuthEntitlementPolicy | undefined; - try { - entitlementPolicy = normalizeEntitlementPolicy(request.body.entitlementPolicy); - } catch (error) { - reply.code(400); - return { - error: "invalid_entitlement_policy", - detail: error instanceof Error ? error.message : "Invalid entitlement policy definition.", - }; - } - if (handoff.kind === "backend_callback" && !acceptsBackendCallback(config, profile.slug, handoff)) { - reply.code(400); - return { - error: "untrusted_backend_callback", - detail: "Backend callback handoffs are only accepted for configured app callback URLs.", - }; - } - const trustedAppCaller = secretMatches(config.appSharedSecrets[profile.slug], getSharedSecret(request)); - if (entitlementPolicy && handoff.kind !== "backend_callback" && !trustedAppCaller) { - reply.code(400); - return { - error: "untrusted_entitlement_policy", - detail: "Caller-supplied entitlement policy is only accepted for trusted backend callback handoffs.", - }; - } - - const providerConfig = config.providers[provider]; - if (!providerConfig.clientId) { - reply.code(503); - return { - 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: request.body.mode, - returnTo: request.body.returnTo, - connection: request.body.connection, - handoff, - entitlementPolicy, - keys, - }); - - reply.code(201); - return { - provider, - appSlug: profile.slug, - mode: request.body.mode, - callbackUrl, - authorizationUrl: buildAuthorizationUrl({ - provider, - clientId: providerConfig.clientId, - redirectUri: callbackUrl, - state: state.token, - requestedScopes: request.body.requestedScopes, - }), - handoff, - stateToken: state.token, - stateExpiresAt: state.expiresAt, - }; + 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; } ); @@ -1347,91 +1448,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { - let entitlementPolicies: OAuthEntitlementPolicy[]; - try { - entitlementPolicies = normalizeEntitlementPolicies(request.body.entitlementPolicies); - const legacyPolicy = normalizeEntitlementPolicy(request.body.entitlementPolicy); - if (legacyPolicy) { - entitlementPolicies.push(legacyPolicy); - } - } catch (error) { - reply.code(400); - return { - error: "invalid_entitlement_policy", - detail: error instanceof Error ? error.message : "Invalid entitlement policy definition.", - }; - } - const trustedGhostlightRefresh = request.params.appSlug === "ghostlight" - && request.headers["x-heimdall-app-secret"] === config.appSharedSecrets.ghostlight; - if (entitlementPolicies.length > 0 && request.params.appSlug !== "repixelizer" && !trustedGhostlightRefresh) { - reply.code(400); - return { - error: "untrusted_entitlement_policy", - detail: "Caller-supplied entitlement policy is only accepted for Repixelizer session refresh.", - }; - } - - const refreshClaim = verifyRefreshToken(request.body.refreshToken, request.params.appSlug, config, keys); - if (!refreshClaim) { - reply.code(401); - return { error: "invalid_or_expired_refresh_token" }; - } - - const nowIso = new Date().toISOString(); - const persistedSession = await store.findSession(request.params.appSlug, refreshClaim.sid); - if (!persistedSession - || persistedSession.accountId !== refreshClaim.account_id - || persistedSession.accessRevision !== refreshClaim.access_revision - || Date.parse(persistedSession.expiresAt) <= Date.parse(nowIso)) { - reply.code(401); - return { 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, request.params.appSlug, nowIso); - const entitlementFactList = await evaluateStoredEntitlements({ - config, - store, - tokenCustody, - oauthRuntimes, - appSlug: request.params.appSlug, - accountId: refreshClaim.account_id, - entitlementPolicies, - now: nowIso, - }); - const issueInput: IssueAccessClaimInput = { - appSlug: request.params.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: request.params.appSlug, - eventType: "session_refreshed", - eventPayloadJson: { - grantedFacts: issued.claimSet.facts, - sharedCapabilities: issued.sharedCapabilities, - }, - createdAt: nowIso, - }); - - reply.code(201); - return issued; + 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; } ); @@ -1474,9 +1499,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise }> { if (operation === "heimdall.auth.begin") return await beginAuth(app, config, appSlug, payload); if (operation === "heimdall.auth.complete") return await completeAuth(app, appSlug, payload); - if (operation === "heimdall.auth.refresh") return await refreshAuth(app, config, appSlug, payload); + if (operation === "heimdall.auth.refresh") return await refreshAuth(app, appSlug, payload); if (operation === "heimdall.auth.logout") return await logoutAuth(app, config, appSlug, payload); throw new Error(`Unsupported Heimdall private operation '${operation}'.`); } @@ -212,7 +212,6 @@ async function logoutAuth( async function refreshAuth( app: FastifyInstance, - config: HeimdallConfig, appSlug: AppSlug, payload: Record, ): Promise<{ status: string; payloadSchema: string; payload: Record }> { @@ -222,17 +221,17 @@ async function refreshAuth( if (appSlug === "ghostlight" && (!entitlementPolicy || entitlementPolicy.kind !== "discord_role_access")) { throw new Error("Ghostlight refresh requires its caller-owned Discord role policy."); } - const refreshed = await app.inject({ - method: "POST", - url: `/v1/apps/${appSlug}/sessions/refresh`, - headers: { "x-heimdall-app-secret": config.appSharedSecrets[appSlug] ?? "" }, - payload: { - refreshToken, - ...(entitlementPolicy ? { entitlementPolicy } : {}), - }, + // The plane already authenticated this call as `appSlug` by opening the + // envelope with that app's shared secret and checking sourceRuntimeId; it + // constructs the AppCaller directly and calls the same handler function the + // HTTP route uses, rather than forging the HTTP header back at itself. + const context = getHeimdallRuntimeContext(app); + const result = await refreshAppSession(context, appSlug, { appSlug }, { + refreshToken, + ...(entitlementPolicy ? { entitlementPolicy } : {}), }); - const refreshedPayload = refreshed.json>(); - if (refreshed.statusCode !== 201) { + const refreshedPayload = result.body as Record; + if (result.statusCode !== 201) { return { status: "accepted", payloadSchema: "heimdall.auth_refresh_receipt.v1", @@ -274,21 +273,20 @@ async function beginAuth( createdAt: now.toISOString(), expiresAt: new Date(now.getTime() + config.stateTtlSeconds * 1000).toISOString(), }); - const start = await app.inject({ - method: "POST", - url: `/v1/oauth/${provider}/start`, - headers: { "x-heimdall-app-secret": config.appSharedSecrets[appSlug] ?? "" }, - payload: { - appSlug, - mode, - returnTo, - handoff: { kind: "browser_completion", attemptId: attempt.handle }, - ...(entitlementPolicy ? { entitlementPolicy } : {}), - }, + // Same authority, same non-HTTP entry as refreshAuth above: the plane + // constructs the AppCaller it already authenticated the envelope for and + // calls startOAuthFlow directly instead of forging the HTTP header. + const context = getHeimdallRuntimeContext(app); + const start = await startOAuthFlow({ config: context.config, keys: context.keys, store: context.store }, provider, { appSlug }, { + appSlug, + mode, + returnTo, + handoff: { kind: "browser_completion", attemptId: attempt.handle }, + ...(entitlementPolicy ? { entitlementPolicy } : {}), }); - const startPayload = start.json>(); + const startPayload = start.body as Record; if (start.statusCode !== 201) { - await getHeimdallRuntimeContext(app).store.updateAuthAttempt(appSlug, attempt.handle, { + await context.store.updateAuthAttempt(appSlug, attempt.handle, { status: "denied", at: new Date().toISOString(), denialCode: String(startPayload.error ?? "oauth_start_denied"),