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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-19 11:11:34 +02:00
commit c81f923890
10 changed files with 979 additions and 99 deletions

View file

@ -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<StoredLinkedIdentity> {
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<string[]> {
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<FastifyIn
}
const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, "patreon");
if (!storedLinkedIdentity?.accessTokenEncrypted) {
if (!storedLinkedIdentity) {
reply.code(404);
return { error: "patreon_identity_not_linked" };
}
const now = new Date().toISOString();
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({
config,
store,
tokenCustody,
oauthRuntimes,
linkedIdentity: storedLinkedIdentity,
now,
});
if (!linkedIdentity.accessTokenEncrypted) {
const refreshed = await refreshManagedCredential({ config, store, tokenCustody, oauthRuntimes }, storedLinkedIdentity);
const linkedIdentity = refreshed.identity;
if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
reply.code(404);
return { error: "patreon_identity_not_linked" };
}
@ -1772,22 +1700,24 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}
const storedLinkedIdentity = await store.findStoredLinkedIdentityForAccount(request.body.accountId, request.body.provider);
if (!storedLinkedIdentity?.accessTokenEncrypted) {
if (!storedLinkedIdentity) {
reply.code(404);
return { error: "managed_credential_not_found" };
}
const now = new Date().toISOString();
const linkedIdentity = await refreshLinkedIdentityCredentialIfNeeded({
config,
store,
tokenCustody,
oauthRuntimes,
linkedIdentity: storedLinkedIdentity,
now,
});
if (!linkedIdentity.accessTokenEncrypted) {
reply.code(404);
return { error: "managed_credential_not_found" };
let refreshed;
try {
refreshed = await refreshManagedCredential({ config, store, tokenCustody, oauthRuntimes }, storedLinkedIdentity);
} catch (error) {
if (error instanceof ManagedCredentialRefreshFailed) {
reply.code(502);
return { error: error.code, provider: error.provider };
}
throw error;
}
const linkedIdentity = refreshed.identity;
if (refreshed.kind === "revoked" || !linkedIdentity.accessTokenEncrypted) {
reply.code(410);
return { error: "managed_credential_revoked", provider: linkedIdentity.provider };
}
const response: {

140
src/managed-credential.ts Normal file
View file

@ -0,0 +1,140 @@
import { type HeimdallConfig } from "./config.js";
import { type Provider } from "./contracts.js";
import { type TokenCustody } from "./custody.js";
import { type OAuthProviderRuntime, type OAuthRuntimeRegistry, ProviderResponseError } from "./oauth.js";
import { providerCatalog } from "./providers.js";
import { type HeimdallStore } from "./store/index.js";
import { type CredentialDecision, type StoredLinkedIdentity } from "./store/types.js";
/** A stored access token this close to expiry (or past it) is refreshed before use. */
export const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000;
/**
* The longest a refresh may hold the row lock: the provider call is aborted
* at this bound, and every waiter holds a pooled connection for at most this
* long behind the holder.
*/
export const MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS = 10_000;
export interface ManagedCredentialContext {
config: HeimdallConfig;
store: HeimdallStore;
tokenCustody: TokenCustody;
oauthRuntimes: OAuthRuntimeRegistry;
clock?: () => 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<ManagedCredentialOutcome> {
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<CredentialDecision> => {
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 };
}

View file

@ -61,6 +61,25 @@ export interface OAuthProviderRuntime {
}): Promise<EntitlementEvaluation>;
}
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<T>(response: Response, failure: string): Promise<T> {
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;

View file

@ -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<string, Promise<unknown>>();
private readonly accounts = new Map<string, StoredAccount>();
private readonly linkedIdentities = new Map<string, StoredLinkedIdentity>();
private readonly grants = new Map<string, StoredCapabilityGrant>();
@ -174,6 +177,45 @@ export class InMemoryStore implements HeimdallStore {
return clone(linkedIdentity);
}
async updateLinkedIdentityCredential(
id: string,
decide: (locked: StoredLinkedIdentity) => Promise<CredentialDecision>,
): Promise<StoredLinkedIdentity | null> {
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<LinkedIdentityInput[]> {
return [...this.linkedIdentities.values()]
.filter((identity) => identity.accountId === accountId)

View file

@ -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<Pool, "query">, require
}
export class PostgresStore implements HeimdallStore {
constructor(private readonly pool: Pick<Pool, "query" | "end">) {}
constructor(private readonly pool: Pick<Pool, "query" | "end" | "connect">) {}
async ensureSchema(): Promise<void> {
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<CredentialDecision>,
): Promise<StoredLinkedIdentity | null> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
try {
const locked = await client.query<LinkedIdentityRow>(
"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<LinkedIdentityRow>(
`
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<LinkedIdentityInput[]> {
const result = await this.pool.query<LinkedIdentityRow>(
`

View file

@ -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<StoredLinkedIdentity | null>;
listStoredLinkedIdentitiesForAccount(accountId: string): Promise<StoredLinkedIdentity[]>;
upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity>;
/**
* 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<CredentialDecision>,
): Promise<StoredLinkedIdentity | null>;
listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]>;
createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant>;
listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]>;

View file

@ -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

View file

@ -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<Response>((_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 <html>", { 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<ReturnType<typeof buildApp>>[];
accountId: string;
identityId: string;
custody: ReturnType<typeof createTokenCustody>;
config: HeimdallConfig;
provider: Provider;
row(): Promise<StoredLinkedIdentity>;
audit(): Promise<Array<{ eventType: string; payload: unknown }>>;
resolve(appIndex?: number): Promise<{ statusCode: number; json: Record<string, unknown>; body: string }>;
close(): Promise<void>;
}
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<void>> = [];
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<Rig> {
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<string, { eventType: string; eventPayloadJson: unknown }> }).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<string, unknown>, 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();
});
});

View file

@ -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 <html>"],
["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");
});
});

View file

@ -326,7 +326,8 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
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");