2026-10-19 13:27:28 +02:00
|
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
2026-09-30 20:40:13 +02:00
|
|
|
import { inspect } from "node:util";
|
2026-04-26 12:25:11 +01:00
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
|
import { type FastifyInstance } from "fastify";
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
import { buildApp, errorIdentity } from "../src/app.js";
|
2026-10-19 13:23:53 +02:00
|
|
|
import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js";
|
2026-09-30 17:07:45 +02:00
|
|
|
import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js";
|
2026-09-08 13:16:19 +02:00
|
|
|
import { type HeimdallConfig, loadConfig } from "../src/config.js";
|
2026-09-08 13:14:52 +02:00
|
|
|
import { entitlementFacts, identityFacts } from "../src/facts.js";
|
2026-10-19 11:11:34 +02:00
|
|
|
import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js";
|
2026-10-19 13:27:28 +02:00
|
|
|
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
|
2026-04-26 16:23:29 +01:00
|
|
|
import { InMemoryStore } from "../src/store/index.js";
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
function createTestConfig(): HeimdallConfig {
|
|
|
|
|
return {
|
|
|
|
|
serviceName: "heimdall",
|
|
|
|
|
host: "127.0.0.1",
|
|
|
|
|
port: 4100,
|
2026-06-16 18:01:07 +01:00
|
|
|
workspaceRoot: "E:/Projects/Heimdall",
|
|
|
|
|
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
|
|
|
|
|
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
|
2026-04-26 12:25:11 +01:00
|
|
|
publicBaseUrl: "https://heimdall.gamecult.org",
|
|
|
|
|
issuer: "https://heimdall.gamecult.org",
|
2026-06-16 18:01:07 +01:00
|
|
|
daemonId: "yggdrasil-heimdall",
|
|
|
|
|
idunnRudpHealth: undefined,
|
|
|
|
|
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
|
2026-08-20 19:56:35 +02:00
|
|
|
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
|
2026-04-26 12:25:11 +01:00
|
|
|
sessionTtlSeconds: 3600,
|
2026-05-20 13:43:53 +01:00
|
|
|
refreshTtlSeconds: 60 * 60 * 24 * 30,
|
2026-04-26 12:25:11 +01:00
|
|
|
stateTtlSeconds: 600,
|
2026-04-26 15:23:04 +01:00
|
|
|
completionTtlSeconds: 300,
|
2026-04-26 16:23:29 +01:00
|
|
|
bootstrapSigningPrivateKeyOnMissing: false,
|
|
|
|
|
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
2026-04-29 15:00:55 +01:00
|
|
|
appSharedSecrets: {
|
|
|
|
|
streampixels: "streampixels-secret",
|
2026-09-08 13:31:42 +02:00
|
|
|
repixelizer: "repixelizer-secret",
|
|
|
|
|
bifrost: "bifrost-secret",
|
2026-04-29 15:00:55 +01:00
|
|
|
},
|
2026-06-02 19:25:14 +01:00
|
|
|
appBackendCallbacks: {
|
2026-06-08 21:04:51 +01:00
|
|
|
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
|
2026-09-08 13:15:25 +02:00
|
|
|
repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"],
|
2026-06-02 19:25:14 +01:00
|
|
|
},
|
2026-04-26 12:50:07 +01:00
|
|
|
storage: {
|
|
|
|
|
backend: "memory",
|
|
|
|
|
applySchemaOnStartup: true,
|
|
|
|
|
},
|
2026-04-26 12:25:11 +01:00
|
|
|
providers: {
|
|
|
|
|
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
|
|
|
|
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
|
|
|
|
|
github: { clientId: "github-client", clientSecret: "github-secret" },
|
|
|
|
|
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
|
|
|
|
|
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
2026-06-02 16:25:38 +01:00
|
|
|
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
2026-10-19 11:13:40 +02:00
|
|
|
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
|
2026-04-26 12:25:11 +01:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 12:50:07 +01:00
|
|
|
function createMockDiscordRuntime(): OAuthProviderRuntime {
|
|
|
|
|
return {
|
|
|
|
|
async exchangeAuthorizationCode() {
|
|
|
|
|
return {
|
|
|
|
|
accessToken: "discord-access-token",
|
|
|
|
|
refreshToken: "discord-refresh-token",
|
|
|
|
|
tokenType: "Bearer",
|
2026-04-29 12:41:43 +01:00
|
|
|
scope: ["identify", "guilds.members.read"],
|
2026-04-26 12:50:07 +01:00
|
|
|
expiresAt: "2026-04-26T13:00:00.000Z",
|
|
|
|
|
raw: { source: "test" },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async resolveIdentity() {
|
|
|
|
|
return {
|
|
|
|
|
provider: "discord",
|
|
|
|
|
providerUserId: "discord-user-123",
|
|
|
|
|
username: "meta",
|
|
|
|
|
displayName: "Meta",
|
|
|
|
|
primaryEmail: "meta@gamecult.org",
|
|
|
|
|
profile: { id: "discord-user-123", username: "meta" },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async evaluateEntitlements({ callback }) {
|
|
|
|
|
return {
|
2026-04-26 14:58:58 +01:00
|
|
|
facts: [entitlementFacts.appAccess],
|
2026-04-26 12:50:07 +01:00
|
|
|
snapshots: [
|
|
|
|
|
{
|
|
|
|
|
accountId: callback.accountId,
|
|
|
|
|
provider: "discord",
|
|
|
|
|
scope: "repixelizer:discord_role_access:gamecult-guild",
|
|
|
|
|
evaluatedAt: "2026-04-26T12:00:00.000Z",
|
|
|
|
|
isAllowed: true,
|
|
|
|
|
reasonCode: "matched_role",
|
|
|
|
|
reasonDetail: "Matched Repixelizer access role.",
|
|
|
|
|
rawSummaryJson: {
|
|
|
|
|
guildId: "gamecult-guild",
|
|
|
|
|
matchedRoles: ["role-repixelizer"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 12:25:11 +01:00
|
|
|
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
2026-04-26 17:00:57 +01:00
|
|
|
const originalFetch = globalThis.fetch;
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
function getPublicKey(app: FastifyInstance) {
|
|
|
|
|
return (
|
|
|
|
|
app as FastifyInstance & {
|
|
|
|
|
heimdallContext: {
|
|
|
|
|
keys: {
|
|
|
|
|
publicKey: Parameters<typeof verifyJwt>[1];
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
).heimdallContext.keys.publicKey;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 16:23:29 +01:00
|
|
|
function getHeimdallContext(app: FastifyInstance) {
|
|
|
|
|
return (app as FastifyInstance & {
|
|
|
|
|
heimdallContext: {
|
|
|
|
|
keys: {
|
|
|
|
|
publicKey: Parameters<typeof verifyJwt>[1];
|
|
|
|
|
publicJwk: { alg: "EdDSA"; kid: string; use: "sig"; [key: string]: unknown };
|
|
|
|
|
};
|
|
|
|
|
tokenCustody: {
|
2026-06-09 20:22:58 +01:00
|
|
|
encrypt(plaintext: string): string;
|
2026-04-26 16:23:29 +01:00
|
|
|
decrypt(ciphertext: string): string;
|
|
|
|
|
};
|
|
|
|
|
store: InMemoryStore;
|
|
|
|
|
};
|
|
|
|
|
}).heimdallContext;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 12:50:07 +01:00
|
|
|
async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/discord/start",
|
|
|
|
|
payload: {
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
|
|
|
return response.json().stateToken as string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 12:25:11 +01:00
|
|
|
afterEach(async () => {
|
2026-04-26 17:00:57 +01:00
|
|
|
globalThis.fetch = originalFetch;
|
2026-04-26 12:25:11 +01:00
|
|
|
while (apps.length) {
|
|
|
|
|
await apps.pop()?.close();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 12:50:07 +01:00
|
|
|
describe("Heimdall service", () => {
|
2026-04-26 12:25:11 +01:00
|
|
|
it("serves discovery and JWKS metadata", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const discoveryResponse = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: "/.well-known/heimdall-configuration",
|
|
|
|
|
});
|
|
|
|
|
expect(discoveryResponse.statusCode).toBe(200);
|
|
|
|
|
expect(discoveryResponse.json()).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
issuer: "https://heimdall.gamecult.org",
|
|
|
|
|
jwksUri: "https://heimdall.gamecult.org/.well-known/jwks.json",
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const jwksResponse = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: "/.well-known/jwks.json",
|
|
|
|
|
});
|
|
|
|
|
expect(jwksResponse.statusCode).toBe(200);
|
|
|
|
|
expect(jwksResponse.json()).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
keys: [
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
alg: "EdDSA",
|
|
|
|
|
kid: expect.any(String),
|
|
|
|
|
use: "sig",
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-06-16 18:01:07 +01:00
|
|
|
}, 15_000);
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
it("builds signed OAuth start state for supported providers", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/discord/start",
|
|
|
|
|
payload: {
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
|
|
|
const payload = response.json();
|
|
|
|
|
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
|
|
|
|
|
2026-04-26 12:50:07 +01:00
|
|
|
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
|
|
|
|
expect(verified.valid).toBe(true);
|
|
|
|
|
if (verified.valid) {
|
|
|
|
|
expect(verified.payload).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
typ: "heimdall_oauth_state",
|
|
|
|
|
provider: "discord",
|
|
|
|
|
app_slug: "repixelizer",
|
2026-04-29 13:22:01 +01:00
|
|
|
entitlement_policy: null,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-08 13:31:42 +02:00
|
|
|
it("rejects caller-owned entitlement policy without a caller, regardless of handoff kind", async () => {
|
|
|
|
|
// Policy trust is decided by resolveAppCaller alone now (R5, fork 1B):
|
|
|
|
|
// handoff.kind no longer substitutes for identity, so the same 401 applies
|
|
|
|
|
// to a browser-completion start as it would to a backend-callback one.
|
2026-04-29 13:22:01 +01:00
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/discord/start",
|
|
|
|
|
payload: {
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
|
|
|
entitlementPolicy: {
|
|
|
|
|
kind: "discord_role_access",
|
|
|
|
|
guildId: "gamecult-guild",
|
|
|
|
|
allowedRoleIds: ["role-repixelizer"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-08 13:31:42 +02:00
|
|
|
expect(response.statusCode).toBe(401);
|
|
|
|
|
expect(response.json()).toEqual(expect.objectContaining({ error: "app_auth_required" }));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("accepts caller-owned entitlement policy for a browser completion handoff when the caller is trusted", async () => {
|
|
|
|
|
// The other half of the same pin: a caller holding the secret may attach
|
|
|
|
|
// policy even without a backend_callback handoff, because identity no
|
|
|
|
|
// longer rides on handoff kind.
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/discord/start",
|
|
|
|
|
headers: { "x-heimdall-app-secret": "repixelizer-secret" },
|
|
|
|
|
payload: {
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
|
|
|
entitlementPolicy: {
|
|
|
|
|
kind: "discord_role_access",
|
|
|
|
|
guildId: "gamecult-guild",
|
|
|
|
|
allowedRoleIds: ["role-repixelizer"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(201);
|
2026-04-29 13:22:01 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("accepts caller-owned entitlement policy for trusted backend handoffs", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/discord/start",
|
2026-09-08 13:31:42 +02:00
|
|
|
headers: { "x-heimdall-app-secret": "repixelizer-secret" },
|
2026-04-29 13:22:01 +01:00
|
|
|
payload: {
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
|
|
|
handoff: {
|
|
|
|
|
kind: "backend_callback",
|
|
|
|
|
attemptId: "attempt-123",
|
|
|
|
|
callbackUrl: "https://repixelizer.gamecult.org/api/auth/heimdall/callback",
|
|
|
|
|
},
|
|
|
|
|
entitlementPolicy: {
|
|
|
|
|
kind: "discord_role_access",
|
|
|
|
|
guildId: "gamecult-guild",
|
|
|
|
|
allowedRoleIds: ["role-repixelizer"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
|
|
|
const payload = response.json();
|
|
|
|
|
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
|
|
|
|
|
|
|
|
|
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
|
|
|
|
expect(verified.valid).toBe(true);
|
|
|
|
|
if (verified.valid) {
|
|
|
|
|
expect(verified.payload).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
typ: "heimdall_oauth_state",
|
|
|
|
|
provider: "discord",
|
|
|
|
|
app_slug: "repixelizer",
|
|
|
|
|
entitlement_policy: {
|
|
|
|
|
kind: "discord_role_access",
|
|
|
|
|
guildId: "gamecult-guild",
|
|
|
|
|
allowedRoleIds: ["role-repixelizer"],
|
|
|
|
|
},
|
2026-04-26 12:50:07 +01:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-26 12:25:11 +01:00
|
|
|
});
|
|
|
|
|
|
2026-04-29 14:36:03 +01:00
|
|
|
it("builds Patreon OAuth start state with caller-owned membership policy", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/patreon/start",
|
2026-09-08 13:31:42 +02:00
|
|
|
headers: { "x-heimdall-app-secret": "repixelizer-secret" },
|
2026-04-29 14:36:03 +01:00
|
|
|
payload: {
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
|
|
|
handoff: {
|
|
|
|
|
kind: "backend_callback",
|
|
|
|
|
attemptId: "attempt-123",
|
|
|
|
|
callbackUrl: "https://repixelizer.gamecult.org/api/auth/heimdall/callback",
|
|
|
|
|
},
|
|
|
|
|
entitlementPolicy: {
|
|
|
|
|
kind: "patreon_membership_access",
|
|
|
|
|
requiredTierTitle: "Inner Sanctum",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
|
|
|
const payload = response.json();
|
|
|
|
|
const authorizationUrl = new URL(payload.authorizationUrl as string);
|
|
|
|
|
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
|
|
|
|
|
expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]");
|
|
|
|
|
|
|
|
|
|
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
|
|
|
|
expect(verified.valid).toBe(true);
|
|
|
|
|
if (verified.valid) {
|
|
|
|
|
expect(verified.payload).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
typ: "heimdall_oauth_state",
|
|
|
|
|
provider: "patreon",
|
|
|
|
|
app_slug: "repixelizer",
|
|
|
|
|
entitlement_policy: {
|
|
|
|
|
kind: "patreon_membership_access",
|
|
|
|
|
requiredTierTitle: "Inner Sanctum",
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-08 21:04:51 +01:00
|
|
|
it("builds Bifrost Discord OAuth start state with caller-owned cult member role policy", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/discord/start",
|
2026-09-08 13:31:42 +02:00
|
|
|
headers: { "x-heimdall-app-secret": "bifrost-secret" },
|
2026-06-08 21:04:51 +01:00
|
|
|
payload: {
|
|
|
|
|
appSlug: "bifrost",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://bifrost.gamecult.org/auth/heimdall/wait?attemptId=bifrost-attempt-123&returnTo=%2FApp",
|
|
|
|
|
handoff: {
|
|
|
|
|
kind: "backend_callback",
|
|
|
|
|
attemptId: "bifrost-attempt-123",
|
|
|
|
|
callbackUrl: "https://bifrost.gamecult.org/auth/heimdall/callback",
|
|
|
|
|
},
|
|
|
|
|
entitlementPolicy: {
|
|
|
|
|
kind: "discord_role_access",
|
|
|
|
|
guildId: "gamecult-guild",
|
|
|
|
|
allowedRoleIds: ["role-ktlst"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
|
|
|
const payload = response.json();
|
|
|
|
|
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
|
|
|
|
|
|
|
|
|
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
|
|
|
|
expect(verified.valid).toBe(true);
|
|
|
|
|
if (verified.valid) {
|
|
|
|
|
expect(verified.payload).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
typ: "heimdall_oauth_state",
|
|
|
|
|
provider: "discord",
|
|
|
|
|
app_slug: "bifrost",
|
|
|
|
|
entitlement_policy: {
|
|
|
|
|
kind: "discord_role_access",
|
|
|
|
|
guildId: "gamecult-guild",
|
|
|
|
|
allowedRoleIds: ["role-ktlst"],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("builds Bifrost Patreon OAuth start state with caller-owned tier policy", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/patreon/start",
|
2026-09-08 13:31:42 +02:00
|
|
|
headers: { "x-heimdall-app-secret": "bifrost-secret" },
|
2026-06-08 21:04:51 +01:00
|
|
|
payload: {
|
|
|
|
|
appSlug: "bifrost",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://bifrost.gamecult.org/auth/heimdall/wait?attemptId=bifrost-attempt-123&returnTo=%2FApp",
|
|
|
|
|
handoff: {
|
|
|
|
|
kind: "backend_callback",
|
|
|
|
|
attemptId: "bifrost-attempt-123",
|
|
|
|
|
callbackUrl: "https://bifrost.gamecult.org/auth/heimdall/callback",
|
|
|
|
|
},
|
|
|
|
|
entitlementPolicy: {
|
|
|
|
|
kind: "patreon_membership_access",
|
|
|
|
|
requiredTierTitle: "Inner Sanctum",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
|
|
|
const payload = response.json();
|
|
|
|
|
const authorizationUrl = new URL(payload.authorizationUrl as string);
|
|
|
|
|
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
|
|
|
|
|
|
|
|
|
|
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
|
|
|
|
expect(verified.valid).toBe(true);
|
|
|
|
|
if (verified.valid) {
|
|
|
|
|
expect(verified.payload).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
typ: "heimdall_oauth_state",
|
|
|
|
|
provider: "patreon",
|
|
|
|
|
app_slug: "bifrost",
|
|
|
|
|
entitlement_policy: {
|
|
|
|
|
kind: "patreon_membership_access",
|
|
|
|
|
requiredTierTitle: "Inner Sanctum",
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 12:50:07 +01:00
|
|
|
it("completes a provider callback and issues a verified Repixelizer claim", async () => {
|
2026-04-26 16:23:29 +01:00
|
|
|
const store = new InMemoryStore();
|
2026-04-26 12:50:07 +01:00
|
|
|
const app = await buildApp({
|
|
|
|
|
config: createTestConfig(),
|
2026-04-26 16:23:29 +01:00
|
|
|
store,
|
2026-04-26 12:50:07 +01:00
|
|
|
oauthRuntimes: {
|
|
|
|
|
discord: createMockDiscordRuntime(),
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-04-26 12:25:11 +01:00
|
|
|
apps.push(app);
|
|
|
|
|
|
2026-04-26 12:50:07 +01:00
|
|
|
const stateToken = await startDiscordSignIn(app);
|
2026-04-26 12:25:11 +01:00
|
|
|
const response = await app.inject({
|
2026-04-26 12:50:07 +01:00
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
2026-04-26 12:25:11 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
|
|
|
const payload = response.json();
|
2026-04-26 15:23:04 +01:00
|
|
|
expect(payload.completion).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
code: expect.any(String),
|
|
|
|
|
redeemEndpoint: "https://heimdall.gamecult.org/v1/apps/repixelizer/auth-completions/redeem",
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-04-26 12:50:07 +01:00
|
|
|
expect(payload.sharedCapabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"]));
|
|
|
|
|
expect(payload.account).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
displayName: "Meta",
|
|
|
|
|
primaryEmail: "meta@gamecult.org",
|
|
|
|
|
})
|
2026-04-26 12:25:11 +01:00
|
|
|
);
|
2026-04-26 14:58:58 +01:00
|
|
|
expect(payload.entitlements.facts).toEqual(expect.arrayContaining([entitlementFacts.appAccess]));
|
2026-04-26 12:25:11 +01:00
|
|
|
|
2026-04-26 16:23:29 +01:00
|
|
|
const storedIdentity = await store.findStoredLinkedIdentity("discord", "discord-user-123");
|
|
|
|
|
expect(storedIdentity).toEqual(expect.objectContaining({ provider: "discord" }));
|
|
|
|
|
expect(storedIdentity?.accessTokenEncrypted).toMatch(/^gc_tok_v1:/);
|
|
|
|
|
expect(storedIdentity?.accessTokenEncrypted).not.toBe("discord-access-token");
|
|
|
|
|
expect(getHeimdallContext(app).tokenCustody.decrypt(storedIdentity?.accessTokenEncrypted ?? "")).toBe(
|
|
|
|
|
"discord-access-token"
|
|
|
|
|
);
|
|
|
|
|
expect(getHeimdallContext(app).tokenCustody.decrypt(storedIdentity?.refreshTokenEncrypted ?? "")).toBe(
|
|
|
|
|
"discord-refresh-token"
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-26 12:50:07 +01:00
|
|
|
const verified = verifyJwt(payload.accessToken as string, getPublicKey(app));
|
2026-04-26 12:25:11 +01:00
|
|
|
expect(verified.valid).toBe(true);
|
2026-04-26 12:50:07 +01:00
|
|
|
if (verified.valid) {
|
|
|
|
|
expect(verified.payload).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
typ: "heimdall_access",
|
|
|
|
|
aud: "repixelizer",
|
|
|
|
|
account_id: payload.account.id,
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-04-26 14:58:58 +01:00
|
|
|
expect(verified.payload.facts).toEqual(
|
|
|
|
|
expect.arrayContaining([identityFacts.authenticated, entitlementFacts.appAccess])
|
|
|
|
|
);
|
2026-04-26 12:50:07 +01:00
|
|
|
expect(verified.payload.capabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"]));
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-26 12:25:11 +01:00
|
|
|
|
2026-09-30 17:08:29 +02:00
|
|
|
async function syncPatronSupport(bifrostAnswer: () => Response) {
|
2026-06-09 20:22:58 +01:00
|
|
|
const baseConfig = createTestConfig();
|
|
|
|
|
const config = {
|
|
|
|
|
...baseConfig,
|
|
|
|
|
appSharedSecrets: {
|
|
|
|
|
...baseConfig.appSharedSecrets,
|
|
|
|
|
bifrost: "bifrost-secret",
|
|
|
|
|
},
|
|
|
|
|
bifrostPatronSupportEndpoint: "https://bifrost.gamecult.org/heimdall/patron-support/events",
|
|
|
|
|
bifrostPatronSupportSecret: "bifrost-support-secret",
|
|
|
|
|
};
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({ config, store });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const context = getHeimdallContext(app);
|
|
|
|
|
const now = "2026-06-09T17:00:00.000Z";
|
|
|
|
|
await store.createAccount({
|
|
|
|
|
id: "heimdall-account-123",
|
|
|
|
|
createdAt: now,
|
|
|
|
|
lastSeenAt: now,
|
|
|
|
|
displayName: "Patron",
|
|
|
|
|
});
|
|
|
|
|
await store.upsertLinkedIdentity({
|
|
|
|
|
accountId: "heimdall-account-123",
|
|
|
|
|
provider: "patreon",
|
|
|
|
|
providerUserId: "patreon-user-456",
|
|
|
|
|
accessTokenEncrypted: context.tokenCustody.encrypt("patreon-access-token"),
|
|
|
|
|
refreshTokenEncrypted: context.tokenCustody.encrypt("patreon-refresh-token"),
|
|
|
|
|
tokenExpiresAt: "2099-06-09T18:00:00.000Z",
|
|
|
|
|
scopes: ["identity", "identity[email]"],
|
|
|
|
|
profileJson: { id: "patreon-user-456" },
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let bifrostBody = "";
|
|
|
|
|
let bifrostSignature = "";
|
|
|
|
|
globalThis.fetch = async (input, init) => {
|
|
|
|
|
const url = String(input);
|
|
|
|
|
if (url.startsWith("https://www.patreon.com/api/oauth2/v2/identity")) {
|
|
|
|
|
expect(init?.headers).toEqual(expect.objectContaining({ Authorization: "Bearer patreon-access-token" }));
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
data: { id: "patreon-user-456", type: "user", attributes: { full_name: "Patron" } },
|
|
|
|
|
included: [
|
|
|
|
|
{
|
|
|
|
|
id: "member-789",
|
|
|
|
|
type: "member",
|
|
|
|
|
attributes: {
|
|
|
|
|
currently_entitled_amount_cents: 1500,
|
|
|
|
|
last_charge_status: "Paid",
|
|
|
|
|
patron_status: "active_patron",
|
|
|
|
|
},
|
|
|
|
|
relationships: {
|
|
|
|
|
currently_entitled_tiers: { data: [{ id: "tier-inner", type: "tier" }] },
|
|
|
|
|
campaign: { data: { id: "campaign-1", type: "campaign" } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ id: "tier-inner", type: "tier", attributes: { title: "Inner Sanctum" } },
|
|
|
|
|
{ id: "campaign-1", type: "campaign", attributes: { currency: "USD" } },
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
{ status: 200, headers: { "content-type": "application/json" } }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (url === "https://bifrost.gamecult.org/heimdall/patron-support/events") {
|
|
|
|
|
bifrostBody = String(init?.body ?? "");
|
|
|
|
|
const headers = init?.headers as Record<string, string>;
|
|
|
|
|
bifrostSignature = headers["x-heimdall-signature-256"] ?? "";
|
2026-09-30 17:08:29 +02:00
|
|
|
return bifrostAnswer();
|
2026-06-09 20:22:58 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Response("unexpected request", { status: 500 });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/bifrost/patron-support/sync",
|
|
|
|
|
headers: {
|
|
|
|
|
"x-heimdall-app-secret": "bifrost-secret",
|
|
|
|
|
},
|
|
|
|
|
payload: {
|
|
|
|
|
accountId: "heimdall-account-123",
|
|
|
|
|
requiredTierTitle: "Inner Sanctum",
|
|
|
|
|
supportedAtUtc: now,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 17:08:29 +02:00
|
|
|
const auditEvents = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
|
|
|
|
|
return { response, auditEvents, bifrostBody, bifrostSignature, now };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("syncs a linked Patreon membership into a signed Bifrost support fact", async () => {
|
|
|
|
|
const { response, auditEvents, bifrostBody, bifrostSignature, now } = await syncPatronSupport(
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
() => new Response("CANARYbifrost processed", { status: 200 })
|
2026-09-30 17:08:29 +02:00
|
|
|
);
|
|
|
|
|
|
2026-06-09 20:22:58 +01:00
|
|
|
expect(response.statusCode, response.body).toBe(200);
|
|
|
|
|
expect(bifrostSignature).toMatch(/^sha256=[0-9a-f]{64}$/);
|
|
|
|
|
expect(JSON.parse(bifrostBody)).toEqual({
|
|
|
|
|
heimdallAccountId: "heimdall-account-123",
|
|
|
|
|
provider: "Patreon",
|
|
|
|
|
providerEventId: "patreon-membership-snapshot:patreon-user-456:member-789:2026-06-09:1500",
|
|
|
|
|
kind: "RecurringSupportSnapshot",
|
|
|
|
|
amount: 15,
|
|
|
|
|
currencyCode: "USD",
|
|
|
|
|
externalSupportId: "patreon-member:member-789",
|
|
|
|
|
supportedAtUtc: now,
|
|
|
|
|
isCurrentRecurringSupport: true,
|
|
|
|
|
providerPayerId: "patreon-user-456",
|
|
|
|
|
providerSubscriptionId: "member-789",
|
|
|
|
|
notes: "Verified active Patreon membership for tier Inner Sanctum.",
|
|
|
|
|
});
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
// Exactly these fields: no part of Bifrost's body rides along.
|
|
|
|
|
expect(response.json()).toEqual({ status: "synced", supportFact: JSON.parse(bifrostBody), bifrostStatus: 200 });
|
2026-09-30 17:08:29 +02:00
|
|
|
expect(auditEvents.map((event) => [event.eventType, event.eventPayloadJson])).toEqual([
|
|
|
|
|
[
|
|
|
|
|
"bifrost_patron_support_synced",
|
|
|
|
|
{
|
|
|
|
|
provider: "patreon",
|
|
|
|
|
requiredTierTitle: "Inner Sanctum",
|
|
|
|
|
providerEventId: "patreon-membership-snapshot:patreon-user-456:member-789:2026-06-09:1500",
|
|
|
|
|
bifrostStatus: 200,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
expect(response.body + JSON.stringify(auditEvents)).not.toContain("CANARY");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("answers a Bifrost refusal with fixed text and its status, never its body", async () => {
|
|
|
|
|
const { response, auditEvents } = await syncPatronSupport(
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
() => new Response('CANARYrefusal {"error":"bad signature"}', { status: 403 })
|
2026-06-09 20:22:58 +01:00
|
|
|
);
|
2026-09-30 17:08:29 +02:00
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(502);
|
|
|
|
|
expect(response.json()).toEqual({
|
|
|
|
|
error: "bifrost_patron_support_rejected",
|
|
|
|
|
statusCode: 403,
|
|
|
|
|
detail: "Bifrost refused the patron support fact.",
|
|
|
|
|
});
|
|
|
|
|
expect(auditEvents.map((event) => [event.eventType, event.eventPayloadJson])).toEqual([
|
|
|
|
|
[
|
|
|
|
|
"bifrost_patron_support_sync_failed",
|
|
|
|
|
{
|
|
|
|
|
provider: "patreon",
|
|
|
|
|
requiredTierTitle: "Inner Sanctum",
|
|
|
|
|
providerEventId: "patreon-membership-snapshot:patreon-user-456:member-789:2026-06-09:1500",
|
|
|
|
|
bifrostStatus: 403,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
expect(response.body + JSON.stringify(auditEvents)).not.toContain("CANARY");
|
2026-06-09 20:22:58 +01:00
|
|
|
});
|
|
|
|
|
|
2026-04-26 15:23:04 +01:00
|
|
|
it("redeems a completion code exactly once", async () => {
|
|
|
|
|
const app = await buildApp({
|
|
|
|
|
config: createTestConfig(),
|
|
|
|
|
oauthRuntimes: {
|
|
|
|
|
discord: createMockDiscordRuntime(),
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-04-26 14:58:58 +01:00
|
|
|
apps.push(app);
|
|
|
|
|
|
2026-04-26 15:23:04 +01:00
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
|
|
|
const callbackResponse = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
const completionCode = callbackResponse.json().completion.code as string;
|
|
|
|
|
|
|
|
|
|
const redeemResponse = await app.inject({
|
2026-04-26 14:58:58 +01:00
|
|
|
method: "POST",
|
2026-04-26 15:23:04 +01:00
|
|
|
url: "/v1/apps/repixelizer/auth-completions/redeem",
|
2026-04-26 14:58:58 +01:00
|
|
|
payload: {
|
2026-04-26 15:23:04 +01:00
|
|
|
completionCode,
|
2026-04-26 14:58:58 +01:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 15:23:04 +01:00
|
|
|
expect(redeemResponse.statusCode).toBe(201);
|
|
|
|
|
expect(redeemResponse.json()).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
status: "success",
|
|
|
|
|
provider: "discord",
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
})
|
2026-04-26 14:58:58 +01:00
|
|
|
);
|
2026-04-26 15:23:04 +01:00
|
|
|
|
|
|
|
|
const secondRedeemResponse = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/repixelizer/auth-completions/redeem",
|
|
|
|
|
payload: {
|
|
|
|
|
completionCode,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(secondRedeemResponse.statusCode).toBe(410);
|
2026-04-26 14:58:58 +01:00
|
|
|
});
|
|
|
|
|
|
2026-09-30 18:56:35 +02:00
|
|
|
it("keeps the completion code out of the audit trail, live or redeemed", async () => {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({ config: createTestConfig(), store, oauthRuntimes: { discord: createMockDiscordRuntime() } });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
const auditEvents = () =>
|
|
|
|
|
[...(store as unknown as { auditEvents: Map<string, { eventType: string; sessionId?: string; eventPayloadJson: unknown }> }).auditEvents.values()];
|
|
|
|
|
|
|
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
|
|
|
const callback = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string };
|
|
|
|
|
const created = auditEvents().filter((event) => event.eventType === "auth_completion_created");
|
|
|
|
|
expect(created.map((event) => event.eventPayloadJson)).toEqual([{ provider: "discord", mode: "sign_in", expiresAt }]);
|
|
|
|
|
expect(created[0]!.sessionId).toEqual(expect.any(String));
|
|
|
|
|
// While the code is live, the audit trail must not be a way to redeem it.
|
|
|
|
|
expect(JSON.stringify(auditEvents())).not.toContain(code);
|
|
|
|
|
|
|
|
|
|
const redeem = await app.inject({ method: "POST", url: "/v1/apps/repixelizer/auth-completions/redeem", payload: { completionCode: code } });
|
|
|
|
|
expect(redeem.statusCode).toBe(201);
|
|
|
|
|
const redeemed = auditEvents().filter((event) => event.eventType === "auth_completion_redeemed");
|
|
|
|
|
expect(redeemed.map((event) => event.eventPayloadJson)).toEqual([{ provider: "discord", mode: "sign_in" }]);
|
|
|
|
|
expect(redeemed[0]!.sessionId).toBe(created[0]!.sessionId);
|
|
|
|
|
expect(JSON.stringify(auditEvents())).not.toContain(code);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-20 13:43:53 +01:00
|
|
|
it("refreshes a Repixelizer app claim from a Heimdall refresh token without provider OAuth", async () => {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
let exchangeCalls = 0;
|
|
|
|
|
let refreshCalls = 0;
|
|
|
|
|
const app = await buildApp({
|
|
|
|
|
config: createTestConfig(),
|
|
|
|
|
store,
|
|
|
|
|
oauthRuntimes: {
|
|
|
|
|
discord: {
|
|
|
|
|
async exchangeAuthorizationCode() {
|
|
|
|
|
exchangeCalls += 1;
|
|
|
|
|
return {
|
|
|
|
|
accessToken: "expired-discord-access-token",
|
|
|
|
|
refreshToken: "discord-refresh-token",
|
|
|
|
|
tokenType: "Bearer",
|
|
|
|
|
scope: ["identify", "guilds.members.read"],
|
|
|
|
|
expiresAt: "2026-04-26T13:00:00.000Z",
|
|
|
|
|
raw: { source: "test" },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async refreshAccessToken({ refreshToken }) {
|
|
|
|
|
refreshCalls += 1;
|
|
|
|
|
expect(refreshToken).toBe("discord-refresh-token");
|
|
|
|
|
return {
|
|
|
|
|
accessToken: "fresh-discord-access-token",
|
|
|
|
|
refreshToken: "rotated-discord-refresh-token",
|
|
|
|
|
tokenType: "Bearer",
|
|
|
|
|
scope: ["identify", "guilds.members.read"],
|
|
|
|
|
expiresAt: "2999-04-26T13:00:00.000Z",
|
|
|
|
|
raw: { source: "refresh" },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async resolveIdentity() {
|
|
|
|
|
return {
|
|
|
|
|
provider: "discord",
|
|
|
|
|
providerUserId: "discord-user-123",
|
|
|
|
|
username: "meta",
|
|
|
|
|
displayName: "Meta",
|
|
|
|
|
primaryEmail: "meta@gamecult.org",
|
|
|
|
|
profile: { id: "discord-user-123", username: "meta" },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async evaluateEntitlements({ callback, tokenSet }) {
|
|
|
|
|
expect(tokenSet.accessToken).toBe(refreshCalls ? "fresh-discord-access-token" : "expired-discord-access-token");
|
|
|
|
|
return {
|
|
|
|
|
facts: [entitlementFacts.appAccess],
|
|
|
|
|
snapshots: [
|
|
|
|
|
{
|
|
|
|
|
accountId: callback.accountId,
|
|
|
|
|
provider: "discord",
|
|
|
|
|
scope: "repixelizer:discord_role_access:gamecult-guild",
|
|
|
|
|
evaluatedAt: "2026-04-26T12:00:00.000Z",
|
|
|
|
|
isAllowed: true,
|
|
|
|
|
reasonCode: "matched_role",
|
|
|
|
|
rawSummaryJson: {},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
|
|
|
const callbackResponse = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
expect(callbackResponse.statusCode).toBe(201);
|
|
|
|
|
const refreshToken = callbackResponse.json().refreshToken as string;
|
|
|
|
|
|
|
|
|
|
const refreshResponse = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/repixelizer/sessions/refresh",
|
2026-09-08 13:31:42 +02:00
|
|
|
headers: { "x-heimdall-app-secret": "repixelizer-secret" },
|
2026-05-20 13:43:53 +01:00
|
|
|
payload: {
|
|
|
|
|
refreshToken,
|
|
|
|
|
entitlementPolicies: [
|
|
|
|
|
{
|
|
|
|
|
kind: "discord_role_access",
|
|
|
|
|
guildId: "gamecult-guild",
|
|
|
|
|
allowedRoleIds: ["role-repixelizer"],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(refreshResponse.statusCode).toBe(201);
|
|
|
|
|
expect(exchangeCalls).toBe(1);
|
|
|
|
|
expect(refreshCalls).toBe(1);
|
|
|
|
|
const payload = refreshResponse.json();
|
|
|
|
|
expect(payload.sharedCapabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"]));
|
|
|
|
|
expect(payload.refreshToken).toEqual(expect.any(String));
|
|
|
|
|
const verified = verifyJwt(payload.accessToken as string, getPublicKey(app));
|
|
|
|
|
expect(verified.valid).toBe(true);
|
|
|
|
|
if (verified.valid) {
|
|
|
|
|
expect(verified.payload.sid).toBe(callbackResponse.json().session.sessionId);
|
|
|
|
|
expect(verified.payload.capabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"]));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 15:23:04 +01:00
|
|
|
it("renders a browser handoff page that posts a completion code instead of a token", async () => {
|
2026-04-26 12:50:07 +01:00
|
|
|
const app = await buildApp({
|
|
|
|
|
config: createTestConfig(),
|
|
|
|
|
oauthRuntimes: {
|
|
|
|
|
discord: createMockDiscordRuntime(),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
headers: {
|
|
|
|
|
accept: "text/html",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 15:23:04 +01:00
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
|
expect(response.headers["content-type"]).toContain("text/html");
|
|
|
|
|
expect(response.body).toContain("window.opener.postMessage");
|
|
|
|
|
expect(response.body).toContain("heimdall_completion_code");
|
|
|
|
|
expect(response.body).not.toContain("heimdall_access_token");
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 17:00:57 +01:00
|
|
|
it("delivers auth results directly to app backends when backend callback handoff is requested", async () => {
|
|
|
|
|
const deliveries: Array<Record<string, unknown>> = [];
|
|
|
|
|
globalThis.fetch = async (input, init) => {
|
|
|
|
|
if (String(input) !== "https://repixelizer.gamecult.org/api/auth/heimdall/callback") {
|
|
|
|
|
throw new Error(`Unexpected fetch target in backend handoff test: ${String(input)}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deliveries.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
|
|
|
|
|
return new Response(null, { status: 204 });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const app = await buildApp({
|
|
|
|
|
config: createTestConfig(),
|
|
|
|
|
oauthRuntimes: {
|
|
|
|
|
discord: createMockDiscordRuntime(),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const startResponse = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/discord/start",
|
|
|
|
|
payload: {
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
|
|
|
handoff: {
|
|
|
|
|
kind: "backend_callback",
|
|
|
|
|
attemptId: "attempt-123",
|
|
|
|
|
callbackUrl: "https://repixelizer.gamecult.org/api/auth/heimdall/callback",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
expect(startResponse.statusCode).toBe(201);
|
|
|
|
|
const stateToken = startResponse.json().stateToken as string;
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
headers: {
|
|
|
|
|
accept: "text/html",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
|
expect(response.body).toContain("heimdall_attempt_id");
|
|
|
|
|
expect(response.body).not.toContain("heimdall_completion_code");
|
|
|
|
|
expect(deliveries).toHaveLength(1);
|
|
|
|
|
const delivery = deliveries[0];
|
|
|
|
|
expect(delivery).toBeDefined();
|
|
|
|
|
expect(delivery).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
source: "heimdall",
|
|
|
|
|
kind: "oauth_result",
|
|
|
|
|
handoffKind: "backend_callback",
|
|
|
|
|
attemptId: "attempt-123",
|
|
|
|
|
status: "success",
|
|
|
|
|
provider: "discord",
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const deliveryAccessToken = delivery?.accessToken;
|
|
|
|
|
expect(typeof deliveryAccessToken).toBe("string");
|
|
|
|
|
const verified = verifyJwt(String(deliveryAccessToken), getPublicKey(app));
|
|
|
|
|
expect(verified.valid).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-08 13:14:52 +02:00
|
|
|
it("no longer exposes a route to mint claims from caller-supplied facts", async () => {
|
2026-04-26 15:23:04 +01:00
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/repixelizer/claims/issue",
|
2026-09-08 13:14:52 +02:00
|
|
|
payload: { accountId: "probe" },
|
2026-04-26 15:23:04 +01:00
|
|
|
});
|
|
|
|
|
|
2026-09-08 13:14:52 +02:00
|
|
|
expect(response.statusCode).toBe(404);
|
|
|
|
|
expect(app.printRoutes()).not.toContain("claims/issue");
|
2026-04-26 16:23:29 +01:00
|
|
|
|
2026-09-08 13:14:52 +02:00
|
|
|
const discoveryResponse = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: "/.well-known/heimdall-configuration",
|
2026-04-26 16:23:29 +01:00
|
|
|
});
|
2026-09-08 13:14:52 +02:00
|
|
|
expect(discoveryResponse.json()).not.toHaveProperty("claimIssueEndpoint");
|
2026-04-26 12:25:11 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("exposes StreamPixels hybrid capability seams without granting them blindly", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const profileResponse = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: "/v1/apps/streampixels",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(profileResponse.statusCode).toBe(200);
|
|
|
|
|
expect(profileResponse.json()).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
slug: "streampixels",
|
2026-10-19 11:13:40 +02:00
|
|
|
identityProviders: ["twitch", "youtube"],
|
|
|
|
|
managedConnectionProviders: ["twitch", "youtube", "joystick"],
|
2026-04-26 12:25:11 +01:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-04-29 15:00:55 +01:00
|
|
|
|
2026-06-02 19:25:14 +01:00
|
|
|
it("rejects unconfigured backend callback URLs", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
Retire the Spotiverse profile; keep the spotify provider intact for restore
Spotiverse was lost with the E: drive and has no repository in the swarm,
but /v1/oauth/spotify/start stayed publicly reachable and
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID/_SECRET stayed configured on
yggdrasil. That path would have run a real OAuth handshake against a real
secret and taken custody of a user's Spotify refresh token for a consumer
that could not use it.
The seam is supportsProvider(profile, provider): a provider with no app
behind it is unreachable. So the registry entry is what had to go, not the
provider.
Removed: spotiverseProfile and its registry entry, the spotiverse slug
from appSlugs, the hardcoded callback arm in app.ts, and three
spotiverse-specific tests. Removed from the host: the two spotify env
vars, backed up to /root/heimdall-service.env.bak-20260906-spotify. Nine
other provider vars and the file's 0600 heimdall:heimdall ownership are
untouched.
Kept deliberately: the entire spotify provider -- the providers.ts catalog
entry, spotifyRuntime and its token/profile exchange in oauth.ts, and
"spotify" in the providers union. It is intact and unreferenced, which is
what makes restoring a registry change rather than protocol work. The
capability is wanted back; a Persona managing the queue during a stream is
the motivating case.
docs/app-profiles/spotiverse.md is now the restore spec rather than an
archive: what was removed, what was kept, the base commit 5a15f71 to
recover the profile verbatim, and the daemon's callback URL
(http://127.0.0.1:8796/auth/heimdall/callback), which existed only in a
hardcoded allowlist arm and would otherwise have been lost with it.
Before deleting the three tests I checked their routes keep coverage:
managed-credential resolve is exercised twice via streampixels, and
backend_callback handoff at three other sites. The generic
untrusted_backend_callback negative test was retargeted to bifrost rather
than deleted. 35 pass, 9 files; tsc clean.
Verified the deployed build still boots without the vars --
readProviderConfig returns an empty config rather than throwing, so the
running release fails that route at request time instead of at startup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:47:10 +02:00
|
|
|
url: "/v1/oauth/discord/start",
|
2026-06-02 19:25:14 +01:00
|
|
|
payload: {
|
Retire the Spotiverse profile; keep the spotify provider intact for restore
Spotiverse was lost with the E: drive and has no repository in the swarm,
but /v1/oauth/spotify/start stayed publicly reachable and
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID/_SECRET stayed configured on
yggdrasil. That path would have run a real OAuth handshake against a real
secret and taken custody of a user's Spotify refresh token for a consumer
that could not use it.
The seam is supportsProvider(profile, provider): a provider with no app
behind it is unreachable. So the registry entry is what had to go, not the
provider.
Removed: spotiverseProfile and its registry entry, the spotiverse slug
from appSlugs, the hardcoded callback arm in app.ts, and three
spotiverse-specific tests. Removed from the host: the two spotify env
vars, backed up to /root/heimdall-service.env.bak-20260906-spotify. Nine
other provider vars and the file's 0600 heimdall:heimdall ownership are
untouched.
Kept deliberately: the entire spotify provider -- the providers.ts catalog
entry, spotifyRuntime and its token/profile exchange in oauth.ts, and
"spotify" in the providers union. It is intact and unreferenced, which is
what makes restoring a registry change rather than protocol work. The
capability is wanted back; a Persona managing the queue during a stream is
the motivating case.
docs/app-profiles/spotiverse.md is now the restore spec rather than an
archive: what was removed, what was kept, the base commit 5a15f71 to
recover the profile verbatim, and the daemon's callback URL
(http://127.0.0.1:8796/auth/heimdall/callback), which existed only in a
hardcoded allowlist arm and would otherwise have been lost with it.
Before deleting the three tests I checked their routes keep coverage:
managed-credential resolve is exercised twice via streampixels, and
backend_callback handoff at three other sites. The generic
untrusted_backend_callback negative test was retargeted to bifrost rather
than deleted. 35 pass, 9 files; tsc clean.
Verified the deployed build still boots without the vars --
readProviderConfig returns an empty config rather than throwing, so the
running release fails that route at request time instead of at startup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:47:10 +02:00
|
|
|
appSlug: "bifrost",
|
2026-06-02 19:25:14 +01:00
|
|
|
mode: "connect",
|
Retire the Spotiverse profile; keep the spotify provider intact for restore
Spotiverse was lost with the E: drive and has no repository in the swarm,
but /v1/oauth/spotify/start stayed publicly reachable and
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID/_SECRET stayed configured on
yggdrasil. That path would have run a real OAuth handshake against a real
secret and taken custody of a user's Spotify refresh token for a consumer
that could not use it.
The seam is supportsProvider(profile, provider): a provider with no app
behind it is unreachable. So the registry entry is what had to go, not the
provider.
Removed: spotiverseProfile and its registry entry, the spotiverse slug
from appSlugs, the hardcoded callback arm in app.ts, and three
spotiverse-specific tests. Removed from the host: the two spotify env
vars, backed up to /root/heimdall-service.env.bak-20260906-spotify. Nine
other provider vars and the file's 0600 heimdall:heimdall ownership are
untouched.
Kept deliberately: the entire spotify provider -- the providers.ts catalog
entry, spotifyRuntime and its token/profile exchange in oauth.ts, and
"spotify" in the providers union. It is intact and unreferenced, which is
what makes restoring a registry change rather than protocol work. The
capability is wanted back; a Persona managing the queue during a stream is
the motivating case.
docs/app-profiles/spotiverse.md is now the restore spec rather than an
archive: what was removed, what was kept, the base commit 5a15f71 to
recover the profile verbatim, and the daemon's callback URL
(http://127.0.0.1:8796/auth/heimdall/callback), which existed only in a
hardcoded allowlist arm and would otherwise have been lost with it.
Before deleting the three tests I checked their routes keep coverage:
managed-credential resolve is exercised twice via streampixels, and
backend_callback handoff at three other sites. The generic
untrusted_backend_callback negative test was retargeted to bifrost rather
than deleted. 35 pass, 9 files; tsc clean.
Verified the deployed build still boots without the vars --
readProviderConfig returns an empty config rather than throwing, so the
running release fails that route at request time instead of at startup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:47:10 +02:00
|
|
|
returnTo: "https://bifrost.gamecult.org/auth/complete",
|
2026-06-02 19:25:14 +01:00
|
|
|
handoff: {
|
|
|
|
|
kind: "backend_callback",
|
Retire the Spotiverse profile; keep the spotify provider intact for restore
Spotiverse was lost with the E: drive and has no repository in the swarm,
but /v1/oauth/spotify/start stayed publicly reachable and
GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID/_SECRET stayed configured on
yggdrasil. That path would have run a real OAuth handshake against a real
secret and taken custody of a user's Spotify refresh token for a consumer
that could not use it.
The seam is supportsProvider(profile, provider): a provider with no app
behind it is unreachable. So the registry entry is what had to go, not the
provider.
Removed: spotiverseProfile and its registry entry, the spotiverse slug
from appSlugs, the hardcoded callback arm in app.ts, and three
spotiverse-specific tests. Removed from the host: the two spotify env
vars, backed up to /root/heimdall-service.env.bak-20260906-spotify. Nine
other provider vars and the file's 0600 heimdall:heimdall ownership are
untouched.
Kept deliberately: the entire spotify provider -- the providers.ts catalog
entry, spotifyRuntime and its token/profile exchange in oauth.ts, and
"spotify" in the providers union. It is intact and unreferenced, which is
what makes restoring a registry change rather than protocol work. The
capability is wanted back; a Persona managing the queue during a stream is
the motivating case.
docs/app-profiles/spotiverse.md is now the restore spec rather than an
archive: what was removed, what was kept, the base commit 5a15f71 to
recover the profile verbatim, and the daemon's callback URL
(http://127.0.0.1:8796/auth/heimdall/callback), which existed only in a
hardcoded allowlist arm and would otherwise have been lost with it.
Before deleting the three tests I checked their routes keep coverage:
managed-credential resolve is exercised twice via streampixels, and
backend_callback handoff at three other sites. The generic
untrusted_backend_callback negative test was retargeted to bifrost rather
than deleted. 35 pass, 9 files; tsc clean.
Verified the deployed build still boots without the vars --
readProviderConfig returns an empty config rather than throwing, so the
running release fails that route at request time instead of at startup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:47:10 +02:00
|
|
|
attemptId: "bifrost-bad-attempt",
|
|
|
|
|
callbackUrl: "https://not-bifrost.example/auth/heimdall/callback",
|
2026-06-02 19:25:14 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
|
|
|
expect(response.json()).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
error: "untrusted_backend_callback",
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-08 13:15:25 +02:00
|
|
|
it("no longer trusts Repixelizer's callback URL by slug name alone", async () => {
|
|
|
|
|
const baseConfig = createTestConfig();
|
|
|
|
|
const config = {
|
|
|
|
|
...baseConfig,
|
|
|
|
|
appBackendCallbacks: {},
|
|
|
|
|
};
|
|
|
|
|
const app = await buildApp({ config });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/discord/start",
|
|
|
|
|
payload: {
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
|
|
|
handoff: {
|
|
|
|
|
kind: "backend_callback",
|
|
|
|
|
attemptId: "attempt-123",
|
|
|
|
|
callbackUrl: "https://repixelizer.gamecult.org/api/auth/heimdall/callback",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
|
|
|
expect(response.json()).toEqual(expect.objectContaining({ error: "untrusted_backend_callback" }));
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-08 13:31:42 +02:00
|
|
|
it("requires the app secret uniformly, with no slug special-cased", async () => {
|
|
|
|
|
// resolveAppCaller is the only identity decision left; it must behave
|
|
|
|
|
// identically for every built-in slug rather than special-casing one.
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
for (const appSlug of ["repixelizer", "streampixels", "bifrost", "ghostlight"] as const) {
|
|
|
|
|
const denied = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: `/v1/apps/${appSlug}/managed-credentials/resolve`,
|
|
|
|
|
payload: { accountId: "acct-1", provider: "discord" },
|
|
|
|
|
});
|
|
|
|
|
expect(denied.statusCode, appSlug).toBe(401);
|
|
|
|
|
expect(denied.json()).toEqual(expect.objectContaining({ error: "app_auth_required" }));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("uses only the timing-safe comparator: a near-miss secret is rejected, an exact one is accepted", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const nearMiss = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/streampixels/managed-credentials/resolve",
|
|
|
|
|
headers: { "x-heimdall-app-secret": "streampixels-secretX" },
|
|
|
|
|
payload: { accountId: "acct-1", provider: "twitch" },
|
|
|
|
|
});
|
|
|
|
|
expect(nearMiss.statusCode).toBe(401);
|
|
|
|
|
|
|
|
|
|
const exact = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/streampixels/managed-credentials/resolve",
|
|
|
|
|
headers: { "x-heimdall-app-secret": "streampixels-secret" },
|
|
|
|
|
payload: { accountId: "acct-1", provider: "twitch" },
|
|
|
|
|
});
|
|
|
|
|
// Past the identity gate: the credential itself doesn't exist in this
|
|
|
|
|
// fixture, but that is a 404, not a 401 -- proof the secret was accepted.
|
|
|
|
|
expect(exact.statusCode).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 15:00:55 +01:00
|
|
|
it("resolves app-managed provider credentials without exposing refresh custody", async () => {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({
|
|
|
|
|
config: createTestConfig(),
|
|
|
|
|
store,
|
|
|
|
|
oauthRuntimes: {
|
|
|
|
|
twitch: {
|
|
|
|
|
async exchangeAuthorizationCode() {
|
|
|
|
|
return {
|
|
|
|
|
accessToken: "twitch-access-token",
|
|
|
|
|
refreshToken: "twitch-refresh-token",
|
|
|
|
|
tokenType: "Bearer",
|
|
|
|
|
scope: ["user:read:email"],
|
|
|
|
|
expiresAt: "2026-04-26T13:00:00.000Z",
|
|
|
|
|
raw: { source: "test" },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async resolveIdentity() {
|
|
|
|
|
return {
|
|
|
|
|
provider: "twitch",
|
|
|
|
|
providerUserId: "twitch-user-123",
|
|
|
|
|
username: "pixelpaladin",
|
|
|
|
|
displayName: "PixelPaladin",
|
|
|
|
|
profile: { id: "twitch-user-123" },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async evaluateEntitlements() {
|
|
|
|
|
return { facts: [], snapshots: [] };
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const startResponse = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/twitch/start",
|
|
|
|
|
payload: {
|
|
|
|
|
appSlug: "streampixels",
|
|
|
|
|
mode: "connect",
|
|
|
|
|
returnTo: "https://streampixels.gamecult.org/auth/connect",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const stateToken = startResponse.json().stateToken as string;
|
|
|
|
|
|
|
|
|
|
const callbackResponse = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
const accountId = callbackResponse.json().account.id as string;
|
|
|
|
|
|
|
|
|
|
const credentialResponse = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/streampixels/managed-credentials/resolve",
|
|
|
|
|
headers: {
|
|
|
|
|
"x-heimdall-app-secret": "streampixels-secret",
|
|
|
|
|
},
|
|
|
|
|
payload: {
|
|
|
|
|
accountId,
|
|
|
|
|
provider: "twitch",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(credentialResponse.statusCode).toBe(200);
|
|
|
|
|
expect(credentialResponse.json()).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
accountId,
|
|
|
|
|
provider: "twitch",
|
|
|
|
|
providerUserId: "twitch-user-123",
|
|
|
|
|
accessToken: "twitch-access-token",
|
|
|
|
|
scopes: ["user:read:email"],
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
expect(credentialResponse.body).not.toContain("twitch-refresh-token");
|
|
|
|
|
});
|
2026-04-29 16:21:21 +01:00
|
|
|
|
2026-04-29 17:24:44 +01:00
|
|
|
it("refreshes expiring app-managed provider credentials before resolving them", async () => {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({
|
|
|
|
|
config: createTestConfig(),
|
|
|
|
|
store,
|
|
|
|
|
oauthRuntimes: {
|
|
|
|
|
twitch: {
|
|
|
|
|
async exchangeAuthorizationCode() {
|
|
|
|
|
return {
|
|
|
|
|
accessToken: "expired-twitch-access-token",
|
|
|
|
|
refreshToken: "twitch-refresh-token",
|
|
|
|
|
tokenType: "Bearer",
|
|
|
|
|
scope: ["user:read:email"],
|
|
|
|
|
expiresAt: "2026-04-26T13:00:00.000Z",
|
|
|
|
|
raw: { source: "test" },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async refreshAccessToken({ refreshToken }) {
|
|
|
|
|
expect(refreshToken).toBe("twitch-refresh-token");
|
|
|
|
|
return {
|
|
|
|
|
accessToken: "fresh-twitch-access-token",
|
|
|
|
|
refreshToken: "rotated-twitch-refresh-token",
|
|
|
|
|
tokenType: "Bearer",
|
|
|
|
|
scope: ["user:read:email", "user:read:chat"],
|
|
|
|
|
expiresAt: "2999-04-26T13:00:00.000Z",
|
|
|
|
|
raw: { source: "refresh" },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async resolveIdentity() {
|
|
|
|
|
return {
|
|
|
|
|
provider: "twitch",
|
|
|
|
|
providerUserId: "twitch-user-123",
|
|
|
|
|
username: "pixelpaladin",
|
|
|
|
|
displayName: "PixelPaladin",
|
|
|
|
|
profile: { id: "twitch-user-123" },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async evaluateEntitlements() {
|
|
|
|
|
return { facts: [], snapshots: [] };
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const startResponse = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/twitch/start",
|
|
|
|
|
payload: {
|
|
|
|
|
appSlug: "streampixels",
|
|
|
|
|
mode: "connect",
|
|
|
|
|
returnTo: "https://streampixels.gamecult.org/auth/connect",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const stateToken = startResponse.json().stateToken as string;
|
|
|
|
|
|
|
|
|
|
const callbackResponse = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
const accountId = callbackResponse.json().account.id as string;
|
|
|
|
|
|
|
|
|
|
const credentialResponse = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/streampixels/managed-credentials/resolve",
|
|
|
|
|
headers: {
|
|
|
|
|
"x-heimdall-app-secret": "streampixels-secret",
|
|
|
|
|
},
|
|
|
|
|
payload: {
|
|
|
|
|
accountId,
|
|
|
|
|
provider: "twitch",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(credentialResponse.statusCode).toBe(200);
|
|
|
|
|
expect(credentialResponse.json()).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
accountId,
|
|
|
|
|
provider: "twitch",
|
|
|
|
|
providerUserId: "twitch-user-123",
|
|
|
|
|
accessToken: "fresh-twitch-access-token",
|
|
|
|
|
tokenExpiresAt: "2999-04-26T13:00:00.000Z",
|
|
|
|
|
scopes: ["user:read:email", "user:read:chat"],
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
expect(credentialResponse.body).not.toContain("rotated-twitch-refresh-token");
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-19 11:11:34 +02:00
|
|
|
it("revocation-is-typed: a revoked identity contributes no entitlement at session refresh", async () => {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
let evaluations = 0;
|
|
|
|
|
const base = createMockDiscordRuntime();
|
|
|
|
|
const app = await buildApp({
|
|
|
|
|
config: createTestConfig(),
|
|
|
|
|
store,
|
|
|
|
|
oauthRuntimes: {
|
|
|
|
|
discord: {
|
|
|
|
|
...base,
|
|
|
|
|
async exchangeAuthorizationCode() {
|
|
|
|
|
return {
|
|
|
|
|
accessToken: "expired-discord-access-token",
|
|
|
|
|
refreshToken: "discord-refresh-token",
|
|
|
|
|
tokenType: "Bearer",
|
|
|
|
|
scope: ["identify"],
|
|
|
|
|
expiresAt: "2026-04-26T13:00:00.000Z",
|
|
|
|
|
raw: {},
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async refreshAccessToken() {
|
|
|
|
|
throw new ProviderResponseError(400, "provider_status", "Discord token refresh failed", "invalid_grant");
|
|
|
|
|
},
|
|
|
|
|
async evaluateEntitlements(options) {
|
|
|
|
|
evaluations += 1;
|
|
|
|
|
return base.evaluateEntitlements(options);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
|
|
|
const callbackResponse = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
expect(callbackResponse.statusCode).toBe(201);
|
|
|
|
|
const evaluationsAtSignIn = evaluations;
|
|
|
|
|
|
|
|
|
|
const refreshResponse = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/repixelizer/sessions/refresh",
|
|
|
|
|
headers: { "x-heimdall-app-secret": "repixelizer-secret" },
|
|
|
|
|
payload: {
|
|
|
|
|
refreshToken: callbackResponse.json().refreshToken as string,
|
|
|
|
|
entitlementPolicies: [{ kind: "discord_role_access", guildId: "gamecult-guild", allowedRoleIds: ["role-repixelizer"] }],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(refreshResponse.statusCode).toBe(201);
|
|
|
|
|
expect(evaluations).toBe(evaluationsAtSignIn);
|
|
|
|
|
const accountId = callbackResponse.json().account.id as string;
|
|
|
|
|
const row = await store.findStoredLinkedIdentityForAccount(accountId, "discord");
|
|
|
|
|
expect(row?.accessTokenEncrypted).toBeUndefined();
|
|
|
|
|
expect(row?.refreshTokenEncrypted).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("revocation-is-typed: a revoked Patreon identity is not linked for Bifrost sync and Bifrost is not called", async () => {
|
|
|
|
|
const baseConfig = createTestConfig();
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({
|
|
|
|
|
config: {
|
|
|
|
|
...baseConfig,
|
|
|
|
|
bifrostPatronSupportEndpoint: "https://bifrost.gamecult.org/heimdall/patron-support/events",
|
|
|
|
|
bifrostPatronSupportSecret: "bifrost-support-secret",
|
|
|
|
|
},
|
|
|
|
|
store,
|
|
|
|
|
oauthRuntimes: {
|
|
|
|
|
patreon: {
|
|
|
|
|
...createOAuthRuntimeRegistry().patreon,
|
|
|
|
|
async refreshAccessToken() {
|
|
|
|
|
throw new ProviderResponseError(400, "provider_status", "Patreon token refresh failed", "invalid_grant");
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
apps.push(app);
|
|
|
|
|
const context = getHeimdallContext(app);
|
|
|
|
|
const now = "2026-06-09T17:00:00.000Z";
|
|
|
|
|
await store.createAccount({ id: "acct-p", createdAt: now, lastSeenAt: now });
|
|
|
|
|
await store.upsertLinkedIdentity({
|
|
|
|
|
accountId: "acct-p",
|
|
|
|
|
provider: "patreon",
|
|
|
|
|
providerUserId: "patreon-user-456",
|
|
|
|
|
accessTokenEncrypted: context.tokenCustody.encrypt("patreon-access-token"),
|
|
|
|
|
refreshTokenEncrypted: context.tokenCustody.encrypt("patreon-refresh-token"),
|
|
|
|
|
tokenExpiresAt: "2026-06-09T17:01:00.000Z",
|
|
|
|
|
scopes: [],
|
|
|
|
|
profileJson: {},
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
});
|
|
|
|
|
const urls: string[] = [];
|
|
|
|
|
globalThis.fetch = async (input) => {
|
|
|
|
|
urls.push(String(input));
|
|
|
|
|
return new Response("unexpected", { status: 500 });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/bifrost/patron-support/sync",
|
|
|
|
|
headers: { "x-heimdall-app-secret": "bifrost-secret" },
|
|
|
|
|
payload: { accountId: "acct-p", requiredTierTitle: "Inner Sanctum", supportedAtUtc: now },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(404);
|
|
|
|
|
expect(response.json()).toEqual({ error: "patreon_identity_not_linked" });
|
|
|
|
|
expect(urls).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-19 11:13:40 +02:00
|
|
|
describe("joystick connect", () => {
|
2026-10-19 14:34:24 +02:00
|
|
|
const creator = { kind: "creator", targetId: "creator-1" };
|
|
|
|
|
async function connect(app: FastifyInstance, extra: Record<string, unknown> = { connection: creator }) {
|
2026-10-19 11:13:40 +02:00
|
|
|
const start = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/joystick/start",
|
2026-10-19 14:34:24 +02:00
|
|
|
payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", ...extra },
|
2026-10-19 11:13:40 +02:00
|
|
|
});
|
|
|
|
|
return start;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 14:34:24 +02:00
|
|
|
it.each([
|
2026-10-19 14:45:35 +02:00
|
|
|
["no connection", {}, "creator_connection_required"],
|
|
|
|
|
["an account connection", { connection: { kind: "account", targetId: "victim-1" } }, "creator_connection_required"],
|
|
|
|
|
["an empty targetId", { connection: { kind: "creator", targetId: "" } }, "invalid_request"],
|
|
|
|
|
])("refuses a joystick connect with %s at start, before any redirect", async (_name, extra, error) => {
|
2026-10-19 14:34:24 +02:00
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
const start = await connect(app, extra);
|
|
|
|
|
expect(start.statusCode).toBe(400);
|
2026-10-19 14:45:35 +02:00
|
|
|
expect(start.json().error).toBe(error);
|
2026-10-19 14:34:24 +02:00
|
|
|
expect(start.json().authorizationUrl).toBeUndefined();
|
|
|
|
|
expect(start.json().stateToken).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
["no connection", null],
|
|
|
|
|
["an account connection", { kind: "account", targetId: "victim-1" }],
|
|
|
|
|
["an empty targetId", { kind: "creator", targetId: "" }],
|
|
|
|
|
])("the callback refuses a signed joystick connect with %s before the code exchange, and nothing is written", async (_name, connection) => {
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "heimdall-forge-"));
|
|
|
|
|
try {
|
|
|
|
|
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({ config, store });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
|
|
|
|
|
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
|
|
|
|
|
const stateToken = (await connect(app)).json().stateToken as string;
|
|
|
|
|
const verified = verifyJwt(stateToken, keys.publicKey);
|
|
|
|
|
expect(verified.valid).toBe(true);
|
|
|
|
|
const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, connection }, keys);
|
|
|
|
|
|
|
|
|
|
const callback = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`,
|
|
|
|
|
});
|
|
|
|
|
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
expect(await store.findStoredLinkedIdentity("joystick", "chan-forged")).toBeNull();
|
|
|
|
|
} finally {
|
|
|
|
|
rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-19 14:34:44 +02:00
|
|
|
it("a backend_callback delivery of a joystick connect carries no refresh token and no capabilities", async () => {
|
|
|
|
|
const callbackUrl = "https://streampixels.gamecult.org/api/heimdall/callback";
|
|
|
|
|
const app = await buildApp({
|
|
|
|
|
config: { ...createTestConfig(), appBackendCallbacks: { streampixels: [callbackUrl] } },
|
|
|
|
|
});
|
|
|
|
|
apps.push(app);
|
|
|
|
|
stubJoystick({ channel_id: "chan-hand", username: "deru" });
|
|
|
|
|
const providerFetch = globalThis.fetch;
|
|
|
|
|
const deliveries: Array<Record<string, any>> = [];
|
|
|
|
|
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
|
|
|
|
|
if (String(input) === callbackUrl) {
|
|
|
|
|
deliveries.push(JSON.parse(String(init?.body)) as Record<string, any>);
|
|
|
|
|
return new Response(null, { status: 204 });
|
|
|
|
|
}
|
|
|
|
|
return providerFetch(input, init);
|
|
|
|
|
}) as typeof fetch;
|
|
|
|
|
|
|
|
|
|
const start = await connect(app, {
|
|
|
|
|
connection: creator,
|
|
|
|
|
handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl },
|
|
|
|
|
});
|
|
|
|
|
expect(start.statusCode).toBe(201);
|
|
|
|
|
const callback = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
|
|
|
|
});
|
|
|
|
|
expect(callback.statusCode).toBeLessThan(300);
|
|
|
|
|
|
|
|
|
|
expect(deliveries).toHaveLength(1);
|
|
|
|
|
const delivered = deliveries[0]!;
|
|
|
|
|
expect(delivered).toMatchObject({ status: "success", handoffKind: "backend_callback", mode: "connect", attemptId: "attempt-js-1" });
|
|
|
|
|
expect(typeof delivered.accessToken).toBe("string");
|
|
|
|
|
expect(delivered.refreshToken).toBeUndefined();
|
|
|
|
|
expect(delivered.refresh).toBeUndefined();
|
|
|
|
|
expect(delivered.claimSet.capabilities).toEqual([]);
|
|
|
|
|
expect(JSON.stringify(delivered)).not.toContain("js-refresh");
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-19 11:13:40 +02:00
|
|
|
it("starts a streampixels connect at joystick.tv with the five scopes and the callback redirect", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
const start = await connect(app);
|
|
|
|
|
expect(start.statusCode).toBe(201);
|
|
|
|
|
const url = new URL(start.json().authorizationUrl as string);
|
|
|
|
|
expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize");
|
|
|
|
|
expect(url.searchParams.get("client_id")).toBe("joystick-client");
|
|
|
|
|
expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read");
|
|
|
|
|
expect(url.searchParams.get("redirect_uri")).toBe("https://heimdall.gamecult.org/v1/oauth/joystick/callback");
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-19 12:31:59 +02:00
|
|
|
it.each(["sign_in", "link"] as const)(
|
|
|
|
|
"refuses %s for joystick on streampixels before any redirect, and Deru's managed row is untouched",
|
|
|
|
|
async (mode) => {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({ config: createTestConfig(), store });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
stubJoystick({ channel_id: "deru-chan", username: "deru" });
|
|
|
|
|
const stateToken = (await connect(app)).json().stateToken as string;
|
|
|
|
|
const connected = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
const accountId = connected.json().account.id as string;
|
|
|
|
|
const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick");
|
|
|
|
|
|
|
|
|
|
const start = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/joystick/start",
|
|
|
|
|
payload: { appSlug: "streampixels", mode, returnTo: "https://streampixels.gamecult.org/auth/connect" },
|
|
|
|
|
});
|
|
|
|
|
expect(start.statusCode).toBe(400);
|
|
|
|
|
expect(start.json()).toMatchObject({ error: "provider_not_supported_for_app", provider: "joystick", mode });
|
|
|
|
|
expect(start.json().authorizationUrl).toBeUndefined();
|
|
|
|
|
expect(await store.findStoredLinkedIdentityForAccount(accountId, "joystick")).toEqual(before);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-19 11:13:40 +02:00
|
|
|
function stubJoystick(identity: Record<string, unknown>) {
|
2026-10-19 11:25:01 +02:00
|
|
|
const calls: Array<{ url: string; headers: Record<string, string>; body?: URLSearchParams | undefined }> = [];
|
2026-10-19 11:13:40 +02:00
|
|
|
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
|
|
|
|
|
const url = String(input);
|
|
|
|
|
calls.push({ url, headers: (init?.headers ?? {}) as Record<string, string>, body: init?.body as URLSearchParams | undefined });
|
|
|
|
|
if (url === "https://api.joystick.tv/api/oauth/token") {
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({ access_token: "js-access", refresh_token: "js-refresh", token_type: "Bearer", expires_in: 864000 }),
|
|
|
|
|
{ status: 200 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (url === "https://api.joystick.tv/api/v1/me/identity") {
|
|
|
|
|
return new Response(JSON.stringify(identity), { status: 200 });
|
|
|
|
|
}
|
|
|
|
|
return new Response("unexpected", { status: 500 });
|
|
|
|
|
}) as typeof fetch;
|
|
|
|
|
return calls;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 13:27:28 +02:00
|
|
|
it.each(["sign_in", "link"] as const)(
|
|
|
|
|
"the callback refuses a signed %s state for joystick on streampixels before the code exchange, and nothing is written",
|
|
|
|
|
async (mode) => {
|
|
|
|
|
// The start gate already refuses these words, so the state is re-signed with the app's own key.
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "heimdall-forge-"));
|
|
|
|
|
try {
|
|
|
|
|
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({ config, store });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
|
|
|
|
|
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
|
|
|
|
|
const stateToken = (await connect(app)).json().stateToken as string;
|
|
|
|
|
const verified = verifyJwt(stateToken, keys.publicKey);
|
|
|
|
|
expect(verified.valid).toBe(true);
|
|
|
|
|
const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, mode }, keys);
|
|
|
|
|
|
|
|
|
|
const callback = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`,
|
|
|
|
|
});
|
|
|
|
|
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
|
|
|
|
|
expect(callback.body).not.toContain("viewer_access");
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
expect(await store.findStoredLinkedIdentity("joystick", "chan-forged")).toBeNull();
|
|
|
|
|
} finally {
|
|
|
|
|
rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-19 11:13:40 +02:00
|
|
|
it("exchanges the code with Basic client auth and links the identity by channel id", async () => {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({ config: createTestConfig(), store });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" });
|
|
|
|
|
const stateToken = (await connect(app)).json().stateToken as string;
|
|
|
|
|
|
|
|
|
|
const callback = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
expect(callback.statusCode).toBeLessThan(300);
|
|
|
|
|
|
|
|
|
|
const exchange = calls.find((c) => c.url === "https://api.joystick.tv/api/oauth/token")!;
|
|
|
|
|
expect(exchange.headers.Authorization).toBe(`Basic ${Buffer.from("joystick-client:joystick-secret").toString("base64")}`);
|
|
|
|
|
expect(exchange.body!.get("grant_type")).toBe("authorization_code");
|
|
|
|
|
expect(exchange.body!.get("code")).toBe("the-code");
|
|
|
|
|
expect(exchange.body!.get("redirect_uri")).toBe("https://heimdall.gamecult.org/v1/oauth/joystick/callback");
|
|
|
|
|
expect(exchange.body!.has("client_secret")).toBe(false);
|
|
|
|
|
const identityCall = calls.find((c) => c.url === "https://api.joystick.tv/api/v1/me/identity")!;
|
|
|
|
|
expect(identityCall.headers.Authorization).toBe("Bearer js-access");
|
|
|
|
|
|
|
|
|
|
const accountId = callback.json().account.id as string;
|
|
|
|
|
const row = await store.findStoredLinkedIdentityForAccount(accountId, "joystick");
|
|
|
|
|
expect(row).toMatchObject({ providerUserId: "chan-9", username: "deru", displayName: "Deru!" });
|
|
|
|
|
expect(row?.accessTokenEncrypted).toEqual(expect.any(String));
|
|
|
|
|
expect(row?.accessTokenEncrypted).not.toContain("js-access");
|
|
|
|
|
expect(row?.refreshTokenEncrypted).toEqual(expect.any(String));
|
|
|
|
|
const left = Date.parse(row!.tokenExpiresAt!) - Date.now();
|
|
|
|
|
expect(left).toBeGreaterThan(863_000 * 1000);
|
|
|
|
|
expect(left).toBeLessThanOrEqual(864_000 * 1000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back to the username for the display name", async () => {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({ config: createTestConfig(), store });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
stubJoystick({ channel_id: "chan-9", username: "deru" });
|
|
|
|
|
const stateToken = (await connect(app)).json().stateToken as string;
|
|
|
|
|
const callback = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick");
|
|
|
|
|
expect(row?.displayName).toBe("deru");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("fails the callback when the identity has no channel id", async () => {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({ config: createTestConfig(), store });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
stubJoystick({ username: "deru" });
|
|
|
|
|
const stateToken = (await connect(app)).json().stateToken as string;
|
|
|
|
|
const callback = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
|
|
|
|
|
expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-08 13:16:19 +02:00
|
|
|
it("no longer honors a global shared secret across apps", async () => {
|
|
|
|
|
// A per-app identity model with a global fallback is a two-app shared
|
|
|
|
|
// secret in practice (R6, fork 2). GC_ACCESS_APP_SHARED_SECRET alone must
|
|
|
|
|
// produce no usable secret for any app.
|
|
|
|
|
const config = loadConfig({ GC_ACCESS_APP_SHARED_SECRET: "one-secret-for-everyone" });
|
|
|
|
|
expect(config.appSharedSecrets).toEqual({});
|
|
|
|
|
|
|
|
|
|
const app = await buildApp({
|
|
|
|
|
config: {
|
|
|
|
|
...createTestConfig(),
|
|
|
|
|
appSharedSecrets: {},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/streampixels/managed-credentials/resolve",
|
|
|
|
|
headers: { "x-heimdall-app-secret": "one-secret-for-everyone" },
|
|
|
|
|
payload: { accountId: "acct-1", provider: "twitch" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
|
|
|
expect(response.json()).toEqual(expect.objectContaining({ error: "app_auth_required" }));
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 16:21:21 +01:00
|
|
|
it("accepts Twitch token scopes returned as an array", async () => {
|
|
|
|
|
globalThis.fetch = async () =>
|
|
|
|
|
new Response(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
access_token: "twitch-access-token",
|
|
|
|
|
refresh_token: "twitch-refresh-token",
|
|
|
|
|
token_type: "bearer",
|
|
|
|
|
expires_in: 14124,
|
|
|
|
|
scope: ["user:read:email"],
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const runtimes = createOAuthRuntimeRegistry();
|
|
|
|
|
const tokenSet = await runtimes.twitch.exchangeAuthorizationCode({
|
|
|
|
|
config: createTestConfig(),
|
|
|
|
|
code: "test-code",
|
|
|
|
|
redirectUri: "https://heimdall.gamecult.org/v1/oauth/twitch/callback",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(tokenSet.scope).toEqual(["user:read:email"]);
|
|
|
|
|
});
|
2026-04-26 12:25:11 +01:00
|
|
|
});
|
2026-09-30 15:30:06 +02:00
|
|
|
|
|
|
|
|
// An answer never carries an input value. The canary stands in for whatever a
|
|
|
|
|
// failure could quote: part of a misbound database URL in a pg or resolver
|
2026-09-30 15:37:11 +02:00
|
|
|
// error, a request body, a header, a path, or a 4xx error's own message.
|
2026-09-30 15:30:06 +02:00
|
|
|
describe("error answers never echo an input", () => {
|
|
|
|
|
const CANARY = "CANARY";
|
|
|
|
|
|
|
|
|
|
function failingStore(): InMemoryStore {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
store.consumeAuthCompletion = async () => {
|
|
|
|
|
throw Object.assign(new Error(`getaddrinfo ENOTFOUND ${CANARY}host.invalid`), {
|
|
|
|
|
code: "ENOTFOUND",
|
|
|
|
|
hostname: `${CANARY}host.invalid`,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
return store;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("answers a store failure with a fixed 500 body and logs only the route and code", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig(), store: failingStore() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
const logged: string[] = [];
|
|
|
|
|
const originalError = console.error;
|
|
|
|
|
console.error = (...args: unknown[]) => void logged.push(args.map(String).join(" "));
|
|
|
|
|
try {
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/bifrost/auth-completions/redeem",
|
|
|
|
|
payload: { completionCode: "x" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
|
|
|
expect(response.json()).toEqual({ error: "internal_error" });
|
|
|
|
|
} finally {
|
|
|
|
|
console.error = originalError;
|
|
|
|
|
}
|
|
|
|
|
expect(logged).toEqual(["Heimdall POST /v1/apps/:appSlug/auth-completions/redeem failed (ENOTFOUND)."]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("answers a body that is not JSON without quoting it", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/bifrost/auth-completions/redeem",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
payload: `${CANARY}notjson`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
|
|
|
expect(response.json().error).toBe("invalid_request");
|
|
|
|
|
expect(response.body).not.toContain(CANARY);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 15:37:11 +02:00
|
|
|
it("answers any other 4xx error by its code, not its message", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
app.get("/v1/probe-4xx", async () => {
|
|
|
|
|
throw Object.assign(new Error(`refused ${CANARY}input`), { statusCode: 409, code: "PROBE_REFUSED" });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({ method: "GET", url: "/v1/probe-4xx" });
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
|
|
|
expect(response.json()).toEqual({ error: "invalid_request", code: "PROBE_REFUSED" });
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 15:30:06 +02:00
|
|
|
it("answers an unsupported media type without quoting the header", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/bifrost/auth-completions/redeem",
|
|
|
|
|
headers: { "content-type": `application/${CANARY}` },
|
|
|
|
|
payload: "x",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(415);
|
|
|
|
|
expect(response.body).not.toContain(CANARY);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("names the failed schema rule without quoting the request", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/apps/bifrost/auth-completions/redeem",
|
|
|
|
|
payload: { [`${CANARY}extra`]: `${CANARY}value` },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
|
|
|
expect(response.json().error).toBe("invalid_request");
|
|
|
|
|
expect(response.json().detail).toMatch(/^body: #\//);
|
|
|
|
|
expect(response.body).not.toContain(CANARY);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("answers an unknown route or app without quoting the path", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const route = await app.inject({ method: "GET", url: `/v1/${CANARY}route` });
|
|
|
|
|
expect(route.statusCode).toBe(404);
|
|
|
|
|
expect(route.json()).toEqual({ error: "not_found" });
|
|
|
|
|
|
|
|
|
|
const unknownApp = await app.inject({ method: "GET", url: `/v1/apps/${CANARY}app` });
|
|
|
|
|
expect(unknownApp.statusCode).toBe(404);
|
|
|
|
|
expect(unknownApp.json().error).toBe("unknown_app");
|
|
|
|
|
expect(unknownApp.body).not.toContain(CANARY);
|
|
|
|
|
|
|
|
|
|
const start = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/discord/start",
|
|
|
|
|
payload: { appSlug: `${CANARY}app`, mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
|
|
|
|
|
});
|
|
|
|
|
expect(start.statusCode).toBe(400);
|
|
|
|
|
expect(start.body).not.toContain(CANARY);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function failingDiscordRuntime(): OAuthProviderRuntime {
|
|
|
|
|
return {
|
|
|
|
|
...createMockDiscordRuntime(),
|
|
|
|
|
async exchangeAuthorizationCode() {
|
|
|
|
|
throw new SyntaxError(`Unexpected token 'a', "access_token=${CANARY}token" is not valid JSON`);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("renders a failed provider callback page without the failure's text", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
headers: { accept: "text/html" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.headers["content-type"]).toContain("text/html");
|
|
|
|
|
expect(response.body).toContain("oauth_callback_failed");
|
|
|
|
|
expect(response.body).not.toContain(CANARY);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("delivers a failed provider callback to the app backend without the failure's text", async () => {
|
|
|
|
|
const deliveries: string[] = [];
|
|
|
|
|
globalThis.fetch = async (_input, init) => {
|
|
|
|
|
deliveries.push(String(init?.body));
|
|
|
|
|
return new Response(null, { status: 204 });
|
|
|
|
|
};
|
|
|
|
|
const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const start = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/discord/start",
|
|
|
|
|
payload: {
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
|
|
|
handoff: {
|
|
|
|
|
kind: "backend_callback",
|
|
|
|
|
attemptId: "attempt-123",
|
|
|
|
|
callbackUrl: "https://repixelizer.gamecult.org/api/auth/heimdall/callback",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
expect(start.statusCode).toBe(201);
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(502);
|
|
|
|
|
expect(response.body).not.toContain(CANARY);
|
|
|
|
|
expect(deliveries).toHaveLength(1);
|
|
|
|
|
expect(JSON.parse(deliveries[0]!)).toMatchObject({ status: "error", error: "oauth_callback_failed" });
|
|
|
|
|
expect(deliveries[0]).not.toContain(CANARY);
|
|
|
|
|
});
|
|
|
|
|
});
|
Relayed provider errors are printable, bounded and inert in the page
Self's ruling: the provider_error relay stays, because it is the protocol,
but its error and error_description are query parameters anyone can write.
Only the characters RFC 6749 4.1.2.1 allows in them survive (%x20-21,
%x23-5B, %x5D-7E), and each is cut to a bound (64 and 256). The JSON
answer, the browser page and the backend handoff all take the relayed
values.
The page has no template engine. Every value in it goes through
serializeForScript, which escapes `<`, apart from the fallback URL in the
link's href, which the URL serializer percent-encodes. A test puts
`<script>` in error_description and checks the page holds only its own
script tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:49:30 +02:00
|
|
|
|
|
|
|
|
// The provider's error and error_description are relayed, but they arrive as
|
|
|
|
|
// query parameters anyone can write: only RFC 6749's characters survive, each
|
|
|
|
|
// is bounded, and the page cannot be made to run what they carry.
|
|
|
|
|
describe("the relayed provider error", () => {
|
|
|
|
|
const injected = `<script>alert(1)</script>"\\\u0007é${"x".repeat(300)}`;
|
|
|
|
|
const allowed = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/;
|
|
|
|
|
|
|
|
|
|
function providerErrorUrl(stateToken: string): string {
|
|
|
|
|
const query = new URLSearchParams({ state: stateToken, error: `${"e".repeat(100)}\u0000`, error_description: injected });
|
|
|
|
|
return `/v1/oauth/discord/callback?${query.toString()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("is printable and bounded in the JSON answer", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) });
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
|
|
|
const { providerError, providerErrorDescription } = response.json() as Record<string, string>;
|
|
|
|
|
expect(providerError).toBe("e".repeat(64));
|
|
|
|
|
expect(providerErrorDescription).toHaveLength(256);
|
|
|
|
|
expect(providerErrorDescription).toMatch(allowed);
|
2026-09-30 15:50:58 +02:00
|
|
|
expect(String(providerErrorDescription).startsWith("<script>alert(1)</script>xxx")).toBe(true);
|
Relayed provider errors are printable, bounded and inert in the page
Self's ruling: the provider_error relay stays, because it is the protocol,
but its error and error_description are query parameters anyone can write.
Only the characters RFC 6749 4.1.2.1 allows in them survive (%x20-21,
%x23-5B, %x5D-7E), and each is cut to a bound (64 and 256). The JSON
answer, the browser page and the backend handoff all take the relayed
values.
The page has no template engine. Every value in it goes through
serializeForScript, which escapes `<`, apart from the fallback URL in the
link's href, which the URL serializer percent-encodes. A test puts
`<script>` in error_description and checks the page holds only its own
script tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:49:30 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("cannot inject markup into the browser page, and is bounded there", async () => {
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: providerErrorUrl(await startDiscordSignIn(app)),
|
|
|
|
|
headers: { accept: "text/html" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.headers["content-type"]).toContain("text/html");
|
|
|
|
|
expect(response.body.match(/<script/gi)).toHaveLength(1);
|
|
|
|
|
expect(response.body).not.toContain("<script>alert");
|
|
|
|
|
expect(response.body).not.toContain("x".repeat(257));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("is printable and bounded in the backend handoff", async () => {
|
|
|
|
|
const deliveries: Array<Record<string, unknown>> = [];
|
|
|
|
|
globalThis.fetch = async (_input, init) => {
|
|
|
|
|
deliveries.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
|
|
|
|
|
return new Response(null, { status: 204 });
|
|
|
|
|
};
|
|
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
const start = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/v1/oauth/discord/start",
|
|
|
|
|
payload: {
|
|
|
|
|
appSlug: "repixelizer",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
|
|
|
handoff: {
|
|
|
|
|
kind: "backend_callback",
|
|
|
|
|
attemptId: "attempt-123",
|
|
|
|
|
callbackUrl: "https://repixelizer.gamecult.org/api/auth/heimdall/callback",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
expect(start.statusCode).toBe(201);
|
|
|
|
|
|
|
|
|
|
await app.inject({ method: "GET", url: providerErrorUrl(start.json().stateToken as string) });
|
|
|
|
|
|
|
|
|
|
expect(deliveries).toHaveLength(1);
|
|
|
|
|
const description = String(deliveries[0]!.errorDescription);
|
|
|
|
|
expect(description).toHaveLength(256);
|
|
|
|
|
expect(description).toMatch(allowed);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 15:49:32 +02:00
|
|
|
describe("the failed-callback audit event", () => {
|
|
|
|
|
it("keeps the error's class and code, not its text", async () => {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({
|
|
|
|
|
config: createTestConfig(),
|
|
|
|
|
store,
|
|
|
|
|
oauthRuntimes: {
|
|
|
|
|
discord: {
|
|
|
|
|
...createMockDiscordRuntime(),
|
|
|
|
|
async exchangeAuthorizationCode() {
|
|
|
|
|
throw Object.assign(new Error("getaddrinfo ENOTFOUND CANARYhost.invalid"), { code: "ENOTFOUND" });
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
apps.push(app);
|
|
|
|
|
|
|
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
expect(response.statusCode).toBe(502);
|
|
|
|
|
|
|
|
|
|
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
|
|
|
|
|
const failed = events.filter((event) => event.eventType === "oauth_callback_failed");
|
|
|
|
|
expect(failed).toHaveLength(1);
|
|
|
|
|
expect(failed[0]!.eventPayloadJson).toEqual({
|
|
|
|
|
provider: "discord",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
errorClass: "Error",
|
|
|
|
|
errorCode: "ENOTFOUND",
|
2026-09-30 16:03:57 +02:00
|
|
|
providerStatus: null,
|
2026-09-30 15:49:32 +02:00
|
|
|
});
|
|
|
|
|
expect(JSON.stringify(events)).not.toContain("CANARY");
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-30 16:03:57 +02:00
|
|
|
|
|
|
|
|
describe("the failed-callback audit event for a provider's HTTP refusal", () => {
|
|
|
|
|
async function auditAfterTokenExchangeAnswers(response: () => Response) {
|
|
|
|
|
globalThis.fetch = async () => response();
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({ config: createTestConfig(), store, oauthRuntimes: createOAuthRuntimeRegistry() });
|
|
|
|
|
apps.push(app);
|
|
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
|
|
|
const callback = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
|
|
|
});
|
|
|
|
|
expect(callback.statusCode).toBe(502);
|
|
|
|
|
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
|
|
|
|
|
return events.filter((event) => event.eventType === "oauth_callback_failed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("keeps the provider's status as a number and never its body", async () => {
|
|
|
|
|
const failed = await auditAfterTokenExchangeAnswers(
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
() => new Response('CANARYbody {"error":"invalid_grant"}', { status: 401 })
|
2026-09-30 16:03:57 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(failed).toHaveLength(1);
|
|
|
|
|
expect(failed[0]!.eventPayloadJson).toEqual({
|
|
|
|
|
provider: "discord",
|
|
|
|
|
mode: "sign_in",
|
2026-09-30 17:07:16 +02:00
|
|
|
errorClass: "ProviderResponseError",
|
|
|
|
|
errorCode: "provider_status",
|
2026-09-30 16:03:57 +02:00
|
|
|
providerStatus: 401,
|
|
|
|
|
});
|
|
|
|
|
expect(JSON.stringify(failed)).not.toContain("CANARY");
|
2026-09-30 17:07:16 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps a 2xx body that is not JSON out of the event, by class and code alone", async () => {
|
|
|
|
|
const failed = await auditAfterTokenExchangeAnswers(
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
() => new Response('CANARYaccess{"access_token":"a"}', { status: 200 })
|
2026-09-30 17:07:16 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(failed).toHaveLength(1);
|
|
|
|
|
expect(failed[0]!.eventPayloadJson).toEqual({
|
|
|
|
|
provider: "discord",
|
|
|
|
|
mode: "sign_in",
|
|
|
|
|
errorClass: "ProviderResponseError",
|
|
|
|
|
errorCode: "provider_body_not_json",
|
|
|
|
|
providerStatus: 200,
|
|
|
|
|
});
|
|
|
|
|
expect(JSON.stringify(failed)).not.toContain("CANARY");
|
2026-09-30 16:03:57 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps no status that is not an HTTP status", async () => {
|
|
|
|
|
const store = new InMemoryStore();
|
|
|
|
|
const app = await buildApp({
|
|
|
|
|
config: createTestConfig(),
|
|
|
|
|
store,
|
|
|
|
|
oauthRuntimes: {
|
|
|
|
|
discord: {
|
|
|
|
|
...createMockDiscordRuntime(),
|
|
|
|
|
async exchangeAuthorizationCode() {
|
|
|
|
|
throw Object.assign(new Error("refused"), { status: 9999 });
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
apps.push(app);
|
|
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
|
|
|
await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}` });
|
|
|
|
|
|
|
|
|
|
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: Record<string, unknown> }> }).auditEvents.values()];
|
|
|
|
|
const failed = events.find((event) => event.eventType === "oauth_callback_failed");
|
|
|
|
|
expect(failed?.eventPayloadJson.providerStatus).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-30 17:07:45 +02:00
|
|
|
|
|
|
|
|
describe("a backend handoff the app refuses", () => {
|
|
|
|
|
it("fails by status alone, never with the app's answer body", async () => {
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
globalThis.fetch = async () => new Response('CANARYhandoff {"error":"rejected"}', { status: 500 });
|
2026-09-30 17:07:45 +02:00
|
|
|
const failure = await deliverBackendHandoff("https://bifrost.gamecult.org/auth/heimdall/callback", {
|
|
|
|
|
source: "heimdall",
|
|
|
|
|
kind: "oauth_result",
|
|
|
|
|
handoffKind: "backend_callback",
|
|
|
|
|
attemptId: "attempt-1",
|
|
|
|
|
status: "error",
|
|
|
|
|
provider: "discord",
|
|
|
|
|
appSlug: "bifrost",
|
|
|
|
|
returnTo: "https://bifrost.gamecult.org/",
|
|
|
|
|
connection: null,
|
|
|
|
|
error: "oauth_callback_failed",
|
|
|
|
|
} as BackendHandoffPayload).then(
|
|
|
|
|
() => undefined,
|
|
|
|
|
(error: unknown) => error
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(failure).toBeInstanceOf(Error);
|
|
|
|
|
expect((failure as Error).message).toBe("Backend handoff delivery failed (status 500).");
|
|
|
|
|
expect(JSON.stringify(failure) + String(failure)).not.toContain("CANARY");
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-30 17:16:39 +02:00
|
|
|
|
|
|
|
|
describe("a provider answer Heimdall cannot use", () => {
|
|
|
|
|
const runtimes = createOAuthRuntimeRegistry();
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
// The canary leads each body, so a message carrying any prefix of it shows.
|
|
|
|
|
const notJson = 'CANARYaccess{"access_token":"a"}';
|
|
|
|
|
const refusal = 'CANARYrefused {"error":"invalid_grant"}';
|
2026-09-30 17:16:39 +02:00
|
|
|
|
|
|
|
|
/** What the call threw, as every form a catcher might record or print. */
|
|
|
|
|
async function failureOf(call: () => Promise<unknown>) {
|
|
|
|
|
const error = await call().then(
|
|
|
|
|
() => undefined,
|
|
|
|
|
(caught: unknown) => caught
|
|
|
|
|
);
|
|
|
|
|
expect(error).toBeInstanceOf(Error);
|
|
|
|
|
return {
|
|
|
|
|
error: error as Error & { code?: unknown; status?: unknown },
|
2026-09-30 20:40:13 +02:00
|
|
|
// inspect shows what the others miss, such as a non-enumerable cause.
|
|
|
|
|
printed: `${String(error)} ${(error as Error).stack ?? ""} ${JSON.stringify(error)} ${inspect(error, { depth: 5, showHidden: true })}`,
|
2026-09-30 17:16:39 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
["a 2xx body that is not JSON", () => new Response(notJson, { status: 200 }), "provider_body_not_json", 200],
|
|
|
|
|
["a refusal", () => new Response(refusal, { status: 401 }), "provider_status", 401],
|
|
|
|
|
])("fails the Discord token exchange on %s by status and code alone", async (_name, answer, code, status) => {
|
|
|
|
|
globalThis.fetch = async () => answer();
|
|
|
|
|
const { error, printed } = await failureOf(() =>
|
|
|
|
|
runtimes.discord.exchangeAuthorizationCode({ config: createTestConfig(), code: "c", redirectUri: "https://heimdall.gamecult.org/cb" })
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(error.name).toBe("ProviderResponseError");
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
expect(error.message).toBe(`Discord token exchange failed (status ${status}, ${code}).`);
|
2026-09-30 17:16:39 +02:00
|
|
|
expect(error.code).toBe(code);
|
|
|
|
|
expect(error.status).toBe(status);
|
|
|
|
|
expect(printed).not.toContain("CANARY");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("fails the Discord guild lookup on a 2xx body that is not JSON by status and code alone", async () => {
|
|
|
|
|
globalThis.fetch = async () => new Response(notJson, { status: 200 });
|
|
|
|
|
const { error, printed } = await failureOf(() =>
|
|
|
|
|
runtimes.discord.evaluateEntitlements({
|
|
|
|
|
config: createTestConfig(),
|
|
|
|
|
callback: {
|
|
|
|
|
appSlug: "ghostlight",
|
|
|
|
|
accountId: "account-1",
|
|
|
|
|
connection: null,
|
|
|
|
|
entitlementPolicy: { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] },
|
|
|
|
|
},
|
|
|
|
|
identity: { provider: "discord", providerUserId: "u1", profile: {} },
|
|
|
|
|
tokenSet: { accessToken: "a", tokenType: "Bearer", scope: [], raw: {} },
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
expect(error.message).toBe("Discord guild member lookup failed (status 200, provider_body_not_json).");
|
2026-09-30 17:16:39 +02:00
|
|
|
expect(error.code).toBe("provider_body_not_json");
|
|
|
|
|
expect(printed).not.toContain("CANARY");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("fails the YouTube channel lookup on a 2xx body that is not JSON by status and code alone", async () => {
|
|
|
|
|
globalThis.fetch = async () => new Response(notJson, { status: 200 });
|
|
|
|
|
const { error, printed } = await failureOf(() =>
|
|
|
|
|
runtimes.youtube.resolveIdentity({ config: createTestConfig(), accessToken: "a" })
|
|
|
|
|
);
|
|
|
|
|
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
expect(error.message).toBe("YouTube channel lookup failed (status 200, provider_body_not_json).");
|
2026-09-30 17:16:39 +02:00
|
|
|
expect(error.code).toBe("provider_body_not_json");
|
|
|
|
|
expect(printed).not.toContain("CANARY");
|
|
|
|
|
});
|
|
|
|
|
});
|
Pin provider errors and Bifrost's reply exactly; defend errorIdentity's filters
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:57:17 +02:00
|
|
|
|
|
|
|
|
describe("what may be recorded about a caught error", () => {
|
|
|
|
|
it("keeps a short identifier class and code", () => {
|
|
|
|
|
expect(errorIdentity(Object.assign(new TypeError("CANARY message"), { code: "ERR_INVALID_URL" }))).toEqual({
|
|
|
|
|
errorClass: "TypeError",
|
|
|
|
|
errorCode: "ERR_INVALID_URL",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
["a code that carries input", Object.assign(new Error("m"), { code: "CANARY host.invalid:5432" }), { errorClass: "Error", errorCode: null }],
|
|
|
|
|
["a code longer than an identifier", Object.assign(new Error("m"), { code: `CANARY${"x".repeat(64)}` }), { errorClass: "Error", errorCode: null }],
|
|
|
|
|
["a code that is not a string", Object.assign(new Error("m"), { code: 42 }), { errorClass: "Error", errorCode: null }],
|
|
|
|
|
["a class name that carries input", Object.assign(new Error("m"), { name: "CANARY: postgres://u:p@h/db" }), { errorClass: "Error", errorCode: null }],
|
|
|
|
|
["a class name longer than an identifier", Object.assign(new Error("m"), { name: `CANARY${"x".repeat(64)}` }), { errorClass: "Error", errorCode: null }],
|
|
|
|
|
["a thrown value that is not an Error", { name: "CANARYname", code: "CANARY code" }, { errorClass: "Error", errorCode: null }],
|
|
|
|
|
["a thrown null", null, { errorClass: "Error", errorCode: null }],
|
|
|
|
|
])("filters %s", (_case, error, expected) => {
|
|
|
|
|
expect(errorIdentity(error)).toEqual(expected);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-19 12:31:59 +02:00
|
|
|
|
|
|
|
|
describe("OAuth start admits a provider by the profile's role for the mode", () => {
|
|
|
|
|
const allProviders = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const;
|
|
|
|
|
const modes = ["sign_in", "link", "connect"] as const;
|
|
|
|
|
|
2026-10-19 12:38:13 +02:00
|
|
|
it("sign_in and link take identityProviders only; connect takes managedConnectionProviders or identityProviders, for every profile", async () => {
|
2026-10-19 12:31:59 +02:00
|
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
|
|
|
try {
|
|
|
|
|
for (const profile of Object.values(builtInAppProfiles)) {
|
|
|
|
|
for (const provider of allProviders) {
|
|
|
|
|
for (const mode of modes) {
|
|
|
|
|
const start = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: `/v1/oauth/${provider}/start`,
|
|
|
|
|
payload: { appSlug: profile.slug, mode, returnTo: `${profile.allowedReturnOrigins[0]}/x` },
|
|
|
|
|
});
|
2026-10-19 12:38:13 +02:00
|
|
|
const roleList =
|
|
|
|
|
mode === "connect"
|
|
|
|
|
? [...profile.managedConnectionProviders, ...profile.identityProviders]
|
|
|
|
|
: profile.identityProviders;
|
2026-10-19 12:31:59 +02:00
|
|
|
const refused = start.json().error === "provider_not_supported_for_app";
|
|
|
|
|
expect([profile.slug, provider, mode, refused]).toEqual([profile.slug, provider, mode, !roleList.includes(provider)]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
await app.close();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-19 13:23:53 +02:00
|
|
|
|
|
|
|
|
describe("callbackMint", () => {
|
|
|
|
|
const streampixels = builtInAppProfiles.streampixels!;
|
|
|
|
|
it.each([
|
|
|
|
|
["twitch", "sign_in", "viewer_session"],
|
|
|
|
|
["twitch", "link", "viewer_session"],
|
|
|
|
|
["youtube", "sign_in", "viewer_session"],
|
|
|
|
|
["twitch", "connect", "viewer_session"],
|
|
|
|
|
["joystick", "connect", "custody_only"],
|
|
|
|
|
["joystick", "sign_in", "refused"],
|
|
|
|
|
["joystick", "link", "refused"],
|
|
|
|
|
["discord", "connect", "refused"],
|
|
|
|
|
["discord", "sign_in", "refused"],
|
|
|
|
|
] as const)("%s %s on streampixels is %s", (provider, mode, expected) => {
|
2026-10-19 14:34:24 +02:00
|
|
|
expect(callbackMint(streampixels, provider, mode, { kind: "creator", targetId: "creator-1" })).toBe(expected);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
["null", null],
|
|
|
|
|
["undefined", undefined],
|
|
|
|
|
["an account binding", { kind: "account", targetId: "a-1" }],
|
|
|
|
|
["an empty creator targetId", { kind: "creator", targetId: "" }],
|
|
|
|
|
] as const)("a joystick connect with %s is refused", (_name, connection) => {
|
|
|
|
|
expect(callbackMint(streampixels, "joystick", "connect", connection)).toBe("refused");
|
2026-10-19 13:23:53 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps Bifrost's discord connect a viewer session", () => {
|
2026-10-19 14:34:24 +02:00
|
|
|
expect(callbackMint(builtInAppProfiles.bifrost!, "discord", "connect", null)).toBe("viewer_session");
|
2026-10-19 13:23:53 +02:00
|
|
|
});
|
|
|
|
|
});
|