fix(auth): don't answer token_expired from an unknowable local expiry

`checkAuthChallengeSatisfied` answered a `token_expired` challenge with
`isAccessTokenUsable`, which treats "we cannot tell" as "still valid":
`isJwtExpired` returns false whenever the token is not a JWT or carries no
`exp`, and storage never records an absolute expiry (`expires_in` is relative
to an issuance instant that is never persisted).

So for an opaque access token — Doorkeeper, Keycloak in opaque mode,
GitHub-style `gho_…` — the local guess outvoted the resource server's
explicit verdict. Callers short-circuited re-authorization and reconnected
with the same dead credential, looping with no redirect and no ReAuthBanner.
A bare 401 also maps to `token_expired`, so this was not limited to servers
sending `error="invalid_token"`.

Adds `isAccessTokenProvablyUnexpired` beside `isAccessTokenUsable` in
`core/auth/connection-state.ts`: true only for a JWT whose `exp` is still in
the future (same 60s skew). `checkAuthChallengeSatisfied` uses it for the
`token_expired` branch, so absence of local evidence no longer beats the
server, while a provably live JWT keeps its cheap short-circuit.

`isAccessTokenUsable` stays optimistic — it is the right default for
displaying authorization state, and flipping it would show every
opaque-token server as unauthorized.

Closes #2051

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 00:59:02 -04:00
commit d41e4e1dd7

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 } from "./ema/jwt.js";
import { isJwtExpired, jwtExpiresAtMs } from "./ema/jwt.js";
import type { OAuthStorage } from "./storage.js";
import type {
AuthProtocol,
@ -24,12 +24,40 @@ export interface BuildOAuthConnectionStateParams {
flowState?: OAuthFlowState;
}
/** True when persisted tokens include a non-expired access token (JWT exp when parseable). */
/**
* True when persisted tokens include a non-expired access token (JWT exp when parseable).
*
* Optimistic: an access token whose expiry cannot be determined locally — an
* opaque token, or a JWT with no `exp` — counts as usable. Storage keeps no
* absolute expiry for those, because `expires_in` is relative to an issuance
* instant that is never recorded. That is the right default for *displaying*
* authorization state (an opaque-token server would otherwise always read as
* unauthorized), and the wrong one once a resource server has actually rejected
* the credential — see {@link isAccessTokenProvablyUnexpired}.
*/
export function isAccessTokenUsable(tokens: OAuthTokens | undefined): boolean {
if (!tokens?.access_token) return false;
return !isJwtExpired(tokens.access_token);
}
/**
* Pessimistic counterpart to {@link isAccessTokenUsable}: true only when the
* access token is a JWT carrying an `exp` that is still in the future (same 60s
* skew as {@link isJwtExpired}).
*
* Absence of local expiry evidence returns `false` rather than `true`, so it
* 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.
*/
export function isAccessTokenProvablyUnexpired(
tokens: OAuthTokens | undefined,
): boolean {
if (!tokens?.access_token) return false;
if (jwtExpiresAtMs(tokens.access_token) === undefined) return false;
return !isJwtExpired(tokens.access_token);
}
function resolveClient(
preregistered: OAuthClientInformation | undefined,
dynamic: OAuthClientInformation | undefined,