Add private Ghostlight session refresh
This commit is contained in:
parent
19e5109cf4
commit
5d777245ea
7 changed files with 113 additions and 1 deletions
21
schemas/heimdall.auth_refresh_command.v1.schema.json
Normal file
21
schemas/heimdall.auth_refresh_command.v1.schema.json
Normal 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
|
||||
}
|
||||
18
schemas/heimdall.auth_refresh_receipt.v1.schema.json
Normal file
18
schemas/heimdall.auth_refresh_receipt.v1.schema.json
Normal 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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue