Add private Ghostlight session refresh

This commit is contained in:
Meta Vens 2026-08-22 01:27:28 +02:00
commit 5d777245ea
7 changed files with 113 additions and 1 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -1415,7 +1415,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
detail: error instanceof Error ? error.message : "Invalid entitlement policy definition.",
};
}
if (entitlementPolicies.length > 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",

View file

@ -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<string, unknown> }> {
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<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
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<Record<string, unknown>>();
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,

View file

@ -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}`,

View file

@ -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);

View file

@ -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",