Joystick joins the catalog (Basic client auth, rotatesRefreshTokens), resolves identity by channel id, and is a streampixels managed-connection provider only: it is not an identity provider for the profile (ruling signin-waits-for-public-bot). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
4.1 KiB
TypeScript
122 lines
4.1 KiB
TypeScript
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { type HeimdallConfig } from "../src/config.js";
|
|
import { createTokenCustody } from "../src/custody.js";
|
|
import { createRuntimeKeyMaterial } from "../src/signing.js";
|
|
|
|
function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
|
|
return {
|
|
serviceName: "heimdall",
|
|
host: "127.0.0.1",
|
|
port: 4100,
|
|
workspaceRoot: "E:/Projects/Heimdall",
|
|
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
|
|
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
|
|
publicBaseUrl: "https://heimdall.gamecult.org",
|
|
issuer: "https://heimdall.gamecult.org",
|
|
daemonId: "yggdrasil-heimdall",
|
|
idunnRudpHealth: undefined,
|
|
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
|
|
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
|
|
sessionTtlSeconds: 3600,
|
|
refreshTtlSeconds: 60 * 60 * 24 * 30,
|
|
stateTtlSeconds: 600,
|
|
completionTtlSeconds: 300,
|
|
bootstrapSigningPrivateKeyOnMissing: false,
|
|
appSharedSecrets: {},
|
|
appBackendCallbacks: {},
|
|
storage: {
|
|
backend: "memory",
|
|
applySchemaOnStartup: true,
|
|
},
|
|
providers: {
|
|
discord: {},
|
|
patreon: {},
|
|
github: {},
|
|
twitch: {},
|
|
youtube: {},
|
|
spotify: {},
|
|
joystick: {},
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
while (tempDirs.length) {
|
|
rmSync(tempDirs.pop() ?? "", { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
describe("Heimdall security helpers", () => {
|
|
it("round-trips provider tokens through AES-GCM custody", () => {
|
|
const custody = createTokenCustody({
|
|
...createTestConfig(),
|
|
tokenEncryptionKeyBase64: Buffer.alloc(32, 23).toString("base64"),
|
|
});
|
|
|
|
const encrypted = custody.encrypt("discord-access-token");
|
|
expect(encrypted).toMatch(/^gc_tok_v1:/);
|
|
expect(encrypted).not.toBe("discord-access-token");
|
|
expect(custody.decrypt(encrypted)).toBe("discord-access-token");
|
|
});
|
|
|
|
it("refuses a token key that does not decode to 32 bytes, without echoing it", () => {
|
|
for (const length of [16, 31, 33]) {
|
|
const key = Buffer.alloc(length, 7).toString("base64");
|
|
const message = (() => {
|
|
try {
|
|
createTokenCustody({ ...createTestConfig(), tokenEncryptionKeyBase64: key });
|
|
} catch (error) {
|
|
return (error as Error).message;
|
|
}
|
|
throw new Error(`a ${length}-byte key was accepted`);
|
|
})();
|
|
expect(message).toMatch(/GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE must decode to exactly 32 bytes/);
|
|
expect(message).not.toContain(key);
|
|
}
|
|
});
|
|
|
|
it("refuses persistent provider-token storage without a stable encryption key", () => {
|
|
expect(() =>
|
|
createTokenCustody(
|
|
createTestConfig({
|
|
storage: {
|
|
backend: "postgres",
|
|
applySchemaOnStartup: true,
|
|
databaseUrl: "postgres://127.0.0.1/heimdall",
|
|
},
|
|
})
|
|
)
|
|
).toThrow(/GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64/);
|
|
});
|
|
|
|
it("bootstraps and then reuses a file-backed signing key", () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "heimdall-signing-"));
|
|
tempDirs.push(tempDir);
|
|
const signingKeyPath = join(tempDir, "keys", "heimdall-signing-key.pem");
|
|
|
|
const first = createRuntimeKeyMaterial(
|
|
createTestConfig({
|
|
signingPrivateKeyPath: signingKeyPath,
|
|
bootstrapSigningPrivateKeyOnMissing: true,
|
|
})
|
|
);
|
|
const second = createRuntimeKeyMaterial(
|
|
createTestConfig({
|
|
signingPrivateKeyPath: signingKeyPath,
|
|
bootstrapSigningPrivateKeyOnMissing: false,
|
|
})
|
|
);
|
|
|
|
expect(first.source).toBe("bootstrapped_file");
|
|
expect(second.source).toBe("configured_file");
|
|
expect(first.kid).toBe(second.kid);
|
|
expect(existsSync(signingKeyPath)).toBe(true);
|
|
expect(readFileSync(signingKeyPath, "utf8")).toContain("BEGIN PRIVATE KEY");
|
|
});
|
|
});
|