Heimdall/tests/security.test.ts
Heimdall 8e870d20a0 Google OIDC provider: link-only, sub-keyed, PKCE and nonce
Google is a Heimdall provider behind Bifrost, with no direct upstream.
Scopes openid email profile; the callback verifies the id_token
(RS256 against Google's keys, iss, aud, exp, iat, nonce) and requires
a verified email. The identity key is sub, never the email.

PKCE verifier and nonce are derived from the state jti and Heimdall's
signing key, so nothing is stored between start and callback.

Google never mints an account. mode=link needs a Heimdall access token
for the app and attaches to that account only; sign_in signs in an
identity already linked; an unknown sub is refused, and a sub held by
another account is never moved. The six existing providers still mint
on first login.

Admitted to the bifrost profile only. The Idunn recipe declares the
Google client id and secret file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 16:10:23 +00:00

123 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: {},
google: {},
},
...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");
});
});