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:
parent
c20fce9ba2
commit
deca2de604
2 changed files with 38 additions and 1 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js";
|
||||
import { getEmaIdpLoginState, normalizeIdpIssuer } from "./ema/idpSession.js";
|
||||
import { idpOAuthStorageKey } from "./ema/storage.js";
|
||||
import { isJwtExpired, jwtExpiresAtMs } from "./ema/jwt.js";
|
||||
import { isJwtExpired, isJwtFormat, jwtExpiresAtMs } from "./ema/jwt.js";
|
||||
import type { OAuthStorage } from "./storage.js";
|
||||
import type {
|
||||
AuthProtocol,
|
||||
|
|
@ -49,11 +49,19 @@ export function isAccessTokenUsable(tokens: OAuthTokens | undefined): boolean {
|
|||
* cannot outvote a resource server that has explicitly said the token is
|
||||
* expired. Use this — not `isAccessTokenUsable` — to decide whether a
|
||||
* `token_expired` challenge is already satisfied.
|
||||
*
|
||||
* The shape is checked with {@link isJwtFormat} before the `exp` is trusted.
|
||||
* {@link jwtExpiresAtMs} is deliberately lenient — it parses the second of any
|
||||
* two-or-more dot-separated segments — so a structured opaque token that merely
|
||||
* happens to carry a decodable `{"exp":…}` segment would otherwise be read as
|
||||
* proof, which is exactly the "guess beats the server" failure this predicate
|
||||
* exists to remove.
|
||||
*/
|
||||
export function isAccessTokenProvablyUnexpired(
|
||||
tokens: OAuthTokens | undefined,
|
||||
): boolean {
|
||||
if (!tokens?.access_token) return false;
|
||||
if (!isJwtFormat(tokens.access_token)) return false;
|
||||
if (jwtExpiresAtMs(tokens.access_token) === undefined) return false;
|
||||
return !isJwtExpired(tokens.access_token);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue