diff --git a/schemas/heimdall.auth_refresh_command.v1.schema.json b/schemas/heimdall.auth_refresh_command.v1.schema.json new file mode 100644 index 0000000..4bc4659 --- /dev/null +++ b/schemas/heimdall.auth_refresh_command.v1.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "heimdall.auth_refresh_command.v1", + "type": "object", + "required": ["schema", "refreshToken", "entitlementPolicy"], + "properties": { + "schema": { "const": "heimdall.auth_refresh_command.v1" }, + "refreshToken": { "type": "string", "minLength": 1 }, + "entitlementPolicy": { + "type": "object", + "required": ["kind", "guildId", "allowedRoleIds"], + "properties": { + "kind": { "const": "discord_role_access" }, + "guildId": { "type": "string", "minLength": 1 }, + "allowedRoleIds": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/schemas/heimdall.auth_refresh_receipt.v1.schema.json b/schemas/heimdall.auth_refresh_receipt.v1.schema.json new file mode 100644 index 0000000..f6cdce1 --- /dev/null +++ b/schemas/heimdall.auth_refresh_receipt.v1.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "heimdall.auth_refresh_receipt.v1", + "type": "object", + "required": ["schema", "status"], + "properties": { + "schema": { "const": "heimdall.auth_refresh_receipt.v1" }, + "status": { "enum": ["authenticated", "denied"] }, + "error": { "type": "string" }, + "accessToken": { "type": "string", "minLength": 1 }, + "refreshToken": { "type": "string", "minLength": 1 }, + "session": { "type": "object" }, + "refresh": { "type": "object" }, + "account": { "type": "object" }, + "sharedCapabilities": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": true +} diff --git a/src/app.ts b/src/app.ts index c3e3c4d..930d011 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1415,7 +1415,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise 0 && request.params.appSlug !== "repixelizer") { + 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", diff --git a/src/private-command-plane.ts b/src/private-command-plane.ts index 4e54b10..aeb7d30 100644 --- a/src/private-command-plane.ts +++ b/src/private-command-plane.ts @@ -70,6 +70,8 @@ export async function startHeimdallPrivateCommandPlane( ? "heimdall.auth_begin_command.v1" : request.operation === "heimdall.auth.complete" ? "heimdall.auth_complete_command.v1" + : request.operation === "heimdall.auth.refresh" + ? "heimdall.auth_refresh_command.v1" : undefined; if (!expectedContentSchema || envelope.contentSchema !== expectedContentSchema) { throw new Error("Private command content schema does not match its operation."); @@ -158,9 +160,50 @@ async function executePrivateCommand( ): Promise<{ status: string; payloadSchema: string; payload: Record }> { 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); throw new Error(`Unsupported Heimdall private operation '${operation}'.`); } +async function refreshAuth( + app: FastifyInstance, + config: HeimdallConfig, + appSlug: AppSlug, + payload: Record, +): Promise<{ status: string; payloadSchema: string; payload: Record }> { + const refreshToken = String(payload.refreshToken ?? ""); + if (!refreshToken) throw new Error("Auth refresh requires the app's encrypted refresh claim."); + const entitlementPolicy = parseEntitlementPolicy(payload.entitlementPolicy); + 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 } : {}), + }, + }); + const refreshedPayload = refreshed.json>(); + if (refreshed.statusCode !== 201) { + return { + status: "denied", + payloadSchema: "heimdall.auth_refresh_receipt.v1", + payload: { + schema: "heimdall.auth_refresh_receipt.v1", + status: "denied", + error: String(refreshedPayload.error ?? "refresh_denied"), + }, + }; + } + return { + status: "accepted", + payloadSchema: "heimdall.auth_refresh_receipt.v1", + payload: { ...refreshedPayload, schema: "heimdall.auth_refresh_receipt.v1", status: "authenticated" }, + }; +} + async function beginAuth( app: FastifyInstance, config: HeimdallConfig, diff --git a/src/verse-state.ts b/src/verse-state.ts index ebda976..7d0db27 100644 --- a/src/verse-state.ts +++ b/src/verse-state.ts @@ -148,6 +148,11 @@ function buildCommandBoundary(config: HeimdallConfig, pulse: HeimdallRuntimePuls requestSchema: "heimdall.private_command_envelope.v1", responseSchema: "heimdall.auth_completion_receipt.v1", }, + { + operation: "heimdall.auth.refresh", + requestSchema: "heimdall.private_command_envelope.v1", + responseSchema: "heimdall.auth_refresh_receipt.v1", + }, ], privateRoute: { endpoint: `rudp://${config.privateCommandHost ?? "127.0.0.1"}:${config.privateCommandPort ?? 4101}`, diff --git a/tests/access-plugin.test.ts b/tests/access-plugin.test.ts index 7ab3a1a..7420860 100644 --- a/tests/access-plugin.test.ts +++ b/tests/access-plugin.test.ts @@ -136,6 +136,28 @@ describe("Heimdall Eve access plugin", () => { expect(completion.status).toBe("authenticated"); expect(completion.accessToken).toEqual(expect.any(String)); + const refreshEnvelope = sealPrivateEnvelope({ + appSlug: "ghostlight", + operation: "heimdall.auth.refresh", + contentSchema: "heimdall.auth_refresh_command.v1", + idempotencyKey: "refresh-1", + secret, + payload: { + refreshToken: completion.refreshToken, + entitlementPolicy: { + kind: "discord_role_access", + guildId: "gamecult-guild", + allowedRoleIds: ["role-ktlst"], + }, + }, + }); + const refreshResponse = await invoke(plane.endpoint, "heimdall.auth.refresh", "transport-refresh-1", refreshEnvelope); + expect(refreshResponse.status).toBe("accepted"); + const refresh = openPrivateEnvelope(decodeEnvelope(refreshResponse.payload), secret); + expect(refresh).toMatchObject({ schema: "heimdall.auth_refresh_receipt.v1", status: "authenticated" }); + expect(refresh.accessToken).toEqual(expect.any(String)); + expect(refresh.refreshToken).toEqual(expect.any(String)); + const retry = await invoke(plane.endpoint, "heimdall.auth.complete", "transport-complete-retry", completeEnvelope); expect(openPrivateEnvelope(decodeEnvelope(retry.payload), secret)).toEqual(completion); diff --git a/tests/verse-runtime.test.ts b/tests/verse-runtime.test.ts index c5a4342..3cd4ca1 100644 --- a/tests/verse-runtime.test.ts +++ b/tests/verse-runtime.test.ts @@ -50,6 +50,7 @@ describe("Heimdall verse runtime publication", () => { commands: [ { operation: "heimdall.auth.begin" }, { operation: "heimdall.auth.complete" }, + { operation: "heimdall.auth.refresh" }, ], privateRoute: { endpoint: "rudp://127.0.0.1:4101",