Skip to content

Commit ce0e492

Browse files
authored
Merge pull request #23 from AgentWorkforce/migration/rs256/121-sdk-dual-verify
migration(rs256): phase 121 — SDK TokenVerifier accepts both RS256 + HS256
2 parents 4fc066b + 5ca7d0a commit ce0e492

3 files changed

Lines changed: 385 additions & 20 deletions

File tree

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import assert from "node:assert/strict";
2+
import { afterEach, test } from "node:test";
3+
import type { RelayAuthTokenClaims } from "@relayauth/types";
4+
5+
import { RelayAuthError } from "../errors.js";
6+
import { TokenVerifier } from "../verify.js";
7+
8+
type SigningFixture = {
9+
kid: string;
10+
publicJwk: JsonWebKey;
11+
privateKey: CryptoKey;
12+
};
13+
14+
type Hs256Fixture = {
15+
kid: string;
16+
secret: Uint8Array;
17+
jwk: JsonWebKey;
18+
};
19+
20+
const jwksUrl = "https://relay.example.test/.well-known/jwks.json";
21+
const originalFetch = globalThis.fetch;
22+
const originalAcceptHs256Env = process.env.RELAYAUTH_VERIFIER_ACCEPT_HS256;
23+
24+
afterEach(() => {
25+
globalThis.fetch = originalFetch;
26+
if (originalAcceptHs256Env === undefined) {
27+
delete process.env.RELAYAUTH_VERIFIER_ACCEPT_HS256;
28+
} else {
29+
process.env.RELAYAUTH_VERIFIER_ACCEPT_HS256 = originalAcceptHs256Env;
30+
}
31+
});
32+
33+
function createClaims(
34+
overrides: Partial<RelayAuthTokenClaims> = {},
35+
): RelayAuthTokenClaims {
36+
const nowSeconds = Math.floor(Date.now() / 1000);
37+
38+
return {
39+
sub: "agent_dual_alg",
40+
org: "org_dual_alg",
41+
wks: "ws_dual_alg",
42+
scopes: ["specialist:invoke"],
43+
sponsorId: "user_dual_alg",
44+
sponsorChain: ["user_dual_alg", "agent_dual_alg"],
45+
token_type: "access",
46+
iss: "https://relayauth.dev",
47+
aud: ["specialist"],
48+
exp: nowSeconds + 3600,
49+
iat: nowSeconds - 60,
50+
jti: "tok_dual_alg",
51+
...overrides,
52+
};
53+
}
54+
55+
async function createRs256Fixture(kid: string): Promise<SigningFixture> {
56+
const { privateKey, publicKey } = await crypto.subtle.generateKey(
57+
{
58+
name: "RSASSA-PKCS1-v1_5",
59+
hash: "SHA-256",
60+
modulusLength: 2048,
61+
publicExponent: new Uint8Array([1, 0, 1]),
62+
},
63+
true,
64+
["sign", "verify"],
65+
);
66+
67+
const publicJwk = await crypto.subtle.exportKey("jwk", publicKey) as JsonWebKey & {
68+
alg?: string;
69+
kid?: string;
70+
use?: string;
71+
};
72+
73+
publicJwk.alg = "RS256";
74+
publicJwk.kid = kid;
75+
publicJwk.use = "sig";
76+
77+
return {
78+
kid,
79+
publicJwk,
80+
privateKey,
81+
};
82+
}
83+
84+
function createHs256Fixture(kid: string): Hs256Fixture {
85+
const secret = crypto.getRandomValues(new Uint8Array(32));
86+
87+
return {
88+
kid,
89+
secret,
90+
jwk: {
91+
kty: "oct",
92+
alg: "HS256",
93+
kid,
94+
use: "sig",
95+
k: Buffer.from(secret).toString("base64url"),
96+
},
97+
};
98+
}
99+
100+
function encodeBase64Url(value: string | Buffer | Uint8Array | ArrayBuffer): string {
101+
if (typeof value === "string") {
102+
return Buffer.from(value, "utf8").toString("base64url");
103+
}
104+
105+
if (value instanceof ArrayBuffer) {
106+
return Buffer.from(new Uint8Array(value)).toString("base64url");
107+
}
108+
109+
return Buffer.from(value).toString("base64url");
110+
}
111+
112+
async function signJwt(
113+
header: Record<string, unknown>,
114+
claims: RelayAuthTokenClaims,
115+
key: CryptoKey | Uint8Array,
116+
): Promise<string> {
117+
const encodedHeader = encodeBase64Url(JSON.stringify(header));
118+
const encodedPayload = encodeBase64Url(JSON.stringify(claims));
119+
const signingInput = `${encodedHeader}.${encodedPayload}`;
120+
const data = new TextEncoder().encode(signingInput);
121+
122+
let signature: ArrayBuffer;
123+
switch (header.alg) {
124+
case "RS256":
125+
signature = await crypto.subtle.sign(
126+
"RSASSA-PKCS1-v1_5",
127+
key as CryptoKey,
128+
data,
129+
);
130+
break;
131+
case "HS256": {
132+
const cryptoKey = await crypto.subtle.importKey(
133+
"raw",
134+
key as Uint8Array,
135+
{ name: "HMAC", hash: "SHA-256" },
136+
false,
137+
["sign"],
138+
);
139+
signature = await crypto.subtle.sign("HMAC", cryptoKey, data);
140+
break;
141+
}
142+
case "none":
143+
return `${encodedHeader}.${encodedPayload}.`;
144+
default:
145+
throw new Error(`Unsupported signing algorithm in test fixture: ${String(header.alg)}`);
146+
}
147+
148+
return `${signingInput}.${encodeBase64Url(signature)}`;
149+
}
150+
151+
function mutatePayload(token: string, nextClaims: RelayAuthTokenClaims): string {
152+
const [encodedHeader, , signature] = token.split(".");
153+
return `${encodedHeader}.${encodeBase64Url(JSON.stringify(nextClaims))}.${signature}`;
154+
}
155+
156+
function mockJwks(keys: JsonWebKey[]): void {
157+
globalThis.fetch = (async (input: RequestInfo | URL) => {
158+
assert.equal(String(input), jwksUrl);
159+
return new Response(JSON.stringify({ keys }), {
160+
status: 200,
161+
headers: {
162+
"content-type": "application/json",
163+
},
164+
});
165+
}) as typeof globalThis.fetch;
166+
}
167+
168+
function createVerifier(): TokenVerifier {
169+
return new TokenVerifier({
170+
jwksUrl,
171+
issuer: "https://relayauth.dev",
172+
audience: ["specialist"],
173+
});
174+
}
175+
176+
async function assertRejectsInvalidToken(action: () => Promise<unknown>): Promise<void> {
177+
await assert.rejects(action, (error) => {
178+
assert.ok(error instanceof RelayAuthError);
179+
assert.equal(error.code, "invalid_token");
180+
return true;
181+
});
182+
}
183+
184+
test("verifies an RS256 token when the matching RSA JWK is published", async () => {
185+
const fixture = await createRs256Fixture("kid-rs256-primary");
186+
const claims = createClaims();
187+
const token = await signJwt(
188+
{ alg: "RS256", typ: "JWT", kid: fixture.kid },
189+
claims,
190+
fixture.privateKey,
191+
);
192+
193+
mockJwks([fixture.publicJwk]);
194+
195+
const verified = await createVerifier().verify(token);
196+
197+
assert.deepEqual(verified, claims);
198+
});
199+
200+
test("also verifies an HS256 token during dual-accept when JWKS publishes both RSA and oct keys", async () => {
201+
const rs256Fixture = await createRs256Fixture("kid-rs256-dual-window");
202+
const hs256Fixture = createHs256Fixture("kid-hs256-dual-window");
203+
const claims = createClaims({ jti: "tok_dual_alg_hs256" });
204+
const token = await signJwt(
205+
{ alg: "HS256", typ: "JWT", kid: hs256Fixture.kid },
206+
claims,
207+
hs256Fixture.secret,
208+
);
209+
210+
mockJwks([rs256Fixture.publicJwk, hs256Fixture.jwk]);
211+
212+
const verified = await createVerifier().verify(token);
213+
214+
assert.deepEqual(verified, claims);
215+
});
216+
217+
test("rejects an alg-confusion token when header.alg is HS256 but the matching kid resolves to an RSA JWK", async () => {
218+
const fixture = await createRs256Fixture("kid-confusion");
219+
const claims = createClaims({ jti: "tok_alg_confusion" });
220+
const token = await signJwt(
221+
{ alg: "HS256", typ: "JWT", kid: fixture.kid },
222+
claims,
223+
crypto.getRandomValues(new Uint8Array(32)),
224+
);
225+
226+
mockJwks([fixture.publicJwk]);
227+
228+
await assertRejectsInvalidToken(() => createVerifier().verify(token));
229+
});
230+
231+
test("rejects downgrade attacks that claim alg=none", async () => {
232+
const fixture = await createRs256Fixture("kid-none-downgrade");
233+
const claims = createClaims({ jti: "tok_none_downgrade" });
234+
const token = await signJwt(
235+
{ alg: "none", typ: "JWT", kid: fixture.kid },
236+
claims,
237+
fixture.privateKey,
238+
);
239+
240+
mockJwks([fixture.publicJwk]);
241+
242+
await assertRejectsInvalidToken(() => createVerifier().verify(token));
243+
});
244+
245+
test("rejects RS256 payload tampering after a valid token has been minted", async () => {
246+
const fixture = await createRs256Fixture("kid-rs256-tamper");
247+
const token = await signJwt(
248+
{ alg: "RS256", typ: "JWT", kid: fixture.kid },
249+
createClaims({ jti: "tok_rs256_tamper", scopes: ["specialist:invoke"] }),
250+
fixture.privateKey,
251+
);
252+
253+
mockJwks([fixture.publicJwk]);
254+
await createVerifier().verify(token);
255+
256+
const tamperedToken = mutatePayload(
257+
token,
258+
createClaims({ jti: "tok_rs256_tamper", scopes: ["specialist:admin"] }),
259+
);
260+
261+
await assertRejectsInvalidToken(() => createVerifier().verify(tamperedToken));
262+
});
263+
264+
test("rejects HS256 payload tampering after a valid token has been minted", async () => {
265+
const rs256Fixture = await createRs256Fixture("kid-rs256-unused");
266+
const hs256Fixture = createHs256Fixture("kid-hs256-tamper");
267+
const token = await signJwt(
268+
{ alg: "HS256", typ: "JWT", kid: hs256Fixture.kid },
269+
createClaims({ jti: "tok_hs256_tamper", scopes: ["specialist:invoke"] }),
270+
hs256Fixture.secret,
271+
);
272+
273+
mockJwks([rs256Fixture.publicJwk, hs256Fixture.jwk]);
274+
await createVerifier().verify(token);
275+
276+
const tamperedToken = mutatePayload(
277+
token,
278+
createClaims({ jti: "tok_hs256_tamper", scopes: ["specialist:admin"] }),
279+
);
280+
281+
await assertRejectsInvalidToken(() => createVerifier().verify(tamperedToken));
282+
});
283+
284+
test("rejects tokens whose header kid is not present in JWKS", async () => {
285+
const fixture = await createRs256Fixture("kid-present");
286+
const token = await signJwt(
287+
{ alg: "RS256", typ: "JWT", kid: "kid-missing" },
288+
createClaims({ jti: "tok_missing_kid" }),
289+
fixture.privateKey,
290+
);
291+
292+
mockJwks([fixture.publicJwk]);
293+
294+
await assertRejectsInvalidToken(() => createVerifier().verify(token));
295+
});
296+
297+
test("rejects HS256 tokens after the sunset flag disables legacy acceptance", async () => {
298+
const rs256Fixture = await createRs256Fixture("kid-rs256-sunset");
299+
const hs256Fixture = createHs256Fixture("kid-hs256-sunset");
300+
const claims = createClaims({ jti: "tok_hs256_sunset" });
301+
const token = await signJwt(
302+
{ alg: "HS256", typ: "JWT", kid: hs256Fixture.kid },
303+
claims,
304+
hs256Fixture.secret,
305+
);
306+
307+
mockJwks([rs256Fixture.publicJwk, hs256Fixture.jwk]);
308+
309+
process.env.RELAYAUTH_VERIFIER_ACCEPT_HS256 = "true";
310+
const verifier = createVerifier();
311+
const verified = await verifier.verify(token);
312+
assert.deepEqual(verified, claims);
313+
314+
process.env.RELAYAUTH_VERIFIER_ACCEPT_HS256 = "false";
315+
316+
await assertRejectsInvalidToken(() => createVerifier().verify(token));
317+
});

0 commit comments

Comments
 (0)