fix(connections): make broker claims retryable

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 16:01:08 +00:00
1 parent c3ba178ec1
commit 9f29f019fa
4 files changed
+43 -12

No files matched your search

+2 -2
View File
@@ -114,7 +114,7 @@ describe("connect protocol", () => {
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const store = replayStore();
const compactJws = signRequestEnvelope({
body: { claimCode: "claim-code-with-enough-entropy" },
body: { claimCode: "claim-code-with-enough-entropy", idempotencyKey: "a".repeat(64) },
instanceId: "in_devinstance",
keyId: "key_1",
privateKey,
@@ -131,7 +131,7 @@ describe("connect protocol", () => {
replayStore: store,
now: new Date("2026-07-21T18:00:01.000Z"),
};
await expect(verifyRequestEnvelope(input)).resolves.toMatchObject({ body: { claimCode: "claim-code-with-enough-entropy" } });
await expect(verifyRequestEnvelope(input)).resolves.toMatchObject({ body: { claimCode: "claim-code-with-enough-entropy", idempotencyKey: "a".repeat(64) } });
await expect(verifyRequestEnvelope(input)).rejects.toMatchObject({ code: "replayed_jti", status: 401 });
});
+4 -1
View File
@@ -52,7 +52,10 @@ export const handshakeResponseSchema = z.object({
expiresAt: z.string().datetime(),
});
export const claimRequestSchema = z.object({ claimCode: z.string().min(22) });
export const claimRequestSchema = z.object({
claimCode: z.string().min(22),
idempotencyKey: z.string().regex(/^[a-f0-9]{64}$/),
});
export const tokenMetadataSchema = z.object({
scopes: z.array(z.string()),
+35 -7
View File
@@ -17,7 +17,7 @@ const CONNECTION_ID = "44444444-4444-4444-8444-444444444444";
function fixture(mode: "A" | "B2" = "A") {
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const replay = new Set<string>();
const claims = new Map([["claim-code-at-least-22-chars", { owner: INSTANCE_ID, attempts: 0, consumed: false }]]);
const claims = new Map([["claim-code-at-least-22-chars", { owner: INSTANCE_ID, attempts: 0, consumed: false, idempotencyKey: "", response: null as Record<string, unknown> | null }]]);
const saved: Parameters<ConnectionBrokerStore["saveGrant"]>[0][] = [];
const secrets: Array<{ purpose: string; value: string }> = [];
const replayStore: ReplayStore = { consume: (_instance, jti) => !replay.has(jti) && Boolean(replay.add(jti)) };
@@ -28,16 +28,22 @@ function fixture(mode: "A" | "B2" = "A") {
try {
const envelope = await verifyRequestEnvelope({ compactJws, publicKey, expectedInstanceId: INSTANCE_ID, expectedPath: path, replayStore });
if (path === "/v1/claims") {
const code = (envelope.body as { claimCode: string }).claimCode;
const { claimCode: code, idempotencyKey } = envelope.body as { claimCode: string; idempotencyKey: string };
const claim = claims.get(code);
if (!claim) return { status: 404, body: { error: "unknown_claim" } };
if (claim.attempts >= 5) return { status: 404, body: { error: "unknown_claim" } };
if (claim.owner !== envelope.iss) { claim.attempts += 1; return { status: 403, body: { error: "claim_instance_mismatch" } }; }
if (claim.consumed) return { status: 409, body: { error: "claim_already_consumed" } };
if (claim.consumed) {
return claim.idempotencyKey === idempotencyKey && claim.response
? { status: 200, body: claim.response }
: { status: 409, body: { error: "claim_already_consumed" } };
}
claim.consumed = true;
return mode === "A"
? { status: 200, body: { custodyMode: "A", grantRef: "gr_testgrant", tokenMeta: { scopes: ["read"], expiresAt: null } } }
: { status: 200, body: { custodyMode: "B2", grantSealed: "sealed-refresh", tokenMeta: { scopes: ["read"], expiresAt: null } } };
claim.idempotencyKey = idempotencyKey;
claim.response = mode === "A"
? { custodyMode: "A", grantRef: "gr_testgrant", tokenMeta: { scopes: ["read"], expiresAt: null } }
: { custodyMode: "B2", grantSealed: "sealed-refresh", tokenMeta: { scopes: ["read"], expiresAt: null } };
return { status: 200, body: claim.response };
}
if (path.endsWith("/token")) return { status: 200, body: { grantSealed: "sealed-access", tokenMeta: { scopes: ["read"], expiresAt: null } } };
if (path === "/v1/handshakes") return { status: 201, body: { handshakeId: "hs_test", authorizeUrl: "https://connect.example/authorize", expiresAt: new Date(Date.now() + 60_000).toISOString() } };
@@ -74,7 +80,7 @@ describe("connection broker", () => {
it("surfaces a 409 redemption as an interception alert", async () => {
const f = fixture();
await f.service.claimOAuth({ companyId: "company", connectionId: CONNECTION_ID, claimCode: "claim-code-at-least-22-chars", kind: "workspace" });
await expect(f.service.claimOAuth({ companyId: "company", connectionId: CONNECTION_ID, claimCode: "claim-code-at-least-22-chars", kind: "workspace" })).rejects.toBeInstanceOf(ConnectClaimInterceptionError);
await expect(f.client.claim("claim-code-at-least-22-chars", "b".repeat(64))).rejects.toBeInstanceOf(ConnectClaimInterceptionError);
});
it("does not consume mismatched claims and locks the service-side double after five attempts", async () => {
@@ -122,6 +128,28 @@ describe("connection broker", () => {
expect(f.claimStates.size).toBe(0);
});
it("retries the remote claim idempotently when local recovery persistence fails", async () => {
const f = fixture("B2");
let failSave = true;
const service = connectionBrokerService({
client: f.client,
store: {
...f.store,
saveClaimState: async (input) => {
if (failSave) { failSave = false; throw new Error("database unavailable"); }
await f.store.saveClaimState(input);
},
},
enabled: true,
vault: { put: async (input) => ({ secretId: "secret-grant", versionSelector: "latest", configPath: "broker.grant", required: true, label: input.purpose }) },
});
const params = { companyId: "company", connectionId: CONNECTION_ID, claimCode: "claim-code-at-least-22-chars", kind: "workspace" as const };
await expect(service.claimOAuth(params)).rejects.toThrow("database unavailable");
expect(f.claims.get(params.claimCode)?.consumed).toBe(true);
await expect(service.claimOAuth(params)).resolves.toMatchObject({ custodyMode: "B2" });
});
it("rejects user grants before consuming a claim when membership is inactive", async () => {
const f = fixture();
const service = connectionBrokerService({
+2 -2
View File
@@ -28,7 +28,7 @@ export function createSignedConnectBrokerClient(input: { instanceId: string; key
}
return {
createHandshake: async (body: unknown) => handshakeResponseSchema.parse(await request("/v1/handshakes", handshakeRequestSchema.parse(body))),
claim: async (claimCode: string) => claimResponseSchema.parse(await request("/v1/claims", claimRequestSchema.parse({ claimCode }))),
claim: async (claimCode: string, idempotencyKey: string) => claimResponseSchema.parse(await request("/v1/claims", claimRequestSchema.parse({ claimCode, idempotencyKey }))),
mintGrantToken: async (grantRef: string, grantWrapKey: string) => grantTokenResponseSchema.parse(await request(`/v1/grants/${encodeURIComponent(grantRef)}/token`, grantTokenRequestSchema.parse({ grantWrapKey }))),
registerRelay: async (body: unknown) => relayRegistrationResponseSchema.parse(await request("/v1/connections/register", relayRegistrationRequestSchema.parse(body))),
};
@@ -112,7 +112,7 @@ export function connectionBrokerService(input: { client: ConnectBrokerClient; st
}
const claimCodeHash = createHash("sha256").update(params.claimCode).digest("hex");
const existingState = await input.store.loadClaimState({ companyId: params.companyId, connectionId: params.connectionId, claimCodeHash });
const claim = existingState?.claim ?? await input.client.claim(params.claimCode);
const claim = existingState?.claim ?? await input.client.claim(params.claimCode, claimCodeHash);
if (!existingState) await input.store.saveClaimState({ companyId: params.companyId, connectionId: params.connectionId, claimCodeHash, claim });
if (claim.custodyMode !== "A" && claim.custodyMode !== "B2") throw new ConnectServiceResponseError(403, "mode_not_allowed");
const serviceGrantRef = claim.custodyMode === "A" ? claim.grantRef : null;