diff --git a/src/app-registry.ts b/src/app-registry.ts index ce20bcf..ec7a019 100644 --- a/src/app-registry.ts +++ b/src/app-registry.ts @@ -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 { - 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(" ")}`); diff --git a/src/app.ts b/src/app.ts index d27e72f..81c1e20 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 | 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("/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", { diff --git a/src/config.ts b/src/config.ts index 23941d1..c60b96a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,8 +44,6 @@ export interface HeimdallConfig { appRuntimeIds?: Partial>; appBackendCallbacks: Partial>; bifrostPatronSupportEndpoint?: string; - /** Operator secret gating runtime app registration. Unset means registration is closed. */ - appRegistrationSecret?: string; bifrostPatronSupportSecret?: string; storage: StorageConfig; providers: Record; @@ -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; } diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 89ec3ee..cbe7b29 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -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); diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 5c2637f..7a5b7de 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -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, }; } diff --git a/src/store/schema.ts b/src/store/schema.ts index 60300da..1a00c2d 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -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, diff --git a/src/store/types.ts b/src/store/types.ts index 65aa570..788828a 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -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 { diff --git a/tests/app-registration-endpoint.test.ts b/tests/app-registration-endpoint.test.ts deleted file mode 100644 index 046fa63..0000000 --- a/tests/app-registration-endpoint.test.ts +++ /dev/null @@ -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 { - 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 { - 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"); - }); -}); diff --git a/tests/app-registry.test.ts b/tests/app-registry.test.ts index a19ea01..05d1738 100644 --- a/tests/app-registry.test.ts +++ b/tests/app-registry.test.ts @@ -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 = {}, 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);