Skip to content

Commit 5a78855

Browse files
author
kjgbot
committed
feat: commit OIDC sponsor evidence atomically
1 parent b2dde75 commit 5a78855

3 files changed

Lines changed: 174 additions & 3 deletions

File tree

packages/server/src/__tests__/sponsor-oidc-binding.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
createTestApp,
1111
createTestRequest,
1212
generateTestToken,
13+
TEST_RS256_PUBLIC_KEY_PEM,
1314
} from "./test-helpers.js";
1415

1516
const OIDC_KEY_PAIR = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
@@ -182,6 +183,47 @@ test("OIDC-bound org accepts verified sponsor proof and records binding evidence
182183
const stored = await assertJsonResponse<CreatedIdentity>(storedResponse, 200);
183184
assert.deepEqual(stored.sponsorBinding, identity.sponsorBinding);
184185

186+
const ledger = await app.storage.DB.prepare(`
187+
SELECT entry_type, agent_id, sponsor_id, jti, payload_json, jws
188+
FROM attestation_ledger
189+
WHERE org_id = ? AND agent_id = ?
190+
`).bind(org, identity.id).first<{
191+
entry_type: string;
192+
agent_id: string;
193+
sponsor_id: string;
194+
jti: string | null;
195+
payload_json: string;
196+
jws: string;
197+
}>();
198+
assert.ok(ledger);
199+
assert.equal(ledger.entry_type, "identity.created");
200+
assert.equal(ledger.agent_id, identity.id);
201+
assert.equal(ledger.sponsor_id, "user_alice");
202+
assert.equal(ledger.jti, "idp-session-1");
203+
const [encodedHeader, encodedPayload, encodedSignature] = ledger.jws.split(".");
204+
assert.ok(encodedHeader && encodedPayload && encodedSignature);
205+
const signedPayloadJson = Buffer.from(encodedPayload, "base64url").toString("utf8");
206+
assert.equal(signedPayloadJson, ledger.payload_json);
207+
assert.deepEqual(JSON.parse(ledger.payload_json), {
208+
agentId: identity.id,
209+
sponsorId: "user_alice",
210+
issuer,
211+
subject: "alice",
212+
iat: now,
213+
jti: "idp-session-1",
214+
sponsorBinding: identity.sponsorBinding,
215+
ts: identity.createdAt,
216+
});
217+
assert.equal(
218+
crypto.verify(
219+
"RSA-SHA256",
220+
Buffer.from(`${encodedHeader}.${encodedPayload}`),
221+
TEST_RS256_PUBLIC_KEY_PEM,
222+
Buffer.from(encodedSignature, "base64url"),
223+
),
224+
true,
225+
);
226+
185227
const patchResponse = await app.request(
186228
createTestRequest(
187229
"PATCH",
@@ -338,3 +380,69 @@ test("malformed sponsor federation configuration fails closed", async () => {
338380
const body = await assertJsonResponse<{ code: string }>(response, 503);
339381
assert.equal(body.code, "sponsor_binding_misconfigured");
340382
});
383+
384+
test("OIDC-bound identity creation rolls back when the signed ledger append fails", async (t) => {
385+
const { issuer } = await startOidcFixture(t);
386+
const org = "org_oidc_atomic_ledger";
387+
const app = createTestApp({
388+
RELAYAUTH_SPONSOR_FEDERATIONS: JSON.stringify({
389+
[org]: { sponsorBinding: "oidc", issuer, clientId: "chief-fixture" },
390+
}),
391+
});
392+
const apiKey = await createWorkspaceApiKey(app, org);
393+
const now = Math.floor(Date.now() / 1000);
394+
const proofResponse = await app.request(
395+
createTestRequest(
396+
"POST",
397+
"/v1/sponsors/proof",
398+
{
399+
idToken: signIdToken({
400+
iss: issuer,
401+
sub: "alice",
402+
aud: "chief-fixture",
403+
iat: now,
404+
exp: now + 300,
405+
}),
406+
},
407+
{ "x-api-key": apiKey },
408+
),
409+
undefined,
410+
app.bindings,
411+
);
412+
const proof = await assertJsonResponse<SponsorProof>(proofResponse, 201);
413+
414+
await app.storage.DB.prepare(`
415+
CREATE TRIGGER reject_identity_created_ledger
416+
BEFORE INSERT ON attestation_ledger
417+
WHEN NEW.entry_type = 'identity.created'
418+
BEGIN
419+
SELECT RAISE(ABORT, 'fixture ledger failure');
420+
END
421+
`).run();
422+
423+
const response = await app.request(
424+
createTestRequest(
425+
"POST",
426+
"/v1/identities",
427+
{
428+
name: "must-roll-back",
429+
sponsorId: proof.sponsorId,
430+
sponsorProof: proof.sponsorProof,
431+
},
432+
{ "x-api-key": apiKey },
433+
),
434+
undefined,
435+
app.bindings,
436+
);
437+
const body = await assertJsonResponse<{ code: string }>(response, 500);
438+
assert.equal(body.code, "identity_create_failed");
439+
440+
const identityRow = await app.storage.DB.prepare(
441+
"SELECT id FROM identities WHERE org_id = ? AND name = ?",
442+
).bind(org, "must-roll-back").first<{ id: string }>();
443+
const ledgerRow = await app.storage.DB.prepare(
444+
"SELECT seq FROM attestation_ledger WHERE org_id = ?",
445+
).bind(org).first<{ seq: number }>();
446+
assert.equal(identityRow ?? null, null);
447+
assert.equal(ledgerRow ?? null, null);
448+
});

packages/server/src/lib/sponsor-binding.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { rsaPublicJwkFromPem } from "./jwk.js";
33
import {
44
encodeBytesAsBase64Url,
55
keyIdFromPublicJwk,
6+
signCanonicalRs256,
67
signRs256,
78
} from "./sign-rs256.js";
89
import {
@@ -63,6 +64,17 @@ export type IssuedSponsorProof = {
6364
expiresAt: string;
6465
};
6566

67+
export type IdentityCreatedLedgerPayload = {
68+
agentId: string;
69+
sponsorId: string;
70+
issuer: string;
71+
subject: string;
72+
iat: number;
73+
jti?: string;
74+
sponsorBinding: Extract<SponsorBinding, { mode: "oidc" }>;
75+
ts: string;
76+
};
77+
6678
type JsonWebKeySet = {
6779
keys: JsonWebKey[];
6880
};
@@ -358,6 +370,21 @@ export class SponsorOidcService {
358370
};
359371
}
360372

373+
async signIdentityCreatedLedgerPayload(
374+
env: SponsorBindingEnv,
375+
payload: IdentityCreatedLedgerPayload,
376+
): Promise<string> {
377+
const privateKey = env.RELAYAUTH_SIGNING_KEY_PEM?.trim();
378+
const publicKey = env.RELAYAUTH_SIGNING_KEY_PEM_PUBLIC?.trim();
379+
if (!privateKey || !publicKey) {
380+
throw configurationError("RelayAuth signing keys are required for sponsor binding ledger entries");
381+
}
382+
383+
const publicJwk = await rsaPublicJwkFromPem(publicKey, "");
384+
const kid = await keyIdFromPublicJwk(publicJwk);
385+
return signCanonicalRs256(payload, privateKey, kid);
386+
}
387+
361388
async #resolveJwks(
362389
config: Extract<SponsorFederationConfig, { sponsorBinding: "oidc" }>,
363390
forceRefresh: boolean,

packages/server/src/routes/identities.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { Hono, type Context } from "hono";
1111
import type { AppEnv } from "../env.js";
1212
import { authenticateAndAuthorizeFromContext, authenticateBearerOrApiKey, authorizeClaims, decodeBase64UrlJson } from "../lib/auth.js";
1313
import { emitObserverEvent, now as observerNow } from "../lib/events.js";
14-
import { SponsorBindingError } from "../lib/sponsor-binding.js";
14+
import {
15+
SponsorBindingError,
16+
type IdentityCreatedLedgerPayload,
17+
} from "../lib/sponsor-binding.js";
1518
import {
1619
isStorageCapacityExhausted,
1720
isTransientStorageOverload,
@@ -509,10 +512,43 @@ identities.post("/", async (c) => {
509512
// Identity creation is not guaranteed to be idempotent across storage
510513
// adapters. Never retry a write that may have committed before its
511514
// adapter surfaced an overload error.
512-
createdIdentity = await storage.identities.create(storedIdentity);
515+
if (sponsorBinding.mode === "oidc") {
516+
const ledgerPayload: IdentityCreatedLedgerPayload = {
517+
agentId: storedIdentity.id,
518+
sponsorId,
519+
issuer: sponsorBinding.issuer,
520+
subject: sponsorBinding.subject,
521+
iat: sponsorBinding.iat,
522+
...(sponsorBinding.jti ? { jti: sponsorBinding.jti } : {}),
523+
sponsorBinding,
524+
ts: timestamp,
525+
};
526+
const jws = await c.get("sponsorOidcService").signIdentityCreatedLedgerPayload(
527+
c.env,
528+
ledgerPayload,
529+
);
530+
createdIdentity = await storage.attestations.createIdentityWithLedgerEntry(
531+
storedIdentity,
532+
{
533+
orgId: storedIdentity.orgId,
534+
entryType: "identity.created",
535+
agentId: storedIdentity.id,
536+
sponsorId,
537+
...(sponsorBinding.jti ? { jti: sponsorBinding.jti } : {}),
538+
payload: ledgerPayload,
539+
jws,
540+
createdAt: timestamp,
541+
},
542+
);
543+
} else {
544+
createdIdentity = await storage.identities.create(storedIdentity);
545+
}
513546
} catch (error) {
514547
if (isTransientStorageOverload(error)) {
515-
throw new StorageOverloadedError("identities.create", 1, { cause: error });
548+
const operation = sponsorBinding.mode === "oidc"
549+
? "attestations.create_identity_with_ledger"
550+
: "identities.create";
551+
throw new StorageOverloadedError(operation, 1, { cause: error });
516552
}
517553
throw error;
518554
}

0 commit comments

Comments
 (0)