fix(auth): require JWT shape before trusting exp as proof of validity

Copilot review on #2105: jwtExpiresAtMs is lenient — it parses the second of
any two-or-more dot-separated segments — so a structured opaque token such as
`opaque.<base64url({"exp":...})>` returned a parseable exp and could still
short-circuit re-authorization, recreating the replay loop.

isAccessTokenProvablyUnexpired now checks isJwtFormat first. Tests cover a
two-segment and a four-segment token whose payload decodes to a future exp.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 07:46:10 -04:00
commit deca2de604

View file

@ -319,6 +319,35 @@ describe("isAccessTokenProvablyUnexpired", () => {
).toBe(false);
});
it("is false for a two-segment token whose payload decodes to an exp", () => {
// jwtExpiresAtMs only requires two dot-separated segments, so a structured
// opaque token carrying a decodable `{"exp":...}` would otherwise read as
// proof of validity. isJwtFormat is what rejects it.
const payload = btoa(JSON.stringify({ exp: nowSec() + 3600 }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
expect(
isAccessTokenProvablyUnexpired({
access_token: `opaque.${payload}`,
token_type: "Bearer",
}),
).toBe(false);
});
it("is false for a four-segment token carrying a decodable exp", () => {
const payload = btoa(JSON.stringify({ exp: nowSec() + 3600 }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
expect(
isAccessTokenProvablyUnexpired({
access_token: `header.${payload}.sig.extra`,
token_type: "Bearer",
}),
).toBe(false);
});
it("is false when there is no access token", () => {
expect(isAccessTokenProvablyUnexpired(undefined)).toBe(false);
expect(