Delete the runtime app registration surface (R7, fork 3)

registerApp minted a client_secret whose hash nothing ever verified (six
routes gate on the shared-secret caller-identity check, not on registration);
registered_apps sits empty on the live host. Deleted: POST /v1/apps,
getRegistrationSecret, config.appRegistrationSecret, registerApp/
hashClientSecret/verifyClientSecret, the client_secret_hash column (dropped
idempotently on startup, same as it is created), and
tests/app-registration-endpoint.test.ts. registered_apps and resolveAppProfile
stay as a profile store; app-registry.test.ts fixtures that used registerApp
purely to seed a profile now write through store.registerApp directly, the
same primitive the built-in-shadow test already used.
This commit is contained in:
Meta Vens 2026-09-08 13:21:18 +02:00
commit 776565effd
9 changed files with 45 additions and 376 deletions

View file

@ -10,8 +10,6 @@
// profile, which is what stops a registration from granting itself something
// Heimdall never decided was true.
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { builtInAppProfiles, type AppProfile } from "./app-profiles.js";
import { validateCapabilityRules, type CapabilityDefinition } from "./capability-rules.js";
import { providers, type Provider } from "./contracts.js";
@ -36,12 +34,6 @@ export interface RegistrationProblem {
reason: string;
}
export interface AppRegistrationResult {
app: StoredRegisteredApp;
/** Returned once, at registration. Only its hash is stored. */
clientSecret: string;
}
export function isBuiltInApp(slug: string): boolean {
return Object.hasOwn(builtInAppProfiles, slug);
}
@ -122,47 +114,6 @@ export function validateAppRegistration(request: AppRegistrationRequest): Regist
return problems;
}
export function hashClientSecret(secret: string): string {
// A generated 256-bit secret does not need a slow KDF; it needs to not be
// stored in the clear. Passwords would be a different question.
return createHash("sha256").update(secret, "utf8").digest("hex");
}
export function verifyClientSecret(secret: string, storedHash: string | null): boolean {
if (!storedHash) return false;
const candidate = Buffer.from(hashClientSecret(secret), "hex");
const expected = Buffer.from(storedHash, "hex");
if (candidate.length !== expected.length) return false;
return timingSafeEqual(candidate, expected);
}
export async function registerApp(
store: HeimdallStore,
request: AppRegistrationRequest,
registeredAt: string = new Date().toISOString(),
): Promise<AppRegistrationResult> {
const problems = validateAppRegistration(request);
if (problems.length > 0) {
throw new AppRegistrationError(problems);
}
const clientSecret = randomBytes(32).toString("base64url");
const app = await store.registerApp({
slug: request.slug,
displayName: request.displayName.trim(),
profileVersion: request.profileVersion?.trim() || registeredAt.slice(0, 10),
registeredAt,
identityProviders: request.identityProviders as Provider[],
entitlementSources: (request.entitlementSources ?? []) as Provider[],
managedConnectionProviders: (request.managedConnectionProviders ?? []) as Provider[],
capabilities: request.capabilities,
redirectUris: request.redirectUris,
clientSecretHash: hashClientSecret(clientSecret),
});
return { app, clientSecret };
}
export class AppRegistrationError extends Error {
constructor(readonly problems: RegistrationProblem[]) {
super(`App registration rejected: ${problems.map((problem) => problem.reason).join(" ")}`);

View file

@ -23,14 +23,7 @@ import {
} from "./contracts.js";
import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
import { builtInAppProfiles, getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js";
import {
AppRegistrationError,
listAppProfiles,
registerApp,
registeredAppToProfile,
resolveAppProfile,
type AppRegistrationRequest,
} from "./app-registry.js";
import { listAppProfiles, resolveAppProfile } from "./app-registry.js";
import { renderBrowserHandoffPage } from "./browser-handoff.js";
import { type HeimdallConfig, loadConfig } from "./config.js";
import { createTokenCustody, type TokenCustody } from "./custody.js";
@ -338,11 +331,6 @@ function getSharedSecret(request: { headers: Record<string, string | string[] |
return Array.isArray(header) ? header[0] : header;
}
function getRegistrationSecret(request: { headers: Record<string, unknown> }): string | undefined {
const header = request.headers["x-heimdall-registration-secret"];
return typeof header === "string" ? header : undefined;
}
function secretMatches(expected: string | undefined, provided: string | undefined): boolean {
if (!expected || !provided) {
return false;
@ -563,53 +551,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
apps: (await listAppProfiles(store)).map(serializeAppProfile),
}));
// POST /v1/apps registration.
//
// Gated on an operator secret rather than open. Heimdall is an OAuth
// provider: an unauthenticated registration endpoint lets anyone create a
// client that starts Discord and Patreon flows under this instance identity,
// which is a phishing surface wearing the operator name. Registration is
// closed until an operator sets one, so a fresh instance is not accidentally
// open.
app.post<{ Body: AppRegistrationRequest }>("/v1/apps", async (request, reply) => {
if (!config.appRegistrationSecret) {
reply.code(404);
return {
error: "registration_closed",
detail: "Runtime app registration is not enabled on this instance.",
};
}
if (!secretMatches(config.appRegistrationSecret, getRegistrationSecret(request))) {
reply.code(401);
return { error: "unauthorized", detail: "A valid registration secret is required." };
}
try {
const { app: registered, clientSecret } = await registerApp(store, request.body);
reply.code(201);
// RFC 7591 response shape, so an ordinary OAuth client library can read
// it. client_secret is returned exactly once; only its hash is stored.
return {
client_id: registered.slug,
client_secret: clientSecret,
client_name: registered.displayName,
redirect_uris: registered.redirectUris,
client_id_issued_at: Math.floor(Date.parse(registered.createdAt) / 1000),
profile: serializeAppProfile(registeredAppToProfile(registered)),
};
} catch (error) {
if (error instanceof AppRegistrationError) {
reply.code(400);
return {
error: "invalid_client_metadata",
detail: "App registration was rejected.",
problems: error.problems,
};
}
throw error;
}
});
app.get<{ Params: { appSlug: AppSlug } }>(
"/v1/apps/:appSlug",
{

View file

@ -44,8 +44,6 @@ export interface HeimdallConfig {
appRuntimeIds?: Partial<Record<AppSlug, string[]>>;
appBackendCallbacks: Partial<Record<AppSlug, string[]>>;
bifrostPatronSupportEndpoint?: string;
/** Operator secret gating runtime app registration. Unset means registration is closed. */
appRegistrationSecret?: string;
bifrostPatronSupportSecret?: string;
storage: StorageConfig;
providers: Record<Provider, ProviderClientConfig>;
@ -302,9 +300,6 @@ export function loadConfig(
}
if (env.GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET) {
if (env.GC_ACCESS_APP_REGISTRATION_SECRET) {
config.appRegistrationSecret = env.GC_ACCESS_APP_REGISTRATION_SECRET;
}
config.bifrostPatronSupportSecret = env.GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET;
}

View file

@ -63,7 +63,6 @@ export class InMemoryStore implements HeimdallStore {
managedConnectionProviders: [...input.managedConnectionProviders],
capabilities: structuredClone(input.capabilities),
redirectUris: [...input.redirectUris],
clientSecretHash: input.clientSecretHash,
};
this.registeredApps.set(record.slug, record);
return structuredClone(record);

View file

@ -75,7 +75,6 @@ interface RegisteredAppRow extends QueryResultRow {
managed_connection_providers: Provider[];
capabilities_json: CapabilityDefinition[];
redirect_uris: string[];
client_secret_hash: string | null;
}
interface SessionRow extends QueryResultRow {
@ -308,9 +307,9 @@ export class PostgresStore implements HeimdallStore {
INSERT INTO registered_apps (
slug, display_name, profile_version, created_at, updated_at,
identity_providers, entitlement_sources, managed_connection_providers,
capabilities_json, redirect_uris, client_secret_hash
capabilities_json, redirect_uris
)
VALUES ($1, $2, $3, $4, $4, $5, $6, $7, $8, $9, $10)
VALUES ($1, $2, $3, $4, $4, $5, $6, $7, $8, $9)
ON CONFLICT (slug)
DO UPDATE SET
display_name = EXCLUDED.display_name,
@ -320,8 +319,7 @@ export class PostgresStore implements HeimdallStore {
entitlement_sources = EXCLUDED.entitlement_sources,
managed_connection_providers = EXCLUDED.managed_connection_providers,
capabilities_json = EXCLUDED.capabilities_json,
redirect_uris = EXCLUDED.redirect_uris,
client_secret_hash = COALESCE(EXCLUDED.client_secret_hash, registered_apps.client_secret_hash)
redirect_uris = EXCLUDED.redirect_uris
RETURNING *
`,
[
@ -334,7 +332,6 @@ export class PostgresStore implements HeimdallStore {
JSON.stringify(input.managedConnectionProviders),
JSON.stringify(input.capabilities),
JSON.stringify(input.redirectUris),
input.clientSecretHash,
],
);
return mapRegisteredApp(result.rows[0]!);
@ -860,6 +857,5 @@ function mapRegisteredApp(row: RegisteredAppRow): StoredRegisteredApp {
managedConnectionProviders: row.managed_connection_providers,
capabilities: row.capabilities_json,
redirectUris: row.redirect_uris,
clientSecretHash: row.client_secret_hash,
};
}

View file

@ -9,10 +9,14 @@ CREATE TABLE IF NOT EXISTS registered_apps (
entitlement_sources JSONB NOT NULL,
managed_connection_providers JSONB NOT NULL,
capabilities_json JSONB NOT NULL,
redirect_uris JSONB NOT NULL,
client_secret_hash TEXT
redirect_uris JSONB NOT NULL
);
-- Runtime app registration (POST /v1/apps) minted a client_secret_hash that
-- nothing ever verified. Deleted with the caller-identity cut; drop the
-- column on any store built before this schema too.
ALTER TABLE registered_apps DROP COLUMN IF EXISTS client_secret_hash;
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,

View file

@ -11,8 +11,9 @@ import {
* An app registered at runtime.
*
* The built-in profiles are seed data of the same shape; nothing here is
* special-cased for them. clientSecretHash is stored rather than the secret,
* so a database read cannot impersonate a registered app.
* special-cased for them. There is no runtime registration authority anymore
* (deleted with the auth-hole cut: a minted client_secret nobody verified);
* this remains a profile store consulted by resolveAppProfile.
*/
export interface StoredRegisteredApp {
slug: string;
@ -25,7 +26,6 @@ export interface StoredRegisteredApp {
managedConnectionProviders: Provider[];
capabilities: CapabilityDefinition[];
redirectUris: string[];
clientSecretHash: string | null;
}
export interface RegisterAppInput {
@ -38,7 +38,6 @@ export interface RegisterAppInput {
managedConnectionProviders: Provider[];
capabilities: CapabilityDefinition[];
redirectUris: string[];
clientSecretHash: string | null;
}
export interface StoredAccount {

View file

@ -1,195 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { type FastifyInstance } from "fastify";
import { buildApp } from "../src/app.js";
import { type HeimdallConfig } from "../src/config.js";
import { identityFacts } from "../src/facts.js";
const REGISTRATION_SECRET = "operator-registration-secret";
function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
return {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
workspaceRoot: ".",
dataRoot: "./.heimdall-data",
cultCachePath: "./.heimdall-data/cultcache/heimdall.service.cc",
publicBaseUrl: "https://heimdall.test",
issuer: "https://heimdall.test",
daemonId: "test-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
providerHealthIdentityPath: "./.heimdall-data/provider-health-identity.cc",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 60 * 60 * 24 * 30,
stateTtlSeconds: 600,
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: {},
appBackendCallbacks: {},
storage: { backend: "memory", applySchemaOnStartup: true },
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
github: { clientId: "github-client", clientSecret: "github-secret" },
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
},
...overrides,
} as HeimdallConfig;
}
const registration = {
slug: "erycina",
displayName: "Erycina",
identityProviders: ["discord"],
redirectUris: ["https://erycina.test/auth/callback"],
capabilities: [
{ key: "viewer_access", mode: "shared", summary: "Signed-in viewer.", anyOf: [identityFacts.authenticated] },
],
};
const apps: FastifyInstance[] = [];
afterEach(async () => {
while (apps.length > 0) await apps.pop()!.close();
});
async function buildOpenInstance(): Promise<FastifyInstance> {
const app = await buildApp({ config: createTestConfig({ appRegistrationSecret: REGISTRATION_SECRET }) });
apps.push(app);
return app;
}
describe("POST /v1/apps", () => {
it("is closed on an instance with no registration secret configured", async () => {
// A fresh instance must not be accidentally open. Heimdall is an OAuth
// provider; an open registration endpoint lets anyone create a client that
// starts provider flows under this instance's identity.
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({ method: "POST", url: "/v1/apps", payload: registration });
expect(response.statusCode).toBe(404);
expect(response.json().error).toBe("registration_closed");
});
it("refuses a request with no registration secret", async () => {
const app = await buildOpenInstance();
const response = await app.inject({ method: "POST", url: "/v1/apps", payload: registration });
expect(response.statusCode).toBe(401);
});
it("refuses a request with the wrong registration secret", async () => {
const app = await buildOpenInstance();
const response = await app.inject({
method: "POST",
url: "/v1/apps",
headers: { "x-heimdall-registration-secret": "not-it" },
payload: registration,
});
expect(response.statusCode).toBe(401);
});
it("registers an app and returns an RFC 7591 shaped response", async () => {
const app = await buildOpenInstance();
const response = await app.inject({
method: "POST",
url: "/v1/apps",
headers: { "x-heimdall-registration-secret": REGISTRATION_SECRET },
payload: registration,
});
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.client_id).toBe("erycina");
expect(body.client_secret).toBeTruthy();
expect(body.client_name).toBe("Erycina");
expect(body.redirect_uris).toEqual(["https://erycina.test/auth/callback"]);
expect(typeof body.client_id_issued_at).toBe("number");
});
it("rejects an invalid registration with the problems that caused it", async () => {
const app = await buildOpenInstance();
const response = await app.inject({
method: "POST",
url: "/v1/apps",
headers: { "x-heimdall-registration-secret": REGISTRATION_SECRET },
payload: { ...registration, slug: "bifrost" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("invalid_client_metadata");
expect(JSON.stringify(response.json().problems)).toMatch(/reserved by a built-in/);
});
it("rejects a capability rule naming a fact Heimdall does not produce", async () => {
const app = await buildOpenInstance();
const response = await app.inject({
method: "POST",
url: "/v1/apps",
headers: { "x-heimdall-registration-secret": REGISTRATION_SECRET },
payload: {
...registration,
capabilities: [{ key: "admin", mode: "shared", summary: "x", anyOf: ["invented.superuser"] }],
},
});
expect(response.statusCode).toBe(400);
expect(JSON.stringify(response.json().problems)).toMatch(/Unknown term/);
});
});
describe("app discovery after registration", () => {
it("lists a registered app alongside the built-ins", async () => {
const app = await buildOpenInstance();
await app.inject({
method: "POST",
url: "/v1/apps",
headers: { "x-heimdall-registration-secret": REGISTRATION_SECRET },
payload: registration,
});
const listed = (await app.inject({ method: "GET", url: "/v1/apps" })).json();
const slugs = listed.apps.map((profile: { slug: string }) => profile.slug);
expect(slugs).toContain("erycina");
expect(slugs).toContain("bifrost");
});
it("serves a registered app's profile by slug", async () => {
const app = await buildOpenInstance();
await app.inject({
method: "POST",
url: "/v1/apps",
headers: { "x-heimdall-registration-secret": REGISTRATION_SECRET },
payload: registration,
});
const response = await app.inject({ method: "GET", url: "/v1/apps/erycina" });
expect(response.statusCode).toBe(200);
expect(response.json().displayName).toBe("Erycina");
});
it("404s an app nobody registered, rather than serving an empty profile", async () => {
const app = await buildOpenInstance();
const response = await app.inject({ method: "GET", url: "/v1/apps/nobody" });
expect(response.statusCode).toBe(404);
expect(response.json().error).toBe("unknown_app");
});
it("still serves the built-in profiles", async () => {
const app = await buildOpenInstance();
const response = await app.inject({ method: "GET", url: "/v1/apps/repixelizer" });
expect(response.statusCode).toBe(200);
expect(response.json().slug).toBe("repixelizer");
});
});

View file

@ -1,17 +1,14 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
AppRegistrationError,
hashClientSecret,
isBuiltInApp,
listAppProfiles,
registerApp,
resolveAppProfile,
validateAppRegistration,
verifyClientSecret,
type AppRegistrationRequest,
} from "../src/app-registry.js";
import { evaluateCapabilityRules } from "../src/capability-rules.js";
import { type Provider } from "../src/contracts.js";
import { entitlementFacts, grantFacts, identityFacts } from "../src/facts.js";
import { InMemoryStore } from "../src/store/in-memory.js";
@ -31,6 +28,28 @@ beforeEach(() => {
store = new InMemoryStore();
});
/**
* There is no runtime registration authority anymore (deleted with the
* caller-identity cut: registerApp minted a client_secret nobody verified).
* `registered_apps` remains a profile store, so tests that need a resolvable
* profile write the row directly through the store primitive, the same way
* production code would seed one outside the deleted HTTP surface.
*/
async function seedRegisteredApp(overrides: Partial<AppRegistrationRequest> = {}, registeredAt = "2026-01-01T00:00:00.000Z") {
const req = request(overrides);
return store.registerApp({
slug: req.slug,
displayName: req.displayName.trim(),
profileVersion: req.profileVersion?.trim() || registeredAt.slice(0, 10),
registeredAt,
identityProviders: req.identityProviders as Provider[],
entitlementSources: (req.entitlementSources ?? []) as Provider[],
managedConnectionProviders: (req.managedConnectionProviders ?? []) as Provider[],
capabilities: req.capabilities,
redirectUris: req.redirectUris,
});
}
describe("validateAppRegistration", () => {
it("accepts a well-formed registration", () => {
expect(validateAppRegistration(request())).toEqual([]);
@ -100,45 +119,9 @@ describe("validateAppRegistration", () => {
});
});
describe("registerApp", () => {
it("stores the app and returns a secret exactly once", async () => {
const { app, clientSecret } = await registerApp(store, request());
expect(app.slug).toBe("erycina");
expect(clientSecret).toBeTruthy();
// The secret is returned, never stored.
expect(app).not.toHaveProperty("clientSecret");
expect(app.clientSecretHash).toBe(hashClientSecret(clientSecret));
});
it("refuses an invalid registration rather than storing a broken profile", async () => {
await expect(registerApp(store, request({ slug: "bifrost" }))).rejects.toBeInstanceOf(AppRegistrationError);
expect(await store.findRegisteredApp("bifrost")).toBeNull();
});
it("keeps the original creation time when an app re-registers", async () => {
const first = await registerApp(store, request(), "2026-01-01T00:00:00.000Z");
const second = await registerApp(store, request({ displayName: "Erycina v2" }), "2026-06-01T00:00:00.000Z");
expect(second.app.createdAt).toBe(first.app.createdAt);
expect(second.app.updatedAt).toBe("2026-06-01T00:00:00.000Z");
expect(second.app.displayName).toBe("Erycina v2");
});
});
describe("verifyClientSecret", () => {
it("accepts the issued secret and rejects anything else", async () => {
const { app, clientSecret } = await registerApp(store, request());
expect(verifyClientSecret(clientSecret, app.clientSecretHash)).toBe(true);
expect(verifyClientSecret("not-the-secret", app.clientSecretHash)).toBe(false);
expect(verifyClientSecret(clientSecret, null)).toBe(false);
});
});
describe("resolveAppProfile", () => {
it("resolves a registered app", async () => {
await registerApp(store, request());
await seedRegisteredApp();
const profile = await resolveAppProfile(store, "erycina");
expect(profile?.displayName).toBe("Erycina");
@ -165,7 +148,6 @@ describe("resolveAppProfile", () => {
managedConnectionProviders: [],
capabilities: [],
redirectUris: [],
clientSecretHash: null,
});
expect((await resolveAppProfile(store, "bifrost"))?.displayName).toBe("Bifrost");
@ -174,15 +156,12 @@ describe("resolveAppProfile", () => {
describe("a registered profile evaluates like a built-in", () => {
it("grants capabilities through the same evaluator", async () => {
await registerApp(
store,
request({
capabilities: [
{ key: "member_access", mode: "shared", summary: "member", anyOf: [entitlementFacts.appAccess, grantFacts.globalMember] },
{ key: "post_create", mode: "shared", summary: "post", anyOf: ["member_access"] },
],
}),
);
await seedRegisteredApp({
capabilities: [
{ key: "member_access", mode: "shared", summary: "member", anyOf: [entitlementFacts.appAccess, grantFacts.globalMember] },
{ key: "post_create", mode: "shared", summary: "post", anyOf: ["member_access"] },
],
});
const profile = await resolveAppProfile(store, "erycina");
expect(evaluateCapabilityRules(profile!.capabilities, new Set([grantFacts.globalMember]))).toEqual([
@ -195,7 +174,7 @@ describe("a registered profile evaluates like a built-in", () => {
describe("listAppProfiles", () => {
it("lists built-ins alongside registered apps", async () => {
await registerApp(store, request());
await seedRegisteredApp();
const slugs = (await listAppProfiles(store)).map((profile) => profile.slug);
expect(slugs).toContain("bifrost");
@ -203,7 +182,7 @@ describe("listAppProfiles", () => {
});
it("lists each app once", async () => {
await registerApp(store, request());
await seedRegisteredApp();
const slugs = (await listAppProfiles(store)).map((profile) => profile.slug);
expect(new Set(slugs).size).toBe(slugs.length);