Skip to content

Commit 8a3dd08

Browse files
kjgbotclaude
andcommitted
phase 118: fix P0 refresh rotation + iss/aud/depth validation
- Refresh path now revokes old JTI on successful rotation and detects re-use (cascade-revokes session). Closes single-use violation per specs/token-format.md:361-369. - verifyLegacyToken validates iss, aud, exp/nbf with +/-60s skew per specs/token-format.md:130-133. - Enforce max-10 sponsor-chain depth at issuance and refresh per specs/token-format.md:111-113. - Adversarial tests covering each of the above. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0137d3b commit 8a3dd08

2 files changed

Lines changed: 348 additions & 8 deletions

File tree

packages/server/src/__tests__/tokens-route.test.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,204 @@ test("POST /v1/tokens/refresh", async (t) => {
551551
assert.match(JSON.stringify(body), /revoked/i);
552552
});
553553
});
554+
555+
await t.test("revokes the old refresh JTI after a successful refresh", async () => {
556+
const { app, identity } = await createHarness();
557+
const { pair, accessClaims, refreshClaims } = createLegacyPhase0TokenPair(identity);
558+
await seedActiveTokens(app, identity.id, [accessClaims.jti, refreshClaims.jti]);
559+
560+
assert.deepEqual(await listRevokedTokenIds(app), []);
561+
562+
const response = await requestRoute(app, "POST", "/v1/tokens/refresh", {
563+
body: { refreshToken: pair.refreshToken },
564+
});
565+
await assertJsonResponse<TokenPair>(response, 200);
566+
567+
const revoked = await listRevokedTokenIds(app);
568+
assert.ok(
569+
revoked.includes(refreshClaims.jti),
570+
`old refresh JTI ${refreshClaims.jti} should be in the revocation list but got ${JSON.stringify(revoked)}`,
571+
);
572+
});
573+
574+
await t.test("detects refresh-token re-use and cascade-revokes the session", async () => {
575+
const { app, identity } = await createHarness();
576+
const { pair, accessClaims, refreshClaims } = createLegacyPhase0TokenPair(identity);
577+
await seedActiveTokens(app, identity.id, [accessClaims.jti, refreshClaims.jti]);
578+
579+
const firstResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", {
580+
body: { refreshToken: pair.refreshToken },
581+
});
582+
const firstBody = await assertJsonResponse<TokenPair>(firstResponse, 200);
583+
const secondRefreshClaims = decodeJwtJsonSegment<RelayAuthTokenClaims>(firstBody.refreshToken, 1);
584+
585+
// Replay the original refresh token (single-use violation).
586+
const replayResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", {
587+
body: { refreshToken: pair.refreshToken },
588+
});
589+
await assertJsonResponse<ErrorBody>(replayResponse, 401, (body) => {
590+
assert.match(JSON.stringify(body), /revoked/i);
591+
});
592+
593+
// The newly issued refresh token should ALSO be unusable now because the
594+
// session was cascade-revoked.
595+
const followupResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", {
596+
body: { refreshToken: firstBody.refreshToken },
597+
});
598+
await assertJsonResponse<ErrorBody>(followupResponse, 401);
599+
600+
const revoked = await listRevokedTokenIds(app);
601+
assert.ok(revoked.includes(refreshClaims.jti), "original refresh JTI must be revoked");
602+
assert.ok(
603+
revoked.includes(secondRefreshClaims.jti),
604+
`second refresh JTI ${secondRefreshClaims.jti} must be revoked after re-use detection (got ${JSON.stringify(revoked)})`,
605+
);
606+
});
607+
608+
await t.test("rejects a refresh token signed with the wrong issuer", async () => {
609+
const { app, identity } = await createHarness();
610+
const now = Math.floor(Date.now() / 1000);
611+
const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`;
612+
const jti = `tok_${crypto.randomUUID().replace(/-/g, "")}`;
613+
await seedActiveTokens(app, identity.id, [jti]);
614+
615+
const evilRefresh = signLegacyHs256Jwt({
616+
sub: identity.id,
617+
org: identity.orgId,
618+
wks: identity.workspaceId,
619+
scopes: ["relayauth:token:refresh"],
620+
sponsorId: identity.sponsorId,
621+
sponsorChain: [...identity.sponsorChain],
622+
token_type: "refresh",
623+
iss: "https://evil.example",
624+
aud: ["relayauth"],
625+
exp: now + 3600,
626+
iat: now,
627+
jti,
628+
sid,
629+
});
630+
631+
const response = await requestRoute(app, "POST", "/v1/tokens/refresh", {
632+
body: { refreshToken: evilRefresh },
633+
});
634+
await assertJsonResponse<ErrorBody>(response, 401);
635+
});
636+
637+
await t.test("rejects a refresh token with a non-relayauth audience", async () => {
638+
const { app, identity } = await createHarness();
639+
const now = Math.floor(Date.now() / 1000);
640+
const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`;
641+
const jti = `tok_${crypto.randomUUID().replace(/-/g, "")}`;
642+
await seedActiveTokens(app, identity.id, [jti]);
643+
644+
const wrongAudRefresh = signLegacyHs256Jwt({
645+
sub: identity.id,
646+
org: identity.orgId,
647+
wks: identity.workspaceId,
648+
scopes: ["relayauth:token:refresh"],
649+
sponsorId: identity.sponsorId,
650+
sponsorChain: [...identity.sponsorChain],
651+
token_type: "refresh",
652+
iss: "https://relayauth.dev",
653+
aud: ["not-relayauth"],
654+
exp: now + 3600,
655+
iat: now,
656+
jti,
657+
sid,
658+
});
659+
660+
const response = await requestRoute(app, "POST", "/v1/tokens/refresh", {
661+
body: { refreshToken: wrongAudRefresh },
662+
});
663+
await assertJsonResponse<ErrorBody>(response, 401);
664+
});
665+
666+
await t.test("rejects a refresh token whose exp is beyond clock-skew in the past", async () => {
667+
const { app, identity } = await createHarness();
668+
const past = Math.floor(Date.now() / 1000) - 1000;
669+
const expiredRefresh = signLegacyHs256Jwt({
670+
sub: identity.id,
671+
org: identity.orgId,
672+
wks: identity.workspaceId,
673+
scopes: ["relayauth:token:refresh"],
674+
sponsorId: identity.sponsorId,
675+
sponsorChain: [...identity.sponsorChain],
676+
token_type: "refresh",
677+
iss: "https://relayauth.dev",
678+
aud: ["relayauth"],
679+
exp: past + 60, // exp 120s before "now"
680+
iat: past,
681+
jti: `tok_${crypto.randomUUID().replace(/-/g, "")}`,
682+
});
683+
684+
const response = await requestRoute(app, "POST", "/v1/tokens/refresh", {
685+
body: { refreshToken: expiredRefresh },
686+
});
687+
await assertJsonResponse<ErrorBody>(response, 401, (body) => {
688+
assert.match(JSON.stringify(body), /expired|invalid/i);
689+
});
690+
});
691+
692+
await t.test("accepts a refresh token whose exp is within the 60s clock-skew allowance", async () => {
693+
const { app, identity } = await createHarness();
694+
const now = Math.floor(Date.now() / 1000);
695+
const jti = `tok_${crypto.randomUUID().replace(/-/g, "")}`;
696+
const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`;
697+
await seedActiveTokens(app, identity.id, [jti]);
698+
699+
const skewedRefresh = signLegacyHs256Jwt({
700+
sub: identity.id,
701+
org: identity.orgId,
702+
wks: identity.workspaceId,
703+
scopes: ["relayauth:token:refresh"],
704+
sponsorId: identity.sponsorId,
705+
sponsorChain: [...identity.sponsorChain],
706+
token_type: "refresh",
707+
iss: "https://relayauth.dev",
708+
aud: ["relayauth"],
709+
exp: now - 30, // 30s past exp, should be accepted within skew
710+
iat: now - 120,
711+
jti,
712+
sid,
713+
});
714+
715+
const response = await requestRoute(app, "POST", "/v1/tokens/refresh", {
716+
body: { refreshToken: skewedRefresh },
717+
});
718+
await assertJsonResponse<TokenPair>(response, 200);
719+
});
720+
});
721+
722+
test("POST /v1/tokens enforces max sponsor-chain depth", async (t) => {
723+
await t.test("rejects issuance when identity.sponsorChain exceeds 10", async () => {
724+
const deepChain = Array.from({ length: 11 }, (_, index) =>
725+
index === 10 ? "agent_deep_subject" : `user_ancestor_${index}`,
726+
);
727+
const deepIdentity = createStoredIdentity({
728+
id: "agent_deep_subject",
729+
name: "Deep Subject",
730+
orgId: "org_tokens_route",
731+
workspaceId: "ws_tokens_route",
732+
sponsorId: "user_ancestor_0",
733+
sponsorChain: deepChain,
734+
scopes: ["specialist:invoke"],
735+
});
736+
737+
const { app, authHeaders } = await createHarness({ identity: deepIdentity });
738+
739+
const response = await requestRoute(app, "POST", "/v1/tokens", {
740+
body: {
741+
identityId: deepIdentity.id,
742+
scopes: ["specialist:invoke"],
743+
audience: ["specialist"],
744+
},
745+
headers: authHeaders,
746+
});
747+
748+
await assertJsonResponse<ErrorBody>(response, 400, (body) => {
749+
assert.match(JSON.stringify(body), /delegation|depth|chain/i);
750+
});
751+
});
554752
});
555753

556754
test("POST /v1/tokens/revoke", async (t) => {

0 commit comments

Comments
 (0)