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

@ -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);
}