Bind link mode to its initiator and redeem to its target #1
11 changed files with 406 additions and 15 deletions
|
|
@ -334,6 +334,11 @@ Request body:
|
|||
}
|
||||
```
|
||||
|
||||
Binding rules:
|
||||
|
||||
- A completion minted for a connection target (`connection.targetId` in the signed state) is redeemed only with `targetId` equal to it; absent or different answers `403 completion_target_mismatch` and leaves the code unconsumed.
|
||||
- A `link` completion is redeemed only by the authenticated app backend (`x-heimdall-app-secret`); otherwise `401 app_auth_required`, code unconsumed. `link` start itself needs that authentication and `initiatingAccountId`; the callback refuses with `403 link_initiator_mismatch` when the provider identity belongs to a different account, and attaches an unlinked identity to the initiator.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
|
|
|
|||
117
src/app.ts
117
src/app.ts
|
|
@ -91,6 +91,7 @@ function buildOAuthStateToken(options: {
|
|||
mode: OAuthMode;
|
||||
returnTo: string;
|
||||
connection: OAuthConnectionBinding | undefined;
|
||||
initiatorAccountId: string | undefined;
|
||||
handoff: OAuthHandoff;
|
||||
entitlementPolicy: OAuthEntitlementPolicy | undefined;
|
||||
keys: ReturnType<typeof createRuntimeKeyMaterial>;
|
||||
|
|
@ -111,6 +112,7 @@ function buildOAuthStateToken(options: {
|
|||
mode: options.mode,
|
||||
return_to: options.returnTo,
|
||||
connection: options.connection ?? null,
|
||||
initiator_account_id: options.initiatorAccountId ?? null,
|
||||
handoff: options.handoff,
|
||||
entitlement_policy: options.entitlementPolicy ?? null,
|
||||
};
|
||||
|
|
@ -168,7 +170,8 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
|
|||
typeof payload.nbf !== "number" ||
|
||||
typeof payload.exp !== "number" ||
|
||||
!handoffIsValid ||
|
||||
!entitlementPolicyIsValid
|
||||
!entitlementPolicyIsValid ||
|
||||
!(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -177,6 +180,9 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
|
|||
if (!normalizedPayload.handoff) {
|
||||
normalizedPayload.handoff = { kind: "browser_completion" };
|
||||
}
|
||||
if (!normalizedPayload.initiator_account_id) {
|
||||
normalizedPayload.initiator_account_id = null;
|
||||
}
|
||||
if (!normalizedPayload.entitlement_policy) {
|
||||
normalizedPayload.entitlement_policy = null;
|
||||
}
|
||||
|
|
@ -253,6 +259,16 @@ function relayedProviderText(value: string | undefined, bound: number): string |
|
|||
* code, each only when it is a short identifier. Never its message, which
|
||||
* carries whatever the thrower quoted (a host, a database, a provider's body).
|
||||
*/
|
||||
/** A link callback the signed state's initiator does not license. Its message is static and safe to relay. */
|
||||
class LinkRefusal extends Error {
|
||||
constructor(
|
||||
readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown",
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function errorIdentity(error: unknown): { errorClass: string; errorCode: string | null } {
|
||||
const code = (error as { code?: unknown } | null)?.code;
|
||||
return {
|
||||
|
|
@ -501,6 +517,16 @@ export async function startOAuthFlow(
|
|||
};
|
||||
}
|
||||
|
||||
// A connection target is app-backend authority: it rides only on connect
|
||||
// and link starts, which are the flows gated by x-heimdall-app-secret.
|
||||
// A sign_in start is open to any browser and must not carry one.
|
||||
if (input.mode === "sign_in" && input.connection !== undefined) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: { error: "connection_not_allowed_in_sign_in", detail: "sign_in start must not carry a connection target." },
|
||||
};
|
||||
}
|
||||
|
||||
if (callbackMint(profile, provider, input.mode, input.connection) === "refused") {
|
||||
return {
|
||||
statusCode: 400,
|
||||
|
|
@ -511,6 +537,24 @@ export async function startOAuthFlow(
|
|||
};
|
||||
}
|
||||
|
||||
// Link mode binds an identity to an existing account, so the account must
|
||||
// be named by someone Heimdall can believe: the authenticated app backend.
|
||||
// The name rides in the signed state and the callback holds the provider's
|
||||
// identity to it. No other mode carries an initiator.
|
||||
if (input.mode === "link") {
|
||||
if (!isAppCaller(caller) || caller.appSlug !== profile.slug) {
|
||||
return { statusCode: 401, body: { error: "app_auth_required" } };
|
||||
}
|
||||
if (!input.initiatingAccountId) {
|
||||
return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } };
|
||||
}
|
||||
if (!(await ctx.store.findAccountById(input.initiatingAccountId))) {
|
||||
return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } };
|
||||
}
|
||||
} else if (input.initiatingAccountId !== undefined) {
|
||||
return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } };
|
||||
}
|
||||
|
||||
if (!isAllowedReturnOrigin(profile, input.returnTo)) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
|
|
@ -574,6 +618,7 @@ export async function startOAuthFlow(
|
|||
mode: input.mode,
|
||||
returnTo: input.returnTo,
|
||||
connection: input.connection,
|
||||
initiatorAccountId: input.initiatingAccountId,
|
||||
handoff,
|
||||
entitlementPolicy,
|
||||
keys,
|
||||
|
|
@ -822,6 +867,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
summary: { type: "string" },
|
||||
},
|
||||
},
|
||||
initiatingAccountId: { type: "string", minLength: 1 },
|
||||
handoff: {
|
||||
type: "object",
|
||||
required: ["kind"],
|
||||
|
|
@ -1035,6 +1081,25 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
const nowIso = new Date().toISOString();
|
||||
let account = await store.findAccountByLinkedIdentity(identity.provider, identity.providerUserId);
|
||||
|
||||
// Link mode attaches the provider identity to the account the app
|
||||
// named at start, and to no other: an identity already held by
|
||||
// another account is a refusal, never a quiet sign-in as that account.
|
||||
const linkInitiatorId = statePayload.mode === "link" ? statePayload.initiator_account_id : null;
|
||||
if (statePayload.mode === "link") {
|
||||
if (!linkInitiatorId) {
|
||||
throw new LinkRefusal("link_initiator_missing", "The signed link state names no initiating account.");
|
||||
}
|
||||
if (account && account.id !== linkInitiatorId) {
|
||||
throw new LinkRefusal("link_initiator_mismatch", "This identity belongs to a different account than the one that started the link.");
|
||||
}
|
||||
if (!account) {
|
||||
account = await store.findAccountById(linkInitiatorId);
|
||||
if (!account) {
|
||||
throw new LinkRefusal("link_initiator_unknown", "The initiating account no longer exists.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
const createAccountInput: CreateAccountInput = {
|
||||
createdAt: nowIso,
|
||||
|
|
@ -1051,10 +1116,11 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
} else {
|
||||
const accountUpdates: { displayName?: string; primaryEmail?: string } = {};
|
||||
const displayName = identity.displayName ?? identity.username;
|
||||
if (displayName !== undefined) {
|
||||
// A link adds an identity; it does not rewrite the account's profile.
|
||||
if (displayName !== undefined && statePayload.mode !== "link") {
|
||||
accountUpdates.displayName = displayName;
|
||||
}
|
||||
if (identity.primaryEmail !== undefined) {
|
||||
if (identity.primaryEmail !== undefined && statePayload.mode !== "link") {
|
||||
accountUpdates.primaryEmail = identity.primaryEmail;
|
||||
}
|
||||
await store.touchAccount(account.id, nowIso, accountUpdates);
|
||||
|
|
@ -1246,6 +1312,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
accountId: account.id,
|
||||
sessionId: issued.session.sessionId,
|
||||
returnTo: statePayload.return_to,
|
||||
...(statePayload.connection ? { targetId: statePayload.connection.targetId } : {}),
|
||||
createdAt: nowIso,
|
||||
expiresAt: completionExpiresAt,
|
||||
payloadJson: completionPayload as unknown as Record<string, unknown>,
|
||||
|
|
@ -1307,12 +1374,16 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
// JSON.parse quotes the provider's response body. The audit event
|
||||
// keeps the error's class, its code, and a provider's HTTP status.
|
||||
const status = (error as { status?: unknown }).status;
|
||||
const failure = "The provider callback could not be completed.";
|
||||
// A link refusal is a typed decision with a static message; every
|
||||
// other failure keeps the opaque answer above.
|
||||
const refusal = error instanceof LinkRefusal ? error : null;
|
||||
const failureCode = refusal?.code ?? "oauth_callback_failed";
|
||||
const failure = refusal?.message ?? "The provider callback could not be completed.";
|
||||
const nowIso = new Date().toISOString();
|
||||
await denyBrowserAttempt("oauth_callback_failed");
|
||||
await denyBrowserAttempt(failureCode);
|
||||
await store.createAuditEvent({
|
||||
appSlug: statePayload.app_slug,
|
||||
eventType: "oauth_callback_failed",
|
||||
eventType: refusal ? "oauth_link_refused" : "oauth_callback_failed",
|
||||
eventPayloadJson: {
|
||||
provider: request.params.provider,
|
||||
mode: statePayload.mode,
|
||||
|
|
@ -1335,7 +1406,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
mode: statePayload.mode,
|
||||
returnTo: statePayload.return_to,
|
||||
connection: statePayload.connection,
|
||||
error: "oauth_callback_failed",
|
||||
error: failureCode,
|
||||
errorDescription: failure,
|
||||
});
|
||||
} catch {}
|
||||
|
|
@ -1352,15 +1423,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
mode: statePayload.mode,
|
||||
returnTo: statePayload.return_to,
|
||||
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
|
||||
error: "oauth_callback_failed",
|
||||
error: failureCode,
|
||||
errorDescription: failure,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
reply.code(502);
|
||||
reply.code(refusal ? 403 : 502);
|
||||
return {
|
||||
error: "oauth_callback_failed",
|
||||
error: failureCode,
|
||||
detail: failure,
|
||||
provider: request.params.provider,
|
||||
appSlug: statePayload.app_slug,
|
||||
|
|
@ -1388,12 +1459,38 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
additionalProperties: false,
|
||||
properties: {
|
||||
completionCode: { type: "string", minLength: 1 },
|
||||
targetId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const nowIso = new Date().toISOString();
|
||||
// Sign-in and connect completions are redeemed by the browser flow, so
|
||||
// the route stays open to it. A link completion was minted for an
|
||||
// initiator the authenticated app named at start; only that app's
|
||||
// backend redeems it. Redeem is also where the connection target is
|
||||
// held to what the signed state named. Both refusals read the row
|
||||
// without consuming it, so a wrong caller or target cannot burn the code.
|
||||
const pending = await store.findAuthCompletion(request.params.appSlug, request.body.completionCode);
|
||||
if (pending && !pending.consumedAt && pending.expiresAt > nowIso) {
|
||||
if (pending.mode === "link" && !resolveAppCaller(config, request.params.appSlug, request.headers)) {
|
||||
reply.code(401);
|
||||
return { error: "app_auth_required" };
|
||||
}
|
||||
if (pending.targetId !== undefined && pending.targetId !== request.body.targetId) {
|
||||
await store.createAuditEvent({
|
||||
accountId: pending.accountId,
|
||||
sessionId: pending.sessionId,
|
||||
appSlug: pending.appSlug,
|
||||
eventType: "auth_completion_target_refused",
|
||||
eventPayloadJson: { provider: pending.provider, mode: pending.mode },
|
||||
createdAt: nowIso,
|
||||
});
|
||||
reply.code(403);
|
||||
return { error: "completion_target_mismatch" };
|
||||
}
|
||||
}
|
||||
const completion = await store.consumeAuthCompletion(request.params.appSlug, request.body.completionCode, nowIso);
|
||||
|
||||
if (!completion) {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ export interface OAuthStartRequest {
|
|||
mode: OAuthMode;
|
||||
returnTo: string;
|
||||
connection?: OAuthConnectionBinding;
|
||||
/** Link mode only: the account the authenticated app says is linking. Carried in the signed state. */
|
||||
initiatingAccountId?: string;
|
||||
handoff?: OAuthHandoff;
|
||||
requestedScopes?: string[];
|
||||
entitlementPolicy?: OAuthEntitlementPolicy;
|
||||
|
|
@ -92,6 +94,7 @@ export interface OAuthStatePayload {
|
|||
mode: OAuthMode;
|
||||
return_to: string;
|
||||
connection: OAuthConnectionBinding | null;
|
||||
initiator_account_id: string | null;
|
||||
handoff: OAuthHandoff | null;
|
||||
entitlement_policy: OAuthEntitlementPolicy | null;
|
||||
}
|
||||
|
|
@ -105,6 +108,8 @@ export interface LinkedIdentityInput {
|
|||
|
||||
export interface RedeemAuthCompletionRequest {
|
||||
completionCode: string;
|
||||
/** Required when the completion was minted for a connection target; must equal it. */
|
||||
targetId?: string;
|
||||
}
|
||||
|
||||
export type HeimdallAuthAttemptStatus = "pending" | "completed" | "denied" | "consumed" | "expired";
|
||||
|
|
|
|||
|
|
@ -390,6 +390,7 @@ export class InMemoryStore implements HeimdallStore {
|
|||
accountId: input.accountId,
|
||||
sessionId: input.sessionId,
|
||||
returnTo: input.returnTo,
|
||||
...(input.targetId ? { targetId: input.targetId } : {}),
|
||||
createdAt: input.createdAt,
|
||||
expiresAt: input.expiresAt,
|
||||
payloadJson: clone(input.payloadJson),
|
||||
|
|
@ -402,6 +403,11 @@ export class InMemoryStore implements HeimdallStore {
|
|||
return clone(completion);
|
||||
}
|
||||
|
||||
async findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null> {
|
||||
const completion = this.authCompletions.get(code);
|
||||
return completion && completion.appSlug === appSlug ? clone(completion) : null;
|
||||
}
|
||||
|
||||
async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null> {
|
||||
const completion = this.authCompletions.get(code);
|
||||
if (!completion) {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ interface AuthCompletionRow extends QueryResultRow {
|
|||
account_id: string;
|
||||
session_id: string;
|
||||
return_to: string;
|
||||
target_id: string | null;
|
||||
payload_json: Record<string, unknown>;
|
||||
created_at: string | Date;
|
||||
expires_at: string | Date;
|
||||
|
|
@ -249,6 +250,10 @@ function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion {
|
|||
completion.attemptId = row.attempt_id;
|
||||
}
|
||||
|
||||
if (row.target_id) {
|
||||
completion.targetId = row.target_id;
|
||||
}
|
||||
|
||||
if (row.consumed_at) {
|
||||
completion.consumedAt = normalizeTimestamp(row.consumed_at);
|
||||
}
|
||||
|
|
@ -1004,9 +1009,9 @@ export class PostgresStore implements HeimdallStore {
|
|||
`
|
||||
INSERT INTO auth_completions (
|
||||
code, attempt_id, app_slug, provider, mode, account_id, session_id,
|
||||
return_to, payload_json, created_at, expires_at
|
||||
return_to, target_id, payload_json, created_at, expires_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
|
|
@ -1018,6 +1023,7 @@ export class PostgresStore implements HeimdallStore {
|
|||
input.accountId,
|
||||
input.sessionId,
|
||||
input.returnTo,
|
||||
input.targetId ?? null,
|
||||
JSON.stringify(input.payloadJson),
|
||||
input.createdAt,
|
||||
input.expiresAt,
|
||||
|
|
@ -1027,6 +1033,14 @@ export class PostgresStore implements HeimdallStore {
|
|||
return mapAuthCompletionRow(expectRow(result.rows[0], "createAuthCompletion"));
|
||||
}
|
||||
|
||||
async findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null> {
|
||||
const result = await this.pool.query<AuthCompletionRow>(
|
||||
"SELECT * FROM auth_completions WHERE code = $1 AND app_slug = $2",
|
||||
[code, appSlug]
|
||||
);
|
||||
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "findAuthCompletion")) : null;
|
||||
}
|
||||
|
||||
async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null> {
|
||||
const result = await this.pool.query<AuthCompletionRow>(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -106,6 +106,10 @@ CREATE INDEX IF NOT EXISTS auth_completions_lookup_idx
|
|||
-- redeem the completion it created without ever needing the code.
|
||||
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT;
|
||||
|
||||
-- The connection target the signed state named (connection.targetId). Redeem
|
||||
-- compares it against the target the redeeming app states.
|
||||
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS target_id TEXT;
|
||||
|
||||
-- One attempt handle may bind at most one unconsumed completion (R21.1): a
|
||||
-- second completion for the same handle would let the by-attempt UPDATE in
|
||||
-- consumeAuthCompletionByAttempt match more than one row, silently consuming
|
||||
|
|
|
|||
|
|
@ -159,6 +159,8 @@ export interface StoredAuthCompletion {
|
|||
accountId: string;
|
||||
sessionId: string;
|
||||
returnTo: string;
|
||||
/** The connection target the signed state named, if any; compared at redeem. */
|
||||
targetId?: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
consumedAt?: string;
|
||||
|
|
@ -171,6 +173,7 @@ export interface CreateAuthCompletionInput {
|
|||
* always mints `code` itself; a caller cannot choose it.
|
||||
*/
|
||||
attemptId?: string;
|
||||
targetId?: string;
|
||||
appSlug: AppSlug;
|
||||
provider: Provider;
|
||||
mode: OAuthMode;
|
||||
|
|
@ -263,6 +266,8 @@ export interface HeimdallStore {
|
|||
createPrivateCommandReceipt(input: CreatePrivateCommandReceiptInput): Promise<StoredPrivateCommandReceipt>;
|
||||
findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise<StoredPrivateCommandReceipt | null>;
|
||||
createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion>;
|
||||
/** Read a completion without consuming it, so redeem can refuse a mismatch without burning the code. */
|
||||
findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null>;
|
||||
consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null>;
|
||||
/**
|
||||
* Consume a completion by its correlated attempt handle instead of its
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ describe("R21.2: the WeakSet brand at every consumer", () => {
|
|||
if (line.includes("caller.appSlug")) expect(line, `${file}: ${line.trim()}`).toContain("isAppCaller(caller)");
|
||||
}
|
||||
}
|
||||
expect(consumers).toEqual(["app.ts: reads=2 guards=2"]);
|
||||
expect(consumers).toEqual(["app.ts: reads=3 guards=3"]);
|
||||
});
|
||||
|
||||
it("a caller minted for app A is refused where app B is expected, over HTTP and by direct call", async () => {
|
||||
|
|
|
|||
255
tests/link-binding.test.ts
Normal file
255
tests/link-binding.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { type HeimdallConfig } from "../src/config.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
|
||||
function createTestConfig(): HeimdallConfig {
|
||||
return {
|
||||
serviceName: "heimdall",
|
||||
host: "127.0.0.1",
|
||||
port: 4100,
|
||||
workspaceRoot: "E:/Projects/Heimdall",
|
||||
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
|
||||
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
|
||||
publicBaseUrl: "https://heimdall.gamecult.org",
|
||||
issuer: "https://heimdall.gamecult.org",
|
||||
daemonId: "yggdrasil-heimdall",
|
||||
idunnRudpHealth: undefined,
|
||||
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
|
||||
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
|
||||
sessionTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 60 * 60 * 24 * 30,
|
||||
stateTtlSeconds: 600,
|
||||
completionTtlSeconds: 300,
|
||||
bootstrapSigningPrivateKeyOnMissing: false,
|
||||
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||
appSharedSecrets: {
|
||||
streampixels: "streampixels-secret",
|
||||
repixelizer: "repixelizer-secret",
|
||||
bifrost: "bifrost-secret",
|
||||
},
|
||||
appBackendCallbacks: {
|
||||
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
|
||||
repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"],
|
||||
},
|
||||
storage: {
|
||||
backend: "memory",
|
||||
applySchemaOnStartup: true,
|
||||
},
|
||||
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" },
|
||||
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
||||
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Link mode binds a provider identity to the account an authenticated app
|
||||
// named at start. These tests pin the three places that binding is decided:
|
||||
// the callback (initiator), the completion row (target), and redeem (caller,
|
||||
// target, replay).
|
||||
|
||||
const returnTo = "https://repixelizer.gamecult.org/app/";
|
||||
const appAuth = { "x-heimdall-app-secret": "repixelizer-secret" };
|
||||
|
||||
function discordRuntime(providerUserId: string): OAuthProviderRuntime {
|
||||
return {
|
||||
async exchangeAuthorizationCode() {
|
||||
return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["identify"], raw: {} };
|
||||
},
|
||||
async resolveIdentity() {
|
||||
return { provider: "discord", providerUserId, username: providerUserId, profile: { id: providerUserId } };
|
||||
},
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
async function rig(providerUserId: string, config: HeimdallConfig = createTestConfig()) {
|
||||
const store = new InMemoryStore();
|
||||
const app = await buildApp({ config, store, oauthRuntimes: { discord: discordRuntime(providerUserId) } });
|
||||
apps.push(app);
|
||||
const now = new Date().toISOString();
|
||||
const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now });
|
||||
return { app, store, initiator };
|
||||
}
|
||||
|
||||
type Rig = Awaited<ReturnType<typeof rig>>;
|
||||
|
||||
function start(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = appAuth) {
|
||||
return r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers, payload: { appSlug: "repixelizer", returnTo, ...payload } });
|
||||
}
|
||||
|
||||
function callback(r: Rig, stateToken: string) {
|
||||
return r.app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(stateToken)}` });
|
||||
}
|
||||
|
||||
function redeem(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = {}) {
|
||||
return r.app.inject({ method: "POST", url: "/v1/apps/repixelizer/auth-completions/redeem", headers, payload });
|
||||
}
|
||||
|
||||
describe("link start names its initiator", () => {
|
||||
it("refuses an unauthenticated link start, a missing or unknown initiator, and an initiator outside link mode", async () => {
|
||||
const r = await rig("d-1");
|
||||
const mode = { mode: "link", initiatingAccountId: r.initiator.id };
|
||||
expect((await start(r, mode, {})).statusCode).toBe(401);
|
||||
expect((await start(r, mode, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401);
|
||||
const missing = await start(r, { mode: "link" });
|
||||
expect([missing.statusCode, missing.json().error]).toEqual([400, "link_initiator_required"]);
|
||||
const unknown = await start(r, { mode: "link", initiatingAccountId: "no-such-account" });
|
||||
expect([unknown.statusCode, unknown.json().error]).toEqual([400, "link_initiator_unknown"]);
|
||||
const stray = await start(r, { mode: "sign_in", initiatingAccountId: r.initiator.id }, {});
|
||||
expect([stray.statusCode, stray.json().error]).toEqual([400, "initiator_only_in_link_mode"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sign_in start carries no connection target", () => {
|
||||
it("refuses connection.targetId on sign_in with connection_not_allowed_in_sign_in, with or without app auth", async () => {
|
||||
const r = await rig("d-si");
|
||||
const connection = { kind: "creator", targetId: "victim-target" };
|
||||
for (const headers of [{}, appAuth]) {
|
||||
const res = await start(r, { mode: "sign_in", connection }, headers);
|
||||
expect([res.statusCode, res.json().error]).toEqual([400, "connection_not_allowed_in_sign_in"]);
|
||||
}
|
||||
expect((await start(r, { mode: "sign_in" }, {})).statusCode).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe("link callback holds the provider identity to the initiator", () => {
|
||||
it("attaches an unlinked identity to the initiating account, not a new one", async () => {
|
||||
const r = await rig("d-new");
|
||||
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
|
||||
expect(started.statusCode).toBe(201);
|
||||
const done = await callback(r, started.json().stateToken);
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(done.json().account.id).toBe(r.initiator.id);
|
||||
expect((await r.store.findAccountByLinkedIdentity("discord", "d-new"))?.id).toBe(r.initiator.id);
|
||||
});
|
||||
|
||||
it("refuses with link_initiator_mismatch when the identity already belongs to another account, and mints nothing", async () => {
|
||||
const r = await rig("d-held");
|
||||
const now = new Date().toISOString();
|
||||
const other = await r.store.createAccount({ createdAt: now, lastSeenAt: now });
|
||||
await r.store.upsertLinkedIdentity({ accountId: other.id, provider: "discord", providerUserId: "d-held", scopes: [], profileJson: {}, createdAt: now, updatedAt: now });
|
||||
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
|
||||
const done = await callback(r, started.json().stateToken);
|
||||
expect(done.statusCode).toBe(403);
|
||||
expect(done.json().error).toBe("link_initiator_mismatch");
|
||||
expect(done.json().completion).toBeUndefined();
|
||||
expect(done.body).not.toContain("accessToken");
|
||||
expect((await r.store.findAccountByLinkedIdentity("discord", "d-held"))?.id).toBe(other.id);
|
||||
});
|
||||
|
||||
it("refuses a link state that carries no initiator (forged or stripped), before attaching anything", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "heimdall-link-"));
|
||||
try {
|
||||
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
|
||||
const r = await rig("d-forged", config);
|
||||
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
|
||||
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
|
||||
const verified = verifyJwt(started.json().stateToken, keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
|
||||
expect(verified.valid).toBe(true);
|
||||
const stripped = signJwt({ ...verified.payload, initiator_account_id: null }, keys);
|
||||
const done = await callback(r, stripped);
|
||||
expect([done.statusCode, done.json().error]).toEqual([403, "link_initiator_missing"]);
|
||||
expect(await r.store.findAccountByLinkedIdentity("discord", "d-forged")).toBeNull();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("redeem binds caller, target and replay", () => {
|
||||
async function linked(r: Rig, targetId: string | undefined) {
|
||||
const started = await start(r, {
|
||||
mode: "link",
|
||||
initiatingAccountId: r.initiator.id,
|
||||
...(targetId ? { connection: { kind: "account", targetId } } : {}),
|
||||
});
|
||||
const done = await callback(r, started.json().stateToken);
|
||||
expect(done.statusCode).toBe(201);
|
||||
return done.json().completion.code as string;
|
||||
}
|
||||
|
||||
it("redeems a link completion only for the authenticated app, and a refused caller does not burn the code", async () => {
|
||||
const r = await rig("d-r1");
|
||||
const completionCode = await linked(r, undefined);
|
||||
const anonymous = await redeem(r, { completionCode });
|
||||
expect([anonymous.statusCode, anonymous.json().error]).toEqual([401, "app_auth_required"]);
|
||||
expect((await redeem(r, { completionCode }, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401);
|
||||
expect((await redeem(r, { completionCode }, appAuth)).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("refuses a wrong or missing target with completion_target_mismatch without consuming the code", async () => {
|
||||
const r = await rig("d-r2");
|
||||
const completionCode = await linked(r, "target-a");
|
||||
for (const payload of [{ completionCode, targetId: "target-b" }, { completionCode }]) {
|
||||
const refused = await redeem(r, payload, appAuth);
|
||||
expect([refused.statusCode, refused.json().error]).toEqual([403, "completion_target_mismatch"]);
|
||||
expect(refused.json().accessToken).toBeUndefined();
|
||||
}
|
||||
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("refuses a replayed completion", async () => {
|
||||
const r = await rig("d-r3");
|
||||
const completionCode = await linked(r, "target-a");
|
||||
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
|
||||
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(410);
|
||||
});
|
||||
|
||||
it("refuses a completion at and after its expiry with 410, whatever the caller and target, and honours it just before", async () => {
|
||||
// completionTtlSeconds is 300. Only Date is faked: the app and store run on real timers.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const t0 = Date.parse("2026-11-03T00:00:00.000Z");
|
||||
const ttlMs = createTestConfig().completionTtlSeconds * 1000;
|
||||
const expired = { statusCode: 410, error: "invalid_or_expired_completion_code" };
|
||||
const outcome = async (r: Rig, payload: Record<string, unknown>) => {
|
||||
const res = await redeem(r, payload, appAuth);
|
||||
return { statusCode: res.statusCode, error: res.json().error };
|
||||
};
|
||||
|
||||
vi.setSystemTime(t0);
|
||||
const r = await rig("d-exp");
|
||||
const code = await linked(r, "target-a");
|
||||
const row = await r.store.findAuthCompletion("repixelizer", code);
|
||||
expect(row?.expiresAt).toBe(new Date(t0 + ttlMs).toISOString());
|
||||
|
||||
// Just before expiry the right caller and target redeem it.
|
||||
const early = await rig("d-exp-early");
|
||||
const earlyCode = await linked(early, "target-a");
|
||||
vi.setSystemTime(t0 + ttlMs - 1);
|
||||
expect((await redeem(early, { completionCode: earlyCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
|
||||
|
||||
// At the expiry instant and after it: 410, for the right target and for a wrong one (not 403), unconsumed.
|
||||
for (const at of [t0 + ttlMs, t0 + ttlMs + 1]) {
|
||||
vi.setSystemTime(at);
|
||||
expect(await outcome(r, { completionCode: code, targetId: "target-a" })).toEqual(expired);
|
||||
expect(await outcome(r, { completionCode: code, targetId: "target-b" })).toEqual(expired);
|
||||
}
|
||||
expect((await r.store.findAuthCompletion("repixelizer", code))?.consumedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores the target on the completion row", async () => {
|
||||
const r = await rig("d-r4");
|
||||
const completionCode = await linked(r, "target-a");
|
||||
expect((await r.store.findAuthCompletion("repixelizer", completionCode))?.targetId).toBe("target-a");
|
||||
});
|
||||
});
|
||||
|
|
@ -589,7 +589,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
const redeem = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps/streampixels/auth-completions/redeem",
|
||||
payload: { completionCode: callback.json().completion.code as string },
|
||||
payload: { completionCode: callback.json().completion.code as string, ...(mode === "connect" ? { targetId: "creator-1" } : {}) },
|
||||
});
|
||||
expect(redeem.statusCode).toBe(201);
|
||||
return { callback, redeem };
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ describe("PostgresStore.checkSchema", () => {
|
|||
"entitlement_snapshots",
|
||||
"audit_events",
|
||||
]);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(85);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(86);
|
||||
expect(REQUIRED_COLUMNS).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
|
||||
|
|
|
|||
Loading…
Reference in a new issue