From c81f9238907fa34f7f53d83c93c9facdf89dd82f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:11:34 +0200 Subject: [PATCH] Refresh managed credentials under a row lock, with typed revocation refreshLinkedIdentityCredentialIfNeeded is deleted. refreshManagedCredential owns the policy; HeimdallStore.updateLinkedIdentityCredential owns the row (SELECT ... FOR UPDATE in Postgres, a per-id chain in memory), so a refresh token is presented once across concurrent requests and processes. invalid_grant clears the tokens (410 managed_credential_revoked); any other failure keeps them (502 managed_credential_refresh_failed). The "?? refreshToken" keep-old fallback is gone; keeping it is an explicit branch for non-rotating providers. Co-Authored-By: Claude Opus 5.5 --- src/app.ts | 118 ++---- src/managed-credential.ts | 140 ++++++++ src/oauth.ts | 29 +- src/store/in-memory.ts | 42 +++ src/store/postgres.ts | 57 ++- src/store/types.ts | 28 ++ tests/app.test.ts | 113 +++++- tests/managed-credential.test.ts | 517 +++++++++++++++++++++++++++ tests/provider-token-request.test.ts | 31 ++ tests/store.test.ts | 3 +- 10 files changed, 979 insertions(+), 99 deletions(-) create mode 100644 src/managed-credential.ts create mode 100644 tests/managed-credential.test.ts diff --git a/src/app.ts b/src/app.ts index 9621856..5345c40 100644 --- a/src/app.ts +++ b/src/app.ts @@ -34,6 +34,7 @@ import { summarizePatreonMemberships, type OAuthRuntimeRegistry, } from "./oauth.js"; +import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js"; import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js"; import { createStore, type HeimdallStore } from "./store/index.js"; @@ -44,8 +45,6 @@ import { type UpsertLinkedIdentityInput, } from "./store/types.js"; -const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000; - export interface BuildAppOptions { config?: HeimdallConfig; store?: HeimdallStore; @@ -367,63 +366,6 @@ function centsToDecimalString(cents: number): string { return (cents / 100).toFixed(2); } -async function refreshLinkedIdentityCredentialIfNeeded(options: { - config: HeimdallConfig; - store: HeimdallStore; - tokenCustody: TokenCustody; - oauthRuntimes: OAuthRuntimeRegistry; - linkedIdentity: StoredLinkedIdentity; - now: string; -}): Promise { - const expiresAt = options.linkedIdentity.tokenExpiresAt ? Date.parse(options.linkedIdentity.tokenExpiresAt) : undefined; - if ( - !expiresAt || - expiresAt - Date.now() > MANAGED_CREDENTIAL_REFRESH_SKEW_MS || - !options.linkedIdentity.refreshTokenEncrypted - ) { - return options.linkedIdentity; - } - - const runtime = options.oauthRuntimes[options.linkedIdentity.provider]; - if (!runtime.refreshAccessToken) { - return options.linkedIdentity; - } - - const refreshToken = options.tokenCustody.decrypt(options.linkedIdentity.refreshTokenEncrypted); - const refreshed = await runtime.refreshAccessToken({ - config: options.config, - refreshToken, - }); - - const upsertInput: UpsertLinkedIdentityInput = { - id: options.linkedIdentity.id, - accountId: options.linkedIdentity.accountId, - provider: options.linkedIdentity.provider, - providerUserId: options.linkedIdentity.providerUserId, - accessTokenEncrypted: options.tokenCustody.encrypt(refreshed.accessToken), - refreshTokenEncrypted: options.tokenCustody.encrypt(refreshed.refreshToken ?? refreshToken), - scopes: refreshed.scope.length ? refreshed.scope : options.linkedIdentity.scopes, - profileJson: options.linkedIdentity.profileJson, - createdAt: options.linkedIdentity.createdAt, - updatedAt: options.now, - }; - const tokenExpiresAt = refreshed.expiresAt ?? options.linkedIdentity.tokenExpiresAt; - if (tokenExpiresAt !== undefined) { - upsertInput.tokenExpiresAt = tokenExpiresAt; - } - if (options.linkedIdentity.username !== undefined) { - upsertInput.username = options.linkedIdentity.username; - } - if (options.linkedIdentity.displayName !== undefined) { - upsertInput.displayName = options.linkedIdentity.displayName; - } - if (options.linkedIdentity.primaryEmail !== undefined) { - upsertInput.primaryEmail = options.linkedIdentity.primaryEmail; - } - - return options.store.upsertLinkedIdentity(upsertInput); -} - function storedIdentityToResolvedIdentity(identity: StoredLinkedIdentity) { const resolved = { provider: identity.provider, @@ -450,7 +392,6 @@ async function evaluateStoredEntitlements(options: { appSlug: AppSlug; accountId: string; entitlementPolicies: OAuthEntitlementPolicy[]; - now: string; }): Promise { const profile = getAppProfile(options.appSlug); if (!profile) { @@ -460,19 +401,13 @@ async function evaluateStoredEntitlements(options: { const facts: string[] = []; for (const storedIdentity of linkedIdentities) { - if (!profile.entitlementSources.includes(storedIdentity.provider) || !storedIdentity.accessTokenEncrypted) { + if (!profile.entitlementSources.includes(storedIdentity.provider)) { continue; } - const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({ - config: options.config, - store: options.store, - tokenCustody: options.tokenCustody, - oauthRuntimes: options.oauthRuntimes, - linkedIdentity: storedIdentity, - now: options.now, - }); - if (!linkedIdentity.accessTokenEncrypted) { + const refreshed = await refreshManagedCredential(options, storedIdentity); + const linkedIdentity = refreshed.identity; + if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) { continue; } @@ -714,7 +649,6 @@ export async function refreshAppSession( appSlug, accountId: refreshClaim.account_id, entitlementPolicies, - now: nowIso, }); const issueInput: IssueAccessClaimInput = { appSlug, @@ -1620,21 +1554,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise number; + timeoutMs?: number; +} + +export type ManagedCredentialRevocationReason = "provider_revoked" | "rotation_missing"; + +/** `revoked` means the identity holds no access token: the provider took the grant back, or it was never held. */ +export type ManagedCredentialOutcome = + | { kind: "current"; identity: StoredLinkedIdentity } + | { kind: "revoked"; identity: StoredLinkedIdentity }; + +/** + * The provider could not be asked, or answered with something that is not a + * grant verdict (invalid_client, 5xx, timeout, malformed body). The stored + * tokens are untouched. Carries the provider and nothing the provider said. + */ +export class ManagedCredentialRefreshFailed extends Error { + readonly code = "managed_credential_refresh_failed"; + readonly provider: Provider; + + constructor(provider: Provider) { + super(`${providerCatalog[provider].displayName} managed credential refresh failed.`); + this.name = "ManagedCredentialRefreshFailed"; + this.provider = provider; + } +} + +function dueForRefresh(identity: StoredLinkedIdentity, runtime: OAuthProviderRuntime, now: number): boolean { + if (!identity.tokenExpiresAt || !identity.refreshTokenEncrypted || !runtime.refreshAccessToken) { + return false; + } + return Date.parse(identity.tokenExpiresAt) - now <= MANAGED_CREDENTIAL_REFRESH_SKEW_MS; +} + +function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity { + const { accessTokenEncrypted: _a, refreshTokenEncrypted: _r, tokenExpiresAt: _e, ...rest } = identity; + return rest; +} + +/** + * The one refresh policy. `snapshot` is a hint: it may skip the lock when no + * refresh is due and never decides one. The row's lock owns when a refresh + * token is presented; the decision is made on the row as read under it. + */ +export async function refreshManagedCredential( + ctx: ManagedCredentialContext, + snapshot: StoredLinkedIdentity +): Promise { + const clock = ctx.clock ?? Date.now; + const provider = snapshot.provider; + const runtime = ctx.oauthRuntimes[provider]; + + if (!snapshot.accessTokenEncrypted) { + return { kind: "revoked", identity: snapshot }; + } + if (!dueForRefresh(snapshot, runtime, clock())) { + return { kind: "current", identity: snapshot }; + } + + let revocation: ManagedCredentialRevocationReason | undefined; + const decide = async (locked: StoredLinkedIdentity): Promise => { + if (!locked.accessTokenEncrypted || !dueForRefresh(locked, runtime, clock())) { + return { kind: "keep" }; + } + const updatedAt = new Date(clock()).toISOString(); + const refreshToken = ctx.tokenCustody.decrypt(locked.refreshTokenEncrypted!); + + let refreshed; + try { + refreshed = await runtime.refreshAccessToken!({ + config: ctx.config, + refreshToken, + signal: AbortSignal.timeout(ctx.timeoutMs ?? MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS), + }); + } catch (error) { + if (error instanceof ProviderResponseError && error.oauthError === "invalid_grant") { + revocation = "provider_revoked"; + return { kind: "revoke", updatedAt }; + } + throw new ManagedCredentialRefreshFailed(provider); + } + + let refreshTokenEncrypted: string; + if (refreshed.refreshToken) { + refreshTokenEncrypted = ctx.tokenCustody.encrypt(refreshed.refreshToken); + } else if (providerCatalog[provider].rotatesRefreshTokens) { + revocation = "rotation_missing"; + return { kind: "revoke", updatedAt }; + } else { + refreshTokenEncrypted = locked.refreshTokenEncrypted!; + } + + const tokenExpiresAt = refreshed.expiresAt ?? locked.tokenExpiresAt; + return { + kind: "rotate", + accessTokenEncrypted: ctx.tokenCustody.encrypt(refreshed.accessToken), + refreshTokenEncrypted, + ...(tokenExpiresAt !== undefined ? { tokenExpiresAt } : {}), + scopes: refreshed.scope.length ? refreshed.scope : locked.scopes, + updatedAt, + }; + }; + + const committed = await ctx.store.updateLinkedIdentityCredential(snapshot.id, decide); + if (revocation) { + await ctx.store.createAuditEvent({ + accountId: snapshot.accountId, + eventType: "managed_credential_revoked", + eventPayloadJson: { provider, reason: revocation }, + createdAt: new Date(clock()).toISOString(), + }); + } + if (!committed) { + return { kind: "revoked", identity: withoutTokens(snapshot) }; + } + return { kind: committed.accessTokenEncrypted ? "current" : "revoked", identity: committed }; +} diff --git a/src/oauth.ts b/src/oauth.ts index 2d0020c..187df6c 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -61,6 +61,25 @@ export interface OAuthProviderRuntime { }): Promise; } +const oauthErrorCodes = [ + "invalid_request", + "invalid_client", + "invalid_grant", + "unauthorized_client", + "unsupported_grant_type", + "invalid_scope", +] as const; +export type OAuthErrorCode = (typeof oauthErrorCodes)[number]; + +function readOAuthErrorCode(text: string): OAuthErrorCode | undefined { + try { + const error = (JSON.parse(text) as { error?: unknown } | null)?.error; + return oauthErrorCodes.find((code) => code === error); + } catch { + return undefined; + } +} + /** * A provider answer Heimdall cannot use: a non-2xx status, or a body that is * not JSON. It carries the status and a code, never the body or the parser's @@ -71,12 +90,18 @@ export interface OAuthProviderRuntime { export class ProviderResponseError extends Error { readonly status: number; readonly code: "provider_status" | "provider_body_not_json"; + /** + * The RFC 6749 section 5.2 error code of a refusal, when the provider sent + * one from that closed set. The only value taken from a provider's body. + */ + readonly oauthError: OAuthErrorCode | undefined; - constructor(status: number, code: ProviderResponseError["code"], failure: string) { + constructor(status: number, code: ProviderResponseError["code"], failure: string, oauthError?: OAuthErrorCode) { super(`${failure} (status ${status}, ${code}).`); this.name = "ProviderResponseError"; this.status = status; this.code = code; + this.oauthError = oauthError; } } @@ -84,7 +109,7 @@ export class ProviderResponseError extends Error { async function readProviderJson(response: Response, failure: string): Promise { const text = await response.text(); if (!response.ok) { - throw new ProviderResponseError(response.status, "provider_status", failure); + throw new ProviderResponseError(response.status, "provider_status", failure, readOAuthErrorCode(text)); } if (!text) { return {} as T; diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 5583ac4..0dea560 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -6,6 +6,7 @@ import { type CreateAuthCompletionInput, type CreateAuditEventInput, type CreateCapabilityGrantInput, + type CredentialDecision, type CreateEntitlementSnapshotInput, type CreateSessionInput, type CreatePrivateCommandReceiptInput, @@ -37,6 +38,8 @@ function isGrantActive(grant: StoredCapabilityGrant, appSlug: AppSlug, at: strin } export class InMemoryStore implements HeimdallStore { + /** Tail of each linked identity's credential-decision chain; an entry is removed when it drains. */ + private readonly credentialChains = new Map>(); private readonly accounts = new Map(); private readonly linkedIdentities = new Map(); private readonly grants = new Map(); @@ -174,6 +177,45 @@ export class InMemoryStore implements HeimdallStore { return clone(linkedIdentity); } + async updateLinkedIdentityCredential( + id: string, + decide: (locked: StoredLinkedIdentity) => Promise, + ): Promise { + const previous = this.credentialChains.get(id) ?? Promise.resolve(); + const run = previous.then(async () => { + const current = this.linkedIdentities.get(id); + if (!current) { + return null; + } + const decision = await decide(clone(current)); + if (decision.kind === "keep") { + return clone(current); + } + const next: StoredLinkedIdentity = { ...current, updatedAt: decision.updatedAt }; + delete next.accessTokenEncrypted; + delete next.refreshTokenEncrypted; + delete next.tokenExpiresAt; + if (decision.kind === "rotate") { + next.accessTokenEncrypted = decision.accessTokenEncrypted; + next.refreshTokenEncrypted = decision.refreshTokenEncrypted; + if (decision.tokenExpiresAt !== undefined) { + next.tokenExpiresAt = decision.tokenExpiresAt; + } + next.scopes = [...decision.scopes]; + } + this.linkedIdentities.set(id, clone(next)); + return clone(next); + }); + const tail = run.catch(() => undefined); + this.credentialChains.set(id, tail); + void tail.then(() => { + if (this.credentialChains.get(id) === tail) { + this.credentialChains.delete(id); + } + }); + return run; + } + async listLinkedIdentitiesForAccount(accountId: string): Promise { return [...this.linkedIdentities.values()] .filter((identity) => identity.accountId === accountId) diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 64f013d..f138f06 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -8,6 +8,7 @@ import { type CreateAuthCompletionInput, type CreateAuditEventInput, type CreateCapabilityGrantInput, + type CredentialDecision, type CreateEntitlementSnapshotInput, type CreateSessionInput, type CreatePrivateCommandReceiptInput, @@ -546,7 +547,7 @@ export async function checkSchemaRequirements(pool: Pick, require } export class PostgresStore implements HeimdallStore { - constructor(private readonly pool: Pick) {} + constructor(private readonly pool: Pick) {} async ensureSchema(): Promise { await this.pool.query(CREATE_SCHEMA_SQL); @@ -722,6 +723,60 @@ export class PostgresStore implements HeimdallStore { return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity")); } + async updateLinkedIdentityCredential( + id: string, + decide: (locked: StoredLinkedIdentity) => Promise, + ): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + try { + const locked = await client.query( + "SELECT * FROM linked_identities WHERE id = $1 FOR UPDATE", + [id] + ); + if (!locked.rowCount) { + await client.query("COMMIT"); + return null; + } + const row = mapLinkedIdentityRow(expectRow(locked.rows[0], "updateLinkedIdentityCredential")); + const decision = await decide(row); + let committed = row; + if (decision.kind !== "keep") { + const rotated = decision.kind === "rotate" ? decision : undefined; + const written = await client.query( + ` + UPDATE linked_identities + SET access_token_encrypted = $2, + refresh_token_encrypted = $3, + token_expires_at = $4, + scopes = $5, + updated_at = $6 + WHERE id = $1 + RETURNING * + `, + [ + id, + nullable(rotated?.accessTokenEncrypted), + nullable(rotated?.refreshTokenEncrypted), + nullable(rotated?.tokenExpiresAt), + (rotated?.scopes ?? row.scopes).join(" "), + decision.updatedAt, + ] + ); + committed = mapLinkedIdentityRow(expectRow(written.rows[0], "updateLinkedIdentityCredential")); + } + await client.query("COMMIT"); + return committed; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + } finally { + client.release(); + } + } + async listLinkedIdentitiesForAccount(accountId: string): Promise { const result = await this.pool.query( ` diff --git a/src/store/types.ts b/src/store/types.ts index d1fabcf..0145626 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -56,6 +56,23 @@ export interface UpsertLinkedIdentityInput { updatedAt: string; } +/** + * What the owner of a refresh decides after reading the row under its lock. + * `rotate` and `revoke` write token columns, scopes and updated_at only; they + * never touch account, profile or identity fields. + */ +export type CredentialDecision = + | { kind: "keep" } + | { + kind: "rotate"; + accessTokenEncrypted: string; + refreshTokenEncrypted: string; + tokenExpiresAt?: string; + scopes: string[]; + updatedAt: string; + } + | { kind: "revoke"; updatedAt: string }; + export interface StoredSession { id: string; accountId: string; @@ -213,6 +230,17 @@ export interface HeimdallStore { findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise; listStoredLinkedIdentitiesForAccount(accountId: string): Promise; upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise; + /** + * Serialise decisions about one row's credential. `decide` receives the row + * as read under the lock (never a caller's snapshot) and the decision is + * committed before this resolves, so a rotated pair is persisted before any + * caller sees the new access token. Returns the committed row, or null when + * no row has that id. A throw from `decide` writes nothing. + */ + updateLinkedIdentityCredential( + id: string, + decide: (locked: StoredLinkedIdentity) => Promise, + ): Promise; listLinkedIdentitiesForAccount(accountId: string): Promise; createCapabilityGrant(input: CreateCapabilityGrantInput): Promise; listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise; diff --git a/tests/app.test.ts b/tests/app.test.ts index 7f9a72c..d938b9c 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -5,7 +5,7 @@ import { buildApp, errorIdentity } from "../src/app.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js"; import { type HeimdallConfig, loadConfig } from "../src/config.js"; import { entitlementFacts, identityFacts } from "../src/facts.js"; -import { createOAuthRuntimeRegistry, type OAuthProviderRuntime } from "../src/oauth.js"; +import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js"; import { verifyJwt } from "../src/signing.js"; import { InMemoryStore } from "../src/store/index.js"; @@ -1238,6 +1238,117 @@ describe("Heimdall service", () => { expect(credentialResponse.body).not.toContain("rotated-twitch-refresh-token"); }); + 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([]); + }); + 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 diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts new file mode 100644 index 0000000..b0dc2a2 --- /dev/null +++ b/tests/managed-credential.test.ts @@ -0,0 +1,517 @@ +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; +import { type HeimdallConfig } from "../src/config.js"; +import { type Provider } from "../src/contracts.js"; +import { createTokenCustody } from "../src/custody.js"; +import { + MANAGED_CREDENTIAL_REFRESH_SKEW_MS, + ManagedCredentialRefreshFailed, + refreshManagedCredential, +} from "../src/managed-credential.js"; +import { createOAuthRuntimeRegistry } from "../src/oauth.js"; +import { providerCatalog } from "../src/providers.js"; +import { InMemoryStore, PostgresStore, type HeimdallStore } from "../src/store/index.js"; +import { type StoredLinkedIdentity } from "../src/store/types.js"; + +const originalFetch = globalThis.fetch; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function testConfig(): HeimdallConfig { + const clients = Object.fromEntries( + ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"].map((p) => [ + p, + { clientId: `${p}-client`, clientSecret: `${p}-secret` }, + ]) + ); + 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", bifrost: "bifrost-secret" }, + appBackendCallbacks: {}, + storage: { backend: "memory", applySchemaOnStartup: true }, + bifrostPatronSupportEndpoint: "https://bifrost.gamecult.org/heimdall/patron-support/events", + bifrostPatronSupportSecret: "bifrost-support-secret", + providers: clients, + } as unknown as HeimdallConfig; +} + +type Mode = "normal" | "invalid_grant" | "invalid_client" | "server_error" | "not_json" | "no_refresh_token" | "hang"; + +/** + * A provider token endpoint with single-use refresh tokens. Presenting a + * consumed refresh token revokes the whole installation, as Joystick does: + * every later refresh answers 400 invalid_grant. + */ +function installTokenEndpoint(provider: Provider, firstRefreshToken: string) { + const state = { + calls: 0, + mode: "normal" as Mode, + live: firstRefreshToken, + issued: 0, + installationRevoked: false, + otherCalls: [] as string[], + }; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + if (url !== providerCatalog[provider].tokenEndpoint) { + state.otherCalls.push(url); + return new Response("unexpected", { status: 500 }); + } + state.calls += 1; + const presented = (init!.body as URLSearchParams).get("refresh_token"); + await sleep(25); + switch (state.mode) { + case "hang": + return new Promise((_resolve, reject) => { + init!.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + }); + case "invalid_grant": + return new Response(JSON.stringify({ error: "invalid_grant", error_description: "CANARY" }), { status: 400 }); + case "invalid_client": + return new Response(JSON.stringify({ error: "invalid_client", error_description: "CANARY" }), { status: 401 }); + case "server_error": + return new Response("CANARY upstream exploded", { status: 500 }); + case "not_json": + return new Response("CANARY ", { status: 200 }); + case "no_refresh_token": + return new Response(JSON.stringify({ access_token: "access-norefresh", token_type: "Bearer", expires_in: 3600 }), { + status: 200, + }); + case "normal": { + if (state.installationRevoked || presented !== state.live) { + state.installationRevoked = true; + return new Response(JSON.stringify({ error: "invalid_grant", error_description: "reused CANARY" }), { status: 400 }); + } + state.issued += 1; + state.live = `refresh-${state.issued}`; + return new Response( + JSON.stringify({ + access_token: `access-${state.issued}`, + refresh_token: state.live, + token_type: "Bearer", + expires_in: 3600, + }), + { status: 200 } + ); + } + } + }) as typeof fetch; + return state; +} + +interface Rig { + stores: HeimdallStore[]; + apps: Awaited>[]; + accountId: string; + identityId: string; + custody: ReturnType; + config: HeimdallConfig; + provider: Provider; + row(): Promise; + audit(): Promise>; + resolve(appIndex?: number): Promise<{ statusCode: number; json: Record; body: string }>; + close(): Promise; +} + +const adminUrl = process.env.HEIMDALL_TEST_PG_ADMIN_URL; +const rigKinds = [ + { kind: "memory", live: false }, + { kind: "postgres one app", live: true }, + { kind: "postgres two apps", live: true }, +] as const; +// Providers whose refresh goes through the real shared runtime; rotation differs by descriptor. +const refreshProviders: Provider[] = ["twitch"]; + +for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`managed credential refresh, ${kind}`, () => { + const admin = live && adminUrl ? new Pool({ connectionString: adminUrl, max: 2 }) : undefined; + const suffix = randomUUID().replace(/-/g, "").slice(0, 12); + const template = `heimdall_mc_template_${suffix}`; + const cleanup: Array<() => Promise> = []; + + function urlFor(database: string): string { + const url = new URL(adminUrl!); + url.pathname = `/${database}`; + return url.toString(); + } + + beforeAll(async () => { + if (!live) return; + await admin!.query(`CREATE DATABASE ${template}`); + const pool = new Pool({ connectionString: urlFor(template), max: 1 }); + await new PostgresStore(pool).ensureSchema(); + await pool.end(); + }, 120_000); + + afterEach(async () => { + globalThis.fetch = originalFetch; + for (const step of cleanup.splice(0).reverse()) await step(); + }); + + afterAll(async () => { + if (!admin) return; + await admin.query(`DROP DATABASE IF EXISTS ${template}`); + await admin.end(); + }, 120_000); + + async function makeRig( + provider: Provider, + options: { expiresInMs?: number; refreshToken?: string | null; accessToken?: string | null } = {} + ): Promise { + const config = testConfig(); + const custody = createTokenCustody(config); + const stores: HeimdallStore[] = []; + let adminForDb: Pool | undefined; + if (!live) { + stores.push(new InMemoryStore()); + } else { + const name = `heimdall_mc_${randomUUID().replace(/-/g, "")}`; + await admin!.query(`CREATE DATABASE ${name} TEMPLATE ${template}`); + const count = kind === "postgres two apps" ? 2 : 1; + for (let i = 0; i < count; i += 1) { + const pool = new Pool({ connectionString: urlFor(name), max: 12 }); + pool.on("error", () => undefined); + stores.push(new PostgresStore(pool)); + } + adminForDb = new Pool({ connectionString: urlFor(name), max: 1 }); + adminForDb.on("error", () => undefined); + cleanup.push(async () => { + await adminForDb!.end(); + await admin!.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`); + }); + } + + const now = new Date().toISOString(); + const account = await stores[0]!.createAccount({ createdAt: now, lastSeenAt: now }); + const expiresInMs = options.expiresInMs ?? 60_000; + const input = { + accountId: account.id, + provider, + providerUserId: "channel-1", + username: "deru", + displayName: "Deru", + scopes: ["identity:read"], + profileJson: { keep: "me" }, + tokenExpiresAt: new Date(Date.now() + expiresInMs).toISOString(), + createdAt: now, + updatedAt: now, + }; + const seeded = await stores[0]!.upsertLinkedIdentity({ + ...input, + ...(options.accessToken === null ? {} : { accessTokenEncrypted: custody.encrypt(options.accessToken ?? "access-0") }), + ...(options.refreshToken === null ? {} : { refreshTokenEncrypted: custody.encrypt(options.refreshToken ?? "refresh-0") }), + }); + + const apps: Rig["apps"] = []; + for (const store of stores) { + apps.push(await buildApp({ config, store, tokenCustody: custody, oauthRuntimes: createOAuthRuntimeRegistry() })); + } + cleanup.push(async () => { + for (const app of apps) await app.close().catch(() => undefined); + }); + + return { + stores, + apps, + accountId: account.id, + identityId: seeded.id, + custody, + config, + provider, + async row() { + const row = await stores[0]!.findStoredLinkedIdentityForAccount(account.id, provider); + return row!; + }, + async audit() { + if (!live) { + const events = (stores[0] as unknown as { auditEvents: Map }).auditEvents; + return [...events.values()].map((e) => ({ eventType: e.eventType, payload: e.eventPayloadJson })); + } + const result = await adminForDb!.query<{ event_type: string; event_payload_json: unknown }>( + "SELECT event_type, event_payload_json FROM audit_events ORDER BY created_at" + ); + return result.rows.map((r) => ({ eventType: r.event_type, payload: r.event_payload_json })); + }, + async resolve(appIndex = 0) { + const response = await apps[appIndex]!.inject({ + method: "POST", + url: "/v1/apps/streampixels/managed-credentials/resolve", + headers: { "x-heimdall-app-secret": "streampixels-secret" }, + payload: { accountId: account.id, provider }, + }); + return { statusCode: response.statusCode, json: response.json() as Record, body: response.body }; + }, + async close() {}, + }; + } + + const timeout = { timeout: 60_000 }; + + describe.each(refreshProviders)("%s", (provider) => { + it("refresh-once-per-token: 20 concurrent resolves present the refresh token once", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + + const answers = await Promise.all(Array.from({ length: 20 }, (_, i) => rig.resolve(i % rig.apps.length))); + + expect(endpoint.calls).toBe(1); + expect(answers.map((a) => a.statusCode)).toEqual(Array(20).fill(200)); + expect(new Set(answers.map((a) => a.json.accessToken))).toEqual(new Set(["access-1"])); + const row = await rig.row(); + expect(rig.custody.decrypt(row.accessTokenEncrypted!)).toBe("access-1"); + expect(rig.custody.decrypt(row.refreshTokenEncrypted!)).toBe("refresh-1"); + expect(await rig.audit()).toEqual([]); + }); + + it("rotated-pair-persisted-before-use: the stored pair is the one the caller was given", timeout, async () => { + const rig = await makeRig(provider); + installTokenEndpoint(provider, "refresh-0"); + const answer = await rig.resolve(); + const row = await rig.row(); + expect(rig.custody.decrypt(row.accessTokenEncrypted!)).toBe(answer.json.accessToken); + expect(Date.parse(row.tokenExpiresAt!)).toBeGreaterThan(Date.now() + MANAGED_CREDENTIAL_REFRESH_SKEW_MS); + }); + + it("outside the skew: no token call, the stored token is returned", timeout, async () => { + const rig = await makeRig(provider, { expiresInMs: 60 * 60 * 1000 }); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + const answer = await rig.resolve(); + expect(endpoint.calls).toBe(0); + expect(answer.statusCode).toBe(200); + expect(answer.json.accessToken).toBe("access-0"); + }); + + it("revocation-is-typed: invalid_grant clears the tokens, answers 410, audits once, and stops calling the provider", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "invalid_grant"; + + const first = await rig.resolve(); + expect(first.statusCode).toBe(410); + expect(first.json).toEqual({ error: "managed_credential_revoked", provider }); + const row = await rig.row(); + expect(row.accessTokenEncrypted).toBeUndefined(); + expect(row.refreshTokenEncrypted).toBeUndefined(); + expect(row.tokenExpiresAt).toBeUndefined(); + expect(row.username).toBe("deru"); + expect(row.profileJson).toEqual({ keep: "me" }); + expect(row.scopes).toEqual(["identity:read"]); + + const second = await rig.resolve(); + expect(second.statusCode).toBe(410); + expect(endpoint.calls).toBe(1); + + const events = await rig.audit(); + expect(events.filter((e) => e.eventType === "managed_credential_revoked")).toEqual([ + { eventType: "managed_credential_revoked", payload: { provider, reason: "provider_revoked" } }, + ]); + expect(first.body + second.body + JSON.stringify(events)).not.toContain("CANARY"); + }); + + it.each(["invalid_client", "server_error", "not_json", "hang"] as const)( + "transient failure (%s) keeps custody: 502, tokens byte-identical, nothing leaked", + timeout, + async (mode) => { + const rig = await makeRig(provider); + const before = await rig.row(); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = mode; + + let answer; + if (mode === "hang") { + // The route's own bound is 10 s; the primitive takes an injected one. + const failure = await refreshManagedCredential( + { config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 100 }, + before + ).then( + () => undefined, + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(ManagedCredentialRefreshFailed); + expect((failure as ManagedCredentialRefreshFailed).provider).toBe(provider); + expect(String(failure) + JSON.stringify(failure)).not.toContain("CANARY"); + } else { + answer = await rig.resolve(); + expect(answer.statusCode).toBe(502); + expect(answer.json).toEqual({ error: "managed_credential_refresh_failed", provider }); + expect(answer.body).not.toContain("CANARY"); + } + + const after = await rig.row(); + expect(after.accessTokenEncrypted).toBe(before.accessTokenEncrypted); + expect(after.refreshTokenEncrypted).toBe(before.refreshTokenEncrypted); + expect(after.tokenExpiresAt).toBe(before.tokenExpiresAt); + expect(await rig.audit()).toEqual([]); + } + ); + + it("bounded lock: a refresh that never answers is aborted and the next resolve takes the lock", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "hang"; + const ctx = { config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 100 }; + const started = Date.now(); + await expect(refreshManagedCredential(ctx, await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed); + expect(Date.now() - started).toBeLessThan(5_000); + + endpoint.mode = "normal"; + const outcome = await refreshManagedCredential(ctx, await rig.row()); + expect(outcome.kind).toBe("current"); + expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1"); + }); + + it("a snapshot taken before another caller's refresh does not decide: the locked row does", timeout, async () => { + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + const stale = await rig.row(); + await rig.resolve(); + expect(endpoint.calls).toBe(1); + const outcome = await refreshManagedCredential( + { config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry() }, + stale + ); + expect(endpoint.calls).toBe(1); + expect(outcome.kind).toBe("current"); + expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1"); + }); + + it("a response without a refresh token keeps the old one for a non-rotating provider", timeout, async () => { + expect(providerCatalog[provider].rotatesRefreshTokens).toBe(false); + const rig = await makeRig(provider); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + endpoint.mode = "no_refresh_token"; + const answer = await rig.resolve(); + expect(answer.statusCode).toBe(200); + expect(answer.json.accessToken).toBe("access-norefresh"); + expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0"); + }); + + it("a row with no access token resolves as revoked (410), a missing row as not found (404)", timeout, async () => { + const rig = await makeRig(provider, { accessToken: null, refreshToken: null }); + const endpoint = installTokenEndpoint(provider, "refresh-0"); + const revoked = await rig.resolve(); + expect(revoked.statusCode).toBe(410); + expect(revoked.json).toEqual({ error: "managed_credential_revoked", provider }); + expect(endpoint.calls).toBe(0); + + const missing = await rig.apps[0]!.inject({ + method: "POST", + url: "/v1/apps/streampixels/managed-credentials/resolve", + headers: { "x-heimdall-app-secret": "streampixels-secret" }, + payload: { accountId: "no-such-account", provider }, + }); + expect(missing.statusCode).toBe(404); + expect(missing.json()).toEqual({ error: "managed_credential_not_found" }); + }); + }); + + describe("updateLinkedIdentityCredential", () => { + it("returns null for a missing id and does not call decide", async () => { + const rig = await makeRig("twitch"); + let called = false; + const result = await rig.stores[0]!.updateLinkedIdentityCredential(randomUUID(), async () => { + called = true; + return { kind: "keep" }; + }); + expect(result).toBeNull(); + expect(called).toBe(false); + }); + + it("hands decide the committed row, and a throw writes nothing", async () => { + const rig = await makeRig("twitch"); + const before = await rig.row(); + let seen: StoredLinkedIdentity | undefined; + await expect( + rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async (locked) => { + seen = locked; + throw new Error("boom"); + }) + ).rejects.toThrow("boom"); + expect(seen).toEqual(before); + expect(await rig.row()).toEqual(before); + }); + + it("rotate writes token columns, scopes and updated_at and nothing else; keep writes nothing", async () => { + const rig = await makeRig("twitch"); + const before = await rig.row(); + const kept = await rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ kind: "keep" })); + expect(kept).toEqual(before); + + const rotated = await rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ + kind: "rotate", + accessTokenEncrypted: "A2", + refreshTokenEncrypted: "R2", + tokenExpiresAt: "2999-01-01T00:00:00.000Z", + scopes: ["a", "b"], + updatedAt: "2999-01-02T00:00:00.000Z", + })); + expect(rotated).toEqual({ + ...before, + accessTokenEncrypted: "A2", + refreshTokenEncrypted: "R2", + tokenExpiresAt: "2999-01-01T00:00:00.000Z", + scopes: ["a", "b"], + updatedAt: "2999-01-02T00:00:00.000Z", + }); + expect(await rig.row()).toEqual(rotated); + + const revoked = await rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ + kind: "revoke", + updatedAt: "2999-01-03T00:00:00.000Z", + })); + const { accessTokenEncrypted: _a, refreshTokenEncrypted: _r, tokenExpiresAt: _e, ...rest } = rotated!; + expect(revoked).toEqual({ ...rest, updatedAt: "2999-01-03T00:00:00.000Z" }); + expect(await rig.row()).toEqual(revoked); + }); + + it("serialises deciders on one row: each sees the previous commit", timeout, async () => { + const rig = await makeRig("twitch"); + const order: string[] = []; + await Promise.all( + [1, 2, 3, 4, 5].map((n) => + rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async (locked) => { + order.push(`${n}:${locked.accessTokenEncrypted}`); + await sleep(20); + return { + kind: "rotate", + accessTokenEncrypted: `A${n}`, + refreshTokenEncrypted: `R${n}`, + scopes: locked.scopes, + updatedAt: new Date().toISOString(), + }; + }) + ) + ); + const seenValues = order.map((entry) => entry.split(":")[1]); + // Each decider saw the value the one before it wrote, so their reads form a chain. + expect(new Set(seenValues).size).toBe(5); + const wrote = order.map((entry) => `A${entry.split(":")[0]}`); + for (let i = 1; i < seenValues.length; i += 1) expect(seenValues[i]).toBe(wrote[i - 1]); + }); + }); +}); + +describe("live Postgres availability", () => { + it.skipIf(!!adminUrl)("is present whenever the job requires it", () => { + // Local workstation runs skip the live suites; the verification job sets + // HEIMDALL_REQUIRE_LIVE_PG=1 and fails here when they would have been skipped. + expect(process.env.HEIMDALL_REQUIRE_LIVE_PG).toBeUndefined(); + }); +}); diff --git a/tests/provider-token-request.test.ts b/tests/provider-token-request.test.ts index 2d13a30..ff13d72 100644 --- a/tests/provider-token-request.test.ts +++ b/tests/provider-token-request.test.ts @@ -1,3 +1,4 @@ +import { inspect } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { type HeimdallConfig } from "../src/config.js"; import { providers, type Provider } from "../src/contracts.js"; @@ -114,3 +115,33 @@ describe("requestProviderTokens follows the provider descriptor", () => { ).rejects.toThrow("Patreon OAuth is not fully configured."); }); }); + +describe("oauthError is a closed set", () => { + const refresh = { grant_type: "refresh_token", refresh_token: "r" } as const; + async function failure(body: string, status = 400) { + capture(() => new Response(body, { status })); + return requestProviderTokens("twitch", configFor(), refresh).then( + () => { + throw new Error("expected a refusal"); + }, + (error: unknown) => error as Error & { oauthError?: string } + ); + } + + it("takes invalid_grant from the body and carries nothing else of it", async () => { + const error = await failure(JSON.stringify({ error: "invalid_grant", error_description: "CANARY" })); + expect(error.oauthError).toBe("invalid_grant"); + expect(`${error.message} ${error.stack} ${inspect(error, { depth: 5, showHidden: true })} ${JSON.stringify(error)}`).not.toContain("CANARY"); + }); + + it.each([ + ["an error outside the RFC set", JSON.stringify({ error: "CANARY_unknown" })], + ["a non-string error", JSON.stringify({ error: { code: "invalid_grant" } })], + ["a body that is not JSON", "CANARY "], + ["JSON null", "null"], + ])("leaves oauthError undefined for %s", async (_name, body) => { + const error = await failure(body); + expect(error.oauthError).toBeUndefined(); + expect(`${error.message} ${inspect(error, { depth: 5, showHidden: true })}`).not.toContain("CANARY"); + }); +}); diff --git a/tests/store.test.ts b/tests/store.test.ts index 8eeaeb1..c5f6445 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -326,7 +326,8 @@ async function exerciseStore(store: PostgresStore): Promise> { createdAt: now, updatedAt: now, }; - await s.upsertLinkedIdentity(identity); + const linked = await s.upsertLinkedIdentity(identity); + await s.updateLinkedIdentityCredential(linked.id, async () => ({ kind: "keep" })); await s.upsertLinkedIdentity({ ...identity, username: "again" }); await s.findAccountByLinkedIdentity("discord", "u1"); await s.findStoredLinkedIdentity("discord", "u1");