Joystick joins the catalog (Basic client auth, rotatesRefreshTokens), resolves identity by channel id, and is a streampixels managed-connection provider only: it is not an identity provider for the profile (ruling signin-waits-for-public-bot). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
616 lines
32 KiB
TypeScript
616 lines
32 KiB
TypeScript
// Soul attack probes, third pass: R21.1 (attempt-handle binding), R21.2
|
|
// (runtime AppCaller brand), R21.3 (registered-app path deletion) and the
|
|
// partial unique index on auth_completions(app_slug, attempt_id).
|
|
//
|
|
// Same convention as the other two Soul files: plain `it` is an invariant
|
|
// that currently holds; `it.fails` is a hole this branch does not close.
|
|
// The "live postgres" block runs only when SOUL_PG_URL names a reachable
|
|
// database; it is skipped in the ordinary suite so the suite stays hermetic.
|
|
import { encode, decode } from "@msgpack/msgpack";
|
|
import { invokeCultNetOperation } from "cultnet-ts";
|
|
import { Pool } from "pg";
|
|
import { newDb } from "pg-mem";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { buildApp, getHeimdallRuntimeContext, refreshAppSession, startOAuthFlow } from "../src/app.js";
|
|
import { type AppCaller, isAppCaller, resolveAppCaller } from "../src/app-caller.js";
|
|
import { getAppProfile } from "../src/app-profiles.js";
|
|
import { type HeimdallConfig } from "../src/config.js";
|
|
import { type AppSlug } from "../src/contracts.js";
|
|
import { entitlementFacts } from "../src/facts.js";
|
|
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
|
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
|
|
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
|
|
import { InMemoryStore } from "../src/store/index.js";
|
|
import { PostgresStore } from "../src/store/postgres.js";
|
|
import { CREATE_SCHEMA_SQL } from "../src/store/schema.js";
|
|
import { type CreateAuthCompletionInput, type HeimdallStore } from "../src/store/types.js";
|
|
|
|
const ghostlightSecret = "ghostlight-private-command-secret";
|
|
const resources: Array<{ close(): Promise<unknown> }> = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(resources.splice(0).reverse().map((resource) => resource.close()));
|
|
});
|
|
|
|
function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
|
|
return {
|
|
serviceName: "heimdall",
|
|
host: "127.0.0.1",
|
|
port: 4100,
|
|
privateCommandHost: "127.0.0.1",
|
|
privateCommandPort: 0,
|
|
workspaceRoot: "F:/Projects/Heimdall",
|
|
dataRoot: "F:/Projects/Heimdall/.heimdall-data",
|
|
cultCachePath: "F:/Projects/Heimdall/.heimdall-data/cultcache/test.cc",
|
|
publicBaseUrl: "https://heimdall.gamecult.org",
|
|
issuer: "https://heimdall.gamecult.org",
|
|
daemonId: "yggdrasil-heimdall",
|
|
idunnRudpHealth: undefined,
|
|
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
|
|
providerHealthIdentityPath: "F:/Projects/Heimdall/.heimdall-data/provider-health-test.cc",
|
|
sessionTtlSeconds: 3600,
|
|
refreshTtlSeconds: 3600,
|
|
stateTtlSeconds: 600,
|
|
completionTtlSeconds: 300,
|
|
bootstrapSigningPrivateKeyOnMissing: false,
|
|
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
|
appSharedSecrets: { repixelizer: "repixelizer-secret", ghostlight: ghostlightSecret },
|
|
appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] },
|
|
appBackendCallbacks: {},
|
|
storage: { backend: "memory", applySchemaOnStartup: true },
|
|
providers: {
|
|
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
|
patreon: {},
|
|
github: {},
|
|
twitch: {},
|
|
youtube: {},
|
|
spotify: {},
|
|
joystick: {},
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// The provider "code" doubles as the identity: code `victim` resolves to
|
|
// discord user `victim`, so one runtime plays every party.
|
|
function identityByCodeRuntime(): OAuthProviderRuntime {
|
|
return {
|
|
async exchangeAuthorizationCode({ code }) {
|
|
return { accessToken: code, tokenType: "Bearer", scope: ["identify"], raw: {} };
|
|
},
|
|
async resolveIdentity({ accessToken }) {
|
|
return { provider: "discord", providerUserId: accessToken, displayName: accessToken, profile: {} };
|
|
},
|
|
async evaluateEntitlements() {
|
|
return { facts: [entitlementFacts.appAccess], snapshots: [] };
|
|
},
|
|
};
|
|
}
|
|
|
|
async function harness(overrides: Partial<HeimdallConfig> = {}) {
|
|
const config = testConfig(overrides);
|
|
const store = new InMemoryStore();
|
|
const app = await buildApp({ config, store, oauthRuntimes: { discord: identityByCodeRuntime() } });
|
|
resources.push(app);
|
|
return { app, store, config };
|
|
}
|
|
|
|
type App = Awaited<ReturnType<typeof harness>>["app"];
|
|
|
|
async function start(app: App, payload: unknown, headers: Record<string, string> = {}) {
|
|
return app.inject({ method: "POST", url: "/v1/oauth/discord/start", payload: payload as never, headers });
|
|
}
|
|
|
|
async function completeAs(app: App, stateToken: string, who: string) {
|
|
return app.inject({
|
|
method: "GET",
|
|
url: `/v1/oauth/discord/callback?code=${who}&state=${encodeURIComponent(stateToken)}`,
|
|
headers: { accept: "application/json" },
|
|
});
|
|
}
|
|
|
|
function jwtPayload(token: string): Record<string, unknown> {
|
|
const [, body] = token.split(".");
|
|
return JSON.parse(Buffer.from(body ?? "", "base64url").toString("utf8")) as Record<string, unknown>;
|
|
}
|
|
|
|
function planeRequest(operation: string, messageId: string, envelope: HeimdallPrivateEnvelope) {
|
|
return {
|
|
schemaVersion: "cultnet.operation_request.v0" as const,
|
|
messageId,
|
|
serviceId: "heimdall.private.commands",
|
|
operation,
|
|
payloadSchema: "heimdall.private_command_envelope.v1",
|
|
payloadEncoding: "messagepack-base64" as const,
|
|
payload: Buffer.from(encode(envelope)).toString("base64"),
|
|
sourceRuntimeId: "yggdrasil-ghostlight",
|
|
};
|
|
}
|
|
|
|
async function planeCommand(
|
|
endpoint: Parameters<typeof invokeCultNetOperation>[0],
|
|
operation: string,
|
|
idempotencyKey: string,
|
|
payload: Record<string, unknown>,
|
|
): Promise<Record<string, unknown>> {
|
|
const contentSchema = operation === "heimdall.auth.begin" ? "heimdall.auth_begin_command.v1" : "heimdall.auth_complete_command.v1";
|
|
const envelope = sealPrivateEnvelope({ appSlug: "ghostlight", operation, contentSchema, idempotencyKey, secret: ghostlightSecret, payload });
|
|
const response = await invokeCultNetOperation(endpoint, planeRequest(operation, `msg-${idempotencyKey}`, envelope), {
|
|
runtimeId: "yggdrasil-ghostlight",
|
|
});
|
|
expect(response.status, JSON.stringify(response.diagnostics)).toBe("accepted");
|
|
const wire = decode(Buffer.from(response.payload, "base64")) as HeimdallPrivateEnvelope;
|
|
return openPrivateEnvelope(wire, ghostlightSecret);
|
|
}
|
|
|
|
async function planeHarness() {
|
|
const { app, store, config } = await harness();
|
|
const plane = await startHeimdallPrivateCommandPlane(app, config);
|
|
resources.push(plane);
|
|
const begin = async (key: string) =>
|
|
planeCommand(plane.endpoint, "heimdall.auth.begin", key, {
|
|
provider: "discord",
|
|
mode: "sign_in",
|
|
returnTo: "https://yggdrasil.gamecult.org/ghostlight/",
|
|
entitlementPolicy: { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] },
|
|
});
|
|
const complete = async (key: string, handle: string) => planeCommand(plane.endpoint, "heimdall.auth.complete", key, { handle });
|
|
return { app, store, config, begin, complete };
|
|
}
|
|
|
|
function completionInput(appSlug: AppSlug, accountId: string, sessionId: string, attemptId?: string): CreateAuthCompletionInput {
|
|
const now = new Date();
|
|
return {
|
|
...(attemptId ? { attemptId } : {}),
|
|
appSlug,
|
|
provider: "discord",
|
|
mode: "sign_in",
|
|
accountId,
|
|
sessionId,
|
|
returnTo: "https://yggdrasil.gamecult.org/ghostlight/",
|
|
createdAt: now.toISOString(),
|
|
expiresAt: new Date(now.getTime() + 300_000).toISOString(),
|
|
payloadJson: { marker: attemptId ?? "none" },
|
|
};
|
|
}
|
|
|
|
// Seeds the account/session rows the FK constraints on auth_completions
|
|
// require in postgres; harmless in memory.
|
|
async function seedAccountAndSession(store: HeimdallStore, appSlug: AppSlug) {
|
|
const now = new Date().toISOString();
|
|
const account = await store.createAccount({ createdAt: now, lastSeenAt: now });
|
|
const session = await store.createSession({
|
|
id: `sess-${account.id}`,
|
|
accountId: account.id,
|
|
appSlug,
|
|
createdAt: now,
|
|
lastSeenAt: now,
|
|
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
claimsJson: {},
|
|
accessRevision: 0,
|
|
});
|
|
return { accountId: account.id, sessionId: session.id };
|
|
}
|
|
|
|
describe("R21.1: the attempt handle is unreachable from the public start route", () => {
|
|
const legitReturn = "https://yggdrasil.gamecult.org/ghostlight/";
|
|
|
|
it("a well-formed browser_completion attemptId is dropped before the state token is signed", async () => {
|
|
const { app, store } = await harness();
|
|
const attempt = await store.createAuthAttempt({
|
|
appSlug: "ghostlight",
|
|
provider: "discord",
|
|
mode: "sign_in",
|
|
returnTo: legitReturn,
|
|
createdAt: new Date().toISOString(),
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
});
|
|
const response = await start(app, {
|
|
appSlug: "ghostlight",
|
|
mode: "sign_in",
|
|
returnTo: legitReturn,
|
|
handoff: { kind: "browser_completion", attemptId: attempt.handle },
|
|
});
|
|
expect(response.statusCode, response.body).toBe(201);
|
|
expect(response.json().handoff).toEqual({ kind: "browser_completion" });
|
|
const state = jwtPayload(response.json().stateToken as string);
|
|
expect(state.handoff).toEqual({ kind: "browser_completion" });
|
|
expect(JSON.stringify(state)).not.toContain(attempt.handle);
|
|
});
|
|
|
|
// Fastify's default AJV runs with removeAdditional and coerceTypes:"array",
|
|
// so `additionalProperties: false` strips unknown keys instead of
|
|
// rejecting, and `["h"]` is coerced to "h". The oracle is therefore not
|
|
// the status code: it is that the planted value is absent from the
|
|
// response's handoff and from the signed state payload.
|
|
const planted = "planted-handle-7f3a";
|
|
it.each([
|
|
["differently-cased key", { kind: "browser_completion", AttemptId: planted }],
|
|
["snake_case key", { kind: "browser_completion", attempt_id: planted }],
|
|
["array value (coerced to a string by AJV)", { kind: "browser_completion", attemptId: [planted] }],
|
|
["object value", { kind: "browser_completion", attemptId: { value: planted } }],
|
|
["nested handoff", { kind: "browser_completion", handoff: { attemptId: planted } }],
|
|
["empty string", { kind: "browser_completion", attemptId: "" }],
|
|
])("a %s attemptId never reaches the state token", async (_label, handoff) => {
|
|
const { app } = await harness();
|
|
const response = await start(app, { appSlug: "ghostlight", mode: "sign_in", returnTo: legitReturn, handoff });
|
|
expect(response.body).not.toContain(planted);
|
|
if (response.statusCode === 201) {
|
|
expect(response.json().handoff).toEqual({ kind: "browser_completion" });
|
|
expect(jwtPayload(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" });
|
|
} else {
|
|
expect(response.statusCode, response.body).toBe(400);
|
|
}
|
|
});
|
|
|
|
it("a top-level attemptId / trustedBrowserAttemptId / options field on the body is stripped, never bound", async () => {
|
|
const { app } = await harness();
|
|
for (const extra of [{ attemptId: planted }, { trustedBrowserAttemptId: planted }, { options: { trustedBrowserAttemptId: planted } }]) {
|
|
const response = await start(app, { appSlug: "ghostlight", mode: "sign_in", returnTo: legitReturn, ...extra });
|
|
expect(response.statusCode, response.body).toBe(201);
|
|
expect(response.body).not.toContain(planted);
|
|
expect(jwtPayload(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" });
|
|
}
|
|
});
|
|
|
|
it("startOAuthFlow has exactly two callers and only beginAuth passes the options argument", async () => {
|
|
const { readFileSync, readdirSync } = await import("node:fs");
|
|
const { join } = await import("node:path");
|
|
const srcDir = join(process.cwd(), "src");
|
|
const callers: string[] = [];
|
|
for (const file of readdirSync(srcDir, { recursive: true }) as string[]) {
|
|
if (!file.endsWith(".ts")) continue;
|
|
const source = readFileSync(join(srcDir, file), "utf8");
|
|
const calls = source.match(/(?<!function )startOAuthFlow\(/g) ?? [];
|
|
if (calls.length) callers.push(`${file}:${calls.length}`);
|
|
if (file.endsWith("app.ts")) {
|
|
// The HTTP route's call ends at the body; no fifth argument.
|
|
expect(source).toMatch(/startOAuthFlow\(\{ config, keys, store \}, request\.params\.provider, caller, request\.body\);/);
|
|
}
|
|
}
|
|
expect(callers.sort()).toEqual(["app.ts:1", "private-command-plane.ts:1"]);
|
|
});
|
|
|
|
it("legit flow: beginAuth binds its own handle, the callback correlates it, completeAuth redeems it", async () => {
|
|
const { app, store, begin, complete } = await planeHarness();
|
|
const receipt = await begin("begin-legit");
|
|
expect(receipt.status).toBe("pending");
|
|
const handle = String(receipt.handle);
|
|
const navigation = receipt.navigation as { url: string };
|
|
const state = new URL(navigation.url).searchParams.get("state") ?? "";
|
|
expect(jwtPayload(state).handoff).toEqual({ kind: "browser_completion", attemptId: handle });
|
|
|
|
const callback = await completeAs(app, state, "victim");
|
|
expect(callback.statusCode, callback.body).toBe(201);
|
|
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("completed");
|
|
|
|
const completed = await complete("complete-legit", handle);
|
|
expect(completed.status).toBe("authenticated");
|
|
expect(completed.handle).toBe(handle);
|
|
expect(completed.accessToken).toEqual(expect.any(String));
|
|
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("consumed");
|
|
});
|
|
|
|
it("a stranger cannot pre-occupy the app's pending handle so that the victim's own completion collides", async () => {
|
|
const { app, store, begin, complete } = await planeHarness();
|
|
const receipt = await begin("begin-victim");
|
|
const handle = String(receipt.handle);
|
|
const victimState = new URL((receipt.navigation as { url: string }).url).searchParams.get("state") ?? "";
|
|
|
|
// Attacker names the victim's handle on the public start route, then
|
|
// completes as themselves before the victim does.
|
|
const attackerStart = await start(app, {
|
|
appSlug: "ghostlight",
|
|
mode: "sign_in",
|
|
returnTo: legitReturn,
|
|
handoff: { kind: "browser_completion", attemptId: handle },
|
|
});
|
|
expect(attackerStart.statusCode).toBe(201);
|
|
const attackerCallback = await completeAs(app, attackerStart.json().stateToken as string, "attacker");
|
|
expect(attackerCallback.statusCode, attackerCallback.body).toBe(201);
|
|
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("pending");
|
|
|
|
// Victim completes; the handle's single unconsumed slot is still free.
|
|
const victimCallback = await completeAs(app, victimState, "victim");
|
|
expect(victimCallback.statusCode, victimCallback.body).toBe(201);
|
|
const completed = await complete("complete-victim", handle);
|
|
expect(completed.status).toBe("authenticated");
|
|
// ...and the tokens are the victim's, not the attacker's.
|
|
const claims = jwtPayload(String(completed.accessToken));
|
|
expect(JSON.stringify(claims)).toContain("victim");
|
|
expect(JSON.stringify(claims)).not.toContain("attacker");
|
|
});
|
|
|
|
it("a backend_callback attemptId (the other handoff kind that carries one) never touches an auth attempt", async () => {
|
|
const { app, store } = await harness({ appBackendCallbacks: { ghostlight: ["http://127.0.0.1:1/never"] } });
|
|
const attempt = await store.createAuthAttempt({
|
|
appSlug: "ghostlight",
|
|
provider: "discord",
|
|
mode: "sign_in",
|
|
returnTo: legitReturn,
|
|
createdAt: new Date().toISOString(),
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
});
|
|
const response = await start(app, {
|
|
appSlug: "ghostlight",
|
|
mode: "sign_in",
|
|
returnTo: legitReturn,
|
|
handoff: { kind: "backend_callback", attemptId: attempt.handle, callbackUrl: "http://127.0.0.1:1/never" },
|
|
});
|
|
expect(response.statusCode, response.body).toBe(201);
|
|
// Delivery to port 1 fails; whatever the callback returns, the attempt
|
|
// must be untouched and no completion may be bound to it.
|
|
await completeAs(app, response.json().stateToken as string, "attacker");
|
|
expect((await store.findAuthAttempt("ghostlight", attempt.handle))?.status).toBe("pending");
|
|
expect(await store.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, new Date().toISOString())).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("R21.2: the WeakSet brand at every consumer", () => {
|
|
it("every function that takes an AppCaller checks isAppCaller before reading appSlug", async () => {
|
|
const { readFileSync, readdirSync } = await import("node:fs");
|
|
const { join } = await import("node:path");
|
|
const srcDir = join(process.cwd(), "src");
|
|
const consumers: string[] = [];
|
|
for (const file of readdirSync(srcDir, { recursive: true }) as string[]) {
|
|
if (!file.endsWith(".ts") || file.endsWith("app-caller.ts")) continue;
|
|
const source = readFileSync(join(srcDir, file), "utf8");
|
|
const readsSlug = source.match(/caller\.appSlug/g) ?? [];
|
|
const guards = source.match(/isAppCaller\(caller\)/g) ?? [];
|
|
if (readsSlug.length) consumers.push(`${file}: reads=${readsSlug.length} guards=${guards.length}`);
|
|
// Every `caller.appSlug` read is on a line that also carries the guard.
|
|
for (const line of source.split("\n")) {
|
|
if (line.includes("caller.appSlug")) expect(line, `${file}: ${line.trim()}`).toContain("isAppCaller(caller)");
|
|
}
|
|
}
|
|
expect(consumers).toEqual(["app.ts: reads=2 guards=2"]);
|
|
});
|
|
|
|
it("a caller minted for app A is refused where app B is expected, over HTTP and by direct call", async () => {
|
|
const { app, config } = await harness();
|
|
const ctx = getHeimdallRuntimeContext(app);
|
|
const repixelizerCaller = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
|
|
expect(isAppCaller(repixelizerCaller)).toBe(true);
|
|
|
|
const startResult = await startOAuthFlow(ctx, "discord", repixelizerCaller, {
|
|
appSlug: "ghostlight",
|
|
mode: "sign_in",
|
|
returnTo: "https://yggdrasil.gamecult.org/ghostlight/",
|
|
entitlementPolicy: { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] },
|
|
});
|
|
expect(startResult.statusCode).toBe(401);
|
|
expect(startResult.body).toMatchObject({ error: "app_auth_required" });
|
|
|
|
const refreshResult = await refreshAppSession(ctx, "ghostlight", repixelizerCaller, {
|
|
refreshToken: "x",
|
|
entitlementPolicies: [{ kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] }],
|
|
});
|
|
expect(refreshResult.statusCode).toBe(401);
|
|
|
|
// Over HTTP: repixelizer's real secret on a ghostlight start with policy.
|
|
const http = await start(
|
|
app,
|
|
{
|
|
appSlug: "ghostlight",
|
|
mode: "sign_in",
|
|
returnTo: "https://yggdrasil.gamecult.org/ghostlight/",
|
|
entitlementPolicy: { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] },
|
|
},
|
|
{ "x-heimdall-app-secret": "repixelizer-secret" },
|
|
);
|
|
expect(http.statusCode).toBe(401);
|
|
});
|
|
|
|
it("symbol-copying, prototype-chaining, Proxy-wrapping and structuredClone forgeries are all refused", async () => {
|
|
const { config } = await harness();
|
|
const real = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" }) as AppCaller;
|
|
const [brandSymbol] = Object.getOwnPropertySymbols(real);
|
|
expect(brandSymbol).toBeDefined();
|
|
|
|
const symbolCopy = { appSlug: "ghostlight", [brandSymbol as symbol]: true };
|
|
// defineProperty, not assign: `real` is frozen, so shadowing its
|
|
// non-writable `appSlug` by assignment throws before the forgery exists.
|
|
const chained = Object.defineProperty(Object.create(real) as object, "appSlug", {
|
|
value: "ghostlight",
|
|
enumerable: true,
|
|
});
|
|
const proxied = new Proxy(real, { get: (target, key) => (key === "appSlug" ? "ghostlight" : Reflect.get(target, key)) });
|
|
const spread = { ...real, appSlug: "ghostlight" };
|
|
const cloned = structuredClone({ appSlug: real.appSlug });
|
|
for (const forgery of [symbolCopy, chained, proxied, spread, cloned]) {
|
|
expect(isAppCaller(forgery)).toBe(false);
|
|
}
|
|
// Frozen at mint, so the last forgery the WeakSet could not see is gone:
|
|
// a legitimately held caller cannot be re-slugged into another app and
|
|
// keep its brand. Modules are strict, so the write throws rather than
|
|
// silently no-opping.
|
|
expect(Object.isFrozen(real)).toBe(true);
|
|
expect(() => {
|
|
(real as { appSlug: string }).appSlug = "ghostlight";
|
|
}).toThrow(TypeError);
|
|
expect(real.appSlug).toBe("repixelizer");
|
|
expect(isAppCaller(real)).toBe(true);
|
|
});
|
|
});
|
|
|
|
async function twoCompletionsForOneHandle(store: HeimdallStore, appSlug: AppSlug = "ghostlight") {
|
|
const { accountId, sessionId } = await seedAccountAndSession(store, appSlug);
|
|
const results = await Promise.allSettled([
|
|
store.createAuthCompletion(completionInput(appSlug, accountId, sessionId, "handle-1")),
|
|
store.createAuthCompletion(completionInput(appSlug, accountId, sessionId, "handle-1")),
|
|
]);
|
|
return { results, accountId, sessionId };
|
|
}
|
|
|
|
function partialUniqueIndexContract(label: string, makeStore: () => Promise<HeimdallStore>) {
|
|
describe(`partial unique index contract: ${label}`, () => {
|
|
it("two concurrent completions for one handle: exactly one is minted", async () => {
|
|
const store = await makeStore();
|
|
const { results } = await twoCompletionsForOneHandle(store);
|
|
const fulfilled = results.filter((r) => r.status === "fulfilled");
|
|
const rejected = results.filter((r) => r.status === "rejected");
|
|
expect(fulfilled).toHaveLength(1);
|
|
expect(rejected).toHaveLength(1);
|
|
const live = await store.consumeAuthCompletionByAttempt("ghostlight", "handle-1", new Date().toISOString());
|
|
expect(live?.attemptId).toBe("handle-1");
|
|
expect(await store.consumeAuthCompletionByAttempt("ghostlight", "handle-1", new Date().toISOString())).toBeNull();
|
|
});
|
|
|
|
it("the predicate: NULL handles never collide, a consumed handle frees its slot, other apps do not collide", async () => {
|
|
const store = await makeStore();
|
|
const g = await seedAccountAndSession(store, "ghostlight");
|
|
const r = await seedAccountAndSession(store, "repixelizer");
|
|
await store.createAuthCompletion(completionInput("ghostlight", g.accountId, g.sessionId));
|
|
await store.createAuthCompletion(completionInput("ghostlight", g.accountId, g.sessionId));
|
|
const first = await store.createAuthCompletion(completionInput("ghostlight", g.accountId, g.sessionId, "shared"));
|
|
await expect(store.createAuthCompletion(completionInput("repixelizer", r.accountId, r.sessionId, "shared"))).resolves.toBeTruthy();
|
|
await expect(store.createAuthCompletion(completionInput("ghostlight", g.accountId, g.sessionId, "shared"))).rejects.toThrow();
|
|
expect(await store.consumeAuthCompletion("ghostlight", first.code, new Date().toISOString())).toBeTruthy();
|
|
const second = await store.createAuthCompletion(completionInput("ghostlight", g.accountId, g.sessionId, "shared"));
|
|
expect(second.code).not.toBe(first.code);
|
|
const byAttempt = await store.consumeAuthCompletionByAttempt("ghostlight", "shared", new Date().toISOString());
|
|
expect(byAttempt?.code).toBe(second.code);
|
|
});
|
|
|
|
it("an expired-but-unconsumed completion still occupies the slot (both stores agree; neither frees it)", async () => {
|
|
const store = await makeStore();
|
|
const g = await seedAccountAndSession(store, "ghostlight");
|
|
const stale = completionInput("ghostlight", g.accountId, g.sessionId, "stale");
|
|
stale.expiresAt = new Date(Date.now() - 1000).toISOString();
|
|
await store.createAuthCompletion(stale);
|
|
expect(await store.consumeAuthCompletionByAttempt("ghostlight", "stale", new Date().toISOString())).toBeNull();
|
|
await expect(store.createAuthCompletion(completionInput("ghostlight", g.accountId, g.sessionId, "stale"))).rejects.toThrow();
|
|
});
|
|
});
|
|
}
|
|
|
|
partialUniqueIndexContract("InMemoryStore", async () => new InMemoryStore());
|
|
partialUniqueIndexContract("PostgresStore over pg-mem", async () => {
|
|
const adapter = newDb().adapters.createPg();
|
|
const store = new PostgresStore(new adapter.Pool());
|
|
await store.ensureSchema();
|
|
return store;
|
|
});
|
|
|
|
describe("duplicate-handle error surfacing through the callback route", () => {
|
|
it("state tokens are replayable, and a replay while the handle's completion is unconsumed echoes the store error in a 502", async () => {
|
|
const { app, store, begin } = await planeHarness();
|
|
const receipt = await begin("begin-replay");
|
|
const handle = String(receipt.handle);
|
|
const state = new URL((receipt.navigation as { url: string }).url).searchParams.get("state") ?? "";
|
|
const first = await completeAs(app, state, "victim");
|
|
expect(first.statusCode).toBe(201);
|
|
const replay = await completeAs(app, state, "victim");
|
|
// Nothing consumed the state's jti, so the second exchange still runs and
|
|
// reaches createAuthCompletion, where the unique index refuses it. The
|
|
// route's catch (app.ts ~1290) now answers with a fixed detail rather than
|
|
// the store's own text, so a Postgres 23505 cannot hand the caller a
|
|
// constraint name. The full text stays in the audit event.
|
|
expect(replay.statusCode, replay.body).toBe(502);
|
|
expect(replay.json()).toMatchObject({
|
|
error: "oauth_callback_failed",
|
|
detail: "The provider callback could not be completed.",
|
|
});
|
|
expect(JSON.stringify(replay.json())).not.toContain("unconsumed completion");
|
|
// The failed replay must not have damaged the real completion.
|
|
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("completed");
|
|
expect(await store.consumeAuthCompletionByAttempt("ghostlight", handle, new Date().toISOString())).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("R21.3: sync getAppProfile at the seven former resolveAppProfile sites", () => {
|
|
it("getAppProfile resolves own keys only, so prototype members are not profiles", () => {
|
|
expect(getAppProfile("nope" as AppSlug)).toBeUndefined();
|
|
// Object.hasOwn guards the lookup; a bare `record[key]` index handed these
|
|
// back as objects that then serialised as an empty profile.
|
|
for (const key of ["constructor", "toString", "__proto__", "hasOwnProperty"]) {
|
|
expect(getAppProfile(key as AppSlug), key).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
// Was an open hole inherited from main: this is the one route whose params
|
|
// schema has no slug enum, and the bare index made `constructor` a 200 `{}`
|
|
// because serializeAppProfile(Object) serialises undefined fields. Closed by
|
|
// Object.hasOwn in getAppProfile.
|
|
it("GET /v1/apps/:appSlug is the one route without the slug enum; a prototype key must still be a 404", async () => {
|
|
const { app } = await harness();
|
|
// Null prototype so the "__proto__" key is a key, not a setter.
|
|
const statuses: Record<string, string> = Object.create(null) as Record<string, string>;
|
|
for (const key of ["constructor", "toString", "__proto__", "hasOwnProperty", "valueOf"]) {
|
|
const response = await app.inject({ method: "GET", url: `/v1/apps/${key}` });
|
|
statuses[key] = `${response.statusCode} ${response.body.slice(0, 60)}`;
|
|
}
|
|
process.stdout.write(`\n[soul] GET /v1/apps/<prototype key>: ${JSON.stringify(statuses)}\n`);
|
|
expect(statuses).toEqual(Object.fromEntries(Object.keys(statuses).map((key) => [key, expect.stringMatching(/^404 /)])));
|
|
});
|
|
|
|
it("every other appSlug parameter is enum-gated, so an unknown slug is a 400 there and a 404 only on the profile read", async () => {
|
|
const { app } = await harness();
|
|
expect((await app.inject({ method: "GET", url: "/v1/apps/nope" })).statusCode).toBe(404);
|
|
expect((await start(app, { appSlug: "nope", mode: "sign_in", returnTo: "https://x.example/" })).statusCode).toBe(400);
|
|
for (const url of [
|
|
"/v1/apps/nope/auth-completions/redeem",
|
|
"/v1/apps/nope/sessions/refresh",
|
|
"/v1/apps/nope/patron-support/sync",
|
|
"/v1/apps/nope/managed-credentials/resolve",
|
|
]) {
|
|
const response = await app.inject({ method: "POST", url, payload: { completionCode: "c", refreshToken: "r", accountId: "a", provider: "discord" } });
|
|
expect(response.statusCode, url).toBe(400);
|
|
}
|
|
});
|
|
|
|
it("the migration adds attempt_id before the partial index that references it, and drops registered_apps first", () => {
|
|
const addColumn = CREATE_SCHEMA_SQL.indexOf("ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT");
|
|
const dropOldIndex = CREATE_SCHEMA_SQL.indexOf("DROP INDEX IF EXISTS auth_completions_attempt_idx");
|
|
const createIndex = CREATE_SCHEMA_SQL.indexOf("CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx");
|
|
const dropTable = CREATE_SCHEMA_SQL.indexOf("DROP TABLE IF EXISTS registered_apps");
|
|
expect(dropTable).toBeGreaterThanOrEqual(0);
|
|
expect(addColumn).toBeGreaterThan(dropTable);
|
|
expect(dropOldIndex).toBeGreaterThan(addColumn);
|
|
expect(createIndex).toBeGreaterThan(dropOldIndex);
|
|
// Statement count, not token count (the comment above the DROP names it too).
|
|
expect(CREATE_SCHEMA_SQL.match(/DROP TABLE IF EXISTS registered_apps/g)).toHaveLength(1);
|
|
expect(CREATE_SCHEMA_SQL).not.toMatch(/CREATE TABLE[^;]*registered_apps/);
|
|
});
|
|
// Re-running ensureSchema on a store built before this branch (table
|
|
// present, column absent, old index name) cannot be simulated in pg-mem —
|
|
// it rejects CREATE TABLE IF NOT EXISTS on an existing table — so that
|
|
// path is proved only by the live block below.
|
|
});
|
|
|
|
const livePg = process.env.SOUL_PG_URL;
|
|
describe.skipIf(!livePg)("live postgres (SOUL_PG_URL)", () => {
|
|
it("real partial index: 23505 names auth_completions_attempt_unconsumed_unique_idx; schema applies twice on a pre-branch store", async () => {
|
|
const pool = new Pool({ connectionString: livePg });
|
|
resources.push({ close: () => pool.end() });
|
|
await pool.query(`
|
|
DROP SCHEMA public CASCADE; CREATE SCHEMA public;
|
|
CREATE TABLE registered_apps (slug TEXT PRIMARY KEY);
|
|
CREATE TABLE accounts (id TEXT PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL, last_seen_at TIMESTAMPTZ NOT NULL, display_name TEXT, primary_email TEXT);
|
|
CREATE TABLE sessions (id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, app_slug TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL, last_seen_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL, claims_json JSONB NOT NULL, access_revision INTEGER NOT NULL);
|
|
CREATE TABLE auth_completions (
|
|
code TEXT PRIMARY KEY, app_slug TEXT NOT NULL, provider TEXT NOT NULL, mode TEXT NOT NULL,
|
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
return_to TEXT NOT NULL, payload_json JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ
|
|
);
|
|
`);
|
|
const store = new PostgresStore(pool);
|
|
await store.ensureSchema();
|
|
await store.ensureSchema();
|
|
const tables = await pool.query<{ table_name: string }>("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY 1");
|
|
expect(tables.rows.map((r) => r.table_name)).not.toContain("registered_apps");
|
|
const indexes = await pool.query<{ indexdef: string }>("SELECT indexdef FROM pg_indexes WHERE tablename = 'auth_completions' ORDER BY 1");
|
|
const partial = indexes.rows.find((r) => r.indexdef.includes("auth_completions_attempt_unconsumed_unique_idx"));
|
|
expect(partial?.indexdef).toContain("WHERE ((attempt_id IS NOT NULL) AND (consumed_at IS NULL))");
|
|
|
|
const { results } = await twoCompletionsForOneHandle(store);
|
|
const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
|
|
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
|
const error = rejected?.reason as { code?: string; message?: string };
|
|
expect(error.code).toBe("23505");
|
|
expect(error.message).toContain("auth_completions_attempt_unconsumed_unique_idx");
|
|
// What the callback route's catch would echo as `detail`.
|
|
process.stdout.write(`\n[soul live pg] 23505 message: ${error.message}\n`);
|
|
}, 30_000);
|
|
});
|