Skip to content

Commit 528b8d9

Browse files
author
kjgbot
committed
fix: throttle sponsor proof verification
1 parent bcbee95 commit 528b8d9

6 files changed

Lines changed: 173 additions & 17 deletions

File tree

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

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { AddressInfo } from "node:net";
55
import test from "node:test";
66
import type { AgentIdentity, SponsorProof } from "@relayauth/types";
77
import { observerBus, type ObserverEvent } from "../lib/events.js";
8+
import { FixedWindowRateLimiter } from "../lib/rate-limit.js";
89
import {
910
assertJsonResponse,
1011
createTestApp,
@@ -31,8 +32,8 @@ type CreatedIdentity = AgentIdentity & {
3132
sponsorChain: string[];
3233
};
3334

34-
function signIdToken(claims: Record<string, unknown>): string {
35-
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT", kid: OIDC_KID }))
35+
function signIdToken(claims: Record<string, unknown>, kid = OIDC_KID): string {
36+
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT", kid }))
3637
.toString("base64url");
3738
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url");
3839
const signingInput = `${header}.${payload}`;
@@ -41,11 +42,17 @@ function signIdToken(claims: Record<string, unknown>): string {
4142
return `${signingInput}.${signature}`;
4243
}
4344

44-
async function startOidcFixture(t: test.TestContext): Promise<{ issuer: string }> {
45+
async function startOidcFixture(
46+
t: test.TestContext,
47+
cacheControl = "public, max-age=60",
48+
): Promise<{ issuer: string; requestCount(path: string): number }> {
4549
let issuer = "";
50+
const requestCounts = new Map<string, number>();
4651
const server = createServer((request, response) => {
52+
const path = request.url ?? "";
53+
requestCounts.set(path, (requestCounts.get(path) ?? 0) + 1);
4754
response.setHeader("content-type", "application/json");
48-
response.setHeader("cache-control", "public, max-age=60");
55+
response.setHeader("cache-control", cacheControl);
4956
if (request.url === "/.well-known/openid-configuration") {
5057
response.end(JSON.stringify({ issuer, jwks_uri: `${issuer}/jwks` }));
5158
return;
@@ -67,7 +74,10 @@ async function startOidcFixture(t: test.TestContext): Promise<{ issuer: string }
6774
t.after(() => new Promise<void>((resolve, reject) => {
6875
server.close((error) => error ? reject(error) : resolve());
6976
}));
70-
return { issuer };
77+
return {
78+
issuer,
79+
requestCount: (path: string) => requestCounts.get(path) ?? 0,
80+
};
7181
}
7282

7383
function adminAuthorization(org: string): HeadersInit {
@@ -404,6 +414,83 @@ test("sponsor proof requires a valid intent", async (t) => {
404414
}
405415
});
406416

417+
test("sponsor proof is rate limited per organization and API key", async (t) => {
418+
const { issuer } = await startOidcFixture(t);
419+
const org = "org_oidc_proof_rate_limit";
420+
const app = createTestApp(
421+
{
422+
RELAYAUTH_SPONSOR_FEDERATIONS: JSON.stringify({
423+
[org]: { sponsorBinding: "oidc", issuer, clientId: "chief-fixture" },
424+
}),
425+
},
426+
{ identityCreateRateLimiter: new FixedWindowRateLimiter(1, 60_000) },
427+
);
428+
const apiKey = await createWorkspaceApiKey(app, org);
429+
const now = Math.floor(Date.now() / 1000);
430+
const request = () => app.request(
431+
createTestRequest(
432+
"POST",
433+
"/v1/sponsors/proof",
434+
{
435+
idToken: signIdToken({
436+
iss: issuer,
437+
sub: "alice",
438+
aud: "chief-fixture",
439+
iat: now,
440+
exp: now + 300,
441+
}),
442+
intent: "approval",
443+
},
444+
{ "x-api-key": apiKey },
445+
),
446+
undefined,
447+
app.bindings,
448+
);
449+
450+
await assertJsonResponse<SponsorProof>(await request(), 201);
451+
const refused = await request();
452+
const body = await assertJsonResponse<{ code: string }>(refused, 429);
453+
assert.equal(body.code, "rate_limited");
454+
assert.equal(refused.headers.get("retry-after"), "60");
455+
});
456+
457+
test("unknown OIDC kids cannot bypass JWKS cache or forced-refresh cooldown", async (t) => {
458+
const { issuer, requestCount } = await startOidcFixture(t, "public, max-age=0");
459+
const org = "org_oidc_jwks_refresh_limit";
460+
const app = createTestApp({
461+
RELAYAUTH_SPONSOR_FEDERATIONS: JSON.stringify({
462+
[org]: { sponsorBinding: "oidc", issuer, clientId: "chief-fixture" },
463+
}),
464+
});
465+
const apiKey = await createWorkspaceApiKey(app, org);
466+
const now = Math.floor(Date.now() / 1000);
467+
const proofRequest = (kid: string) => app.request(
468+
createTestRequest(
469+
"POST",
470+
"/v1/sponsors/proof",
471+
{
472+
idToken: signIdToken({
473+
iss: issuer,
474+
sub: "alice",
475+
aud: "chief-fixture",
476+
iat: now,
477+
exp: now + 300,
478+
}, kid),
479+
intent: "approval",
480+
},
481+
{ "x-api-key": apiKey },
482+
),
483+
undefined,
484+
app.bindings,
485+
);
486+
487+
await assertJsonResponse<SponsorProof>(await proofRequest(OIDC_KID), 201);
488+
await assertJsonResponse<{ code: string }>(await proofRequest("attacker-kid-1"), 403);
489+
await assertJsonResponse<{ code: string }>(await proofRequest("attacker-kid-2"), 403);
490+
assert.equal(requestCount("/.well-known/openid-configuration"), 1);
491+
assert.equal(requestCount("/jwks"), 2);
492+
});
493+
407494
test("OIDC subject mapping is collision-free for raw and encoded-looking values", async (t) => {
408495
const { issuer } = await startOidcFixture(t);
409496
const org = "org_oidc_subject_encoding";

packages/server/src/__tests__/storage-sqlite.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,35 @@ test("TestSqliteIdentityCRUD", async (t) => {
567567
assert.deepEqual(afterDelete, []);
568568
});
569569

570+
test("TestSqliteIdentityRejectsMalformedOidcSponsorBinding", async (t) => {
571+
const { storage } = createTempStorage(t);
572+
573+
await assert.rejects(
574+
storage.identities.create({
575+
id: "agent_invalid_oidc_binding",
576+
name: "Invalid OIDC Binding",
577+
type: "agent",
578+
orgId: "org_invalid_oidc_binding",
579+
status: "active",
580+
createdAt: "2026-08-08T20:00:00.000Z",
581+
updatedAt: "2026-08-08T20:00:00.000Z",
582+
workspaceId: "ws_invalid_oidc_binding",
583+
sponsorId: "user_invalid",
584+
sponsorChain: ["user_invalid", "agent_invalid_oidc_binding"],
585+
sponsorBinding: {
586+
mode: "oidc",
587+
issuer: "",
588+
subject: "subject",
589+
iat: 1,
590+
},
591+
scopes: [],
592+
roles: [],
593+
metadata: {},
594+
}),
595+
(error: unknown) => error instanceof StorageError && error.code === "invalid_sponsor_binding",
596+
);
597+
});
598+
570599
test("TestSqliteIdentitySuspendRetire", async (t) => {
571600
const { storage } = createTempStorage(t);
572601

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ const MAX_GRANT_TTL_SECONDS = 900;
1919
const DEFAULT_ID_TOKEN_MAX_AGE_SECONDS = 300;
2020
const DEFAULT_CLOCK_SKEW_SECONDS = 60;
2121
const DEFAULT_JWKS_CACHE_SECONDS = 300;
22+
const MIN_JWKS_CACHE_SECONDS = 30;
2223
const MAX_JWKS_CACHE_SECONDS = 3600;
24+
const JWKS_FORCED_REFRESH_COOLDOWN_MS = 30_000;
2325
const FETCH_TIMEOUT_MS = 5_000;
2426
const SPONSOR_ID_PATTERN = /^user_[A-Za-z0-9_-]+$/u;
2527
const SPONSOR_INTENT_PATTERN = /^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$/u;
@@ -145,6 +147,7 @@ export class SponsorBindingError extends Error {
145147
export class SponsorOidcService {
146148
readonly #jwksCache = new Map<string, CachedJwks>();
147149
readonly #discoveryCache = new Map<string, { expiresAt: number; jwksUri: string }>();
150+
readonly #jwksForcedRefreshAt = new Map<string, number>();
148151

149152
resolveConfig(env: SponsorBindingEnv, orgId: string): SponsorFederationConfig {
150153
const raw = env.RELAYAUTH_SPONSOR_FEDERATIONS?.trim();
@@ -197,7 +200,7 @@ export class SponsorOidcService {
197200

198201
let jwks = await this.#resolveJwks(config, false);
199202
let key = selectVerificationKey(jwks, header.kid);
200-
if (!key && !config.jwks) {
203+
if (!key && !config.jwks && this.#canForceRefresh(config.issuer)) {
201204
jwks = await this.#resolveJwks(config, true);
202205
key = selectVerificationKey(jwks, header.kid);
203206
}
@@ -410,7 +413,9 @@ export class SponsorOidcService {
410413
return validateJwks(config.jwks);
411414
}
412415

413-
const jwksUri = config.jwksUri ?? await this.#resolveJwksUri(config, forceRefresh);
416+
// Key rotation refreshes the JWKS itself; the separately cached discovery
417+
// document must not be refetched for every attacker-controlled unknown kid.
418+
const jwksUri = config.jwksUri ?? await this.#resolveJwksUri(config, false);
414419
assertSecureProviderUrl(jwksUri, "jwksUri");
415420
const cached = this.#jwksCache.get(jwksUri);
416421
if (!forceRefresh && cached && cached.expiresAt > Date.now()) {
@@ -426,6 +431,17 @@ export class SponsorOidcService {
426431
return jwks;
427432
}
428433

434+
#canForceRefresh(issuer: string): boolean {
435+
const now = Date.now();
436+
const previous = this.#jwksForcedRefreshAt.get(issuer);
437+
if (previous !== undefined && now - previous < JWKS_FORCED_REFRESH_COOLDOWN_MS) {
438+
return false;
439+
}
440+
// Record the attempt before performing I/O so failures are throttled too.
441+
this.#jwksForcedRefreshAt.set(issuer, now);
442+
return true;
443+
}
444+
429445
async #resolveJwksUri(
430446
config: Extract<SponsorFederationConfig, { sponsorBinding: "oidc" }>,
431447
forceRefresh: boolean,
@@ -614,7 +630,10 @@ function parseCacheSeconds(cacheControl: string | null): number {
614630
if (!match) {
615631
return DEFAULT_JWKS_CACHE_SECONDS;
616632
}
617-
return Math.min(Number(match[1]), MAX_JWKS_CACHE_SECONDS);
633+
return Math.max(
634+
MIN_JWKS_CACHE_SECONDS,
635+
Math.min(Number(match[1]), MAX_JWKS_CACHE_SECONDS),
636+
);
618637
}
619638

620639
function validateJwks(value: unknown): JsonWebKeySet {

packages/server/src/routes/identities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ identities.post("/", async (c) => {
563563
org: createdIdentity.orgId,
564564
name: createdIdentity.name,
565565
sponsorId: createdIdentity.sponsorId,
566-
sponsorBinding: createdIdentity.sponsorBinding ?? { mode: "legacy" },
566+
sponsorBinding,
567567
},
568568
});
569569
return c.json(createdIdentity, 201);

packages/server/src/routes/sponsors.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,25 @@ sponsors.post("/proof", async (c) => {
2626
return c.json({ error: auth.error, code: auth.code }, auth.status);
2727
}
2828

29+
const apiKeyId = typeof auth.claims.meta?.apiKeyId === "string"
30+
? auth.claims.meta.apiKeyId.trim()
31+
: "";
32+
const rateLimit = c.get("identityCreateRateLimiter").consume([
33+
`sponsor-proof:org:${auth.claims.org}`,
34+
...(apiKeyId ? [`sponsor-proof:api-key:${apiKeyId}`] : []),
35+
]);
36+
c.header("RateLimit-Limit", String(rateLimit.limit));
37+
c.header("RateLimit-Remaining", String(rateLimit.remaining));
38+
c.header("RateLimit-Reset", String(rateLimit.retryAfterSeconds));
39+
if (!rateLimit.allowed) {
40+
c.header("Retry-After", String(rateLimit.retryAfterSeconds));
41+
return c.json({
42+
error: "Sponsor proof rate limit exceeded",
43+
code: "rate_limited",
44+
retryable: true,
45+
}, 429);
46+
}
47+
2948
const body = await c.req.json<SponsorProofRequest>().catch(() => null);
3049
if (!body || typeof body !== "object" || Array.isArray(body)) {
3150
return c.json({ error: "Invalid JSON body", code: "invalid_request" }, 400);

packages/server/src/storage/sqlite.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4541,14 +4541,16 @@ function normalizeIdentityStatus(value: unknown): IdentityStatus | undefined {
45414541
}
45424542

45434543
function normalizeSponsorBinding(value: SponsorBinding | undefined): SponsorBinding {
4544-
if (
4545-
value?.mode === "oidc"
4546-
&& typeof value.issuer === "string"
4547-
&& value.issuer.trim()
4548-
&& typeof value.subject === "string"
4549-
&& value.subject.trim()
4550-
&& Number.isInteger(value.iat)
4551-
) {
4544+
if (value?.mode === "oidc") {
4545+
if (
4546+
typeof value.issuer !== "string"
4547+
|| !value.issuer.trim()
4548+
|| typeof value.subject !== "string"
4549+
|| !value.subject.trim()
4550+
|| !Number.isInteger(value.iat)
4551+
) {
4552+
throw new StorageError("invalid sponsor binding", 400, "invalid_sponsor_binding");
4553+
}
45524554
return {
45534555
mode: "oidc",
45544556
issuer: value.issuer.trim(),

0 commit comments

Comments
 (0)