OAuth-connected providers (GitHub confirmed, others likely affected) require users to re-authorize more often than expected — sometimes within days of a fresh authorization. Two separate bugs contribute.
Bug 1 — Redis TTL derived from access-token expiry, not refresh-token lifetime
StateAdapterTokenStore.set() calculates TTL as expiresAt - now + 24h. For GitHub OAuth apps with token expiration enabled, access tokens expire in 8 hours, so stored tokens get a Redis TTL of ~32h. After 32h of inactivity the Redis key is gone — including the still-valid refresh token (GitHub refresh tokens last 6 months). The next request gets tokenSlot.get() → undefined and prompts re-authorization.
// packages/junior/src/chat/credentials/state-adapter-token-store.ts
const BUFFER_MS = 24 * 60 * 60 * 1000; // 24h — too short
const ttlMs = tokens.expiresAt
? Math.max(tokens.expiresAt - Date.now() + BUFFER_MS, BUFFER_MS)
: LONG_LIVED_TTL_MS; // 365d — only used when expiresAt is absent
refresh_token_expires_in (returned by GitHub but not parsed) is never stored or used in TTL calculation.
Fix: when a refreshToken is present, TTL should be based on the refresh token's lifetime, not the access token's. At minimum, use LONG_LIVED_TTL_MS unconditionally, or parse and store refresh_token_expires_in and derive TTL from that.
Bug 2 — No locking on concurrent refresh; failed refresh doesn't re-read the token slot
In issueUserCredential (github plugin), multiple concurrent requests near the 5-minute refresh window all read the same near-expired stored token, then all attempt refreshUserAccessToken in parallel. The first request rotates the GitHub refresh token; all subsequent ones fail. The failure handler returns credentialNeeded(...) directly without re-reading the token slot to check whether another concurrent request already refreshed successfully.
// packages/junior-github/index.js — issueUserCredential
} catch (error) {
if (!(error instanceof GitHubUserRefreshRejectedError)) throw error;
return credentialNeeded("Your GitHub authorization has expired.", scope);
// ↑ should re-read tokenSlot first — another concurrent request may have refreshed
}
Fix: add a per-user+provider distributed lock around the refresh path. After acquiring the lock, re-read the token slot; if another request already refreshed, use the fresh token instead of re-authenticating.
Evidence
- Sentry JUNIOR-40: "OAuth token response missing access_token" — 3 events within 26 seconds on 2026-06-18, same user, all in
sentry__junior-github.mjs parseOAuthTokenResponse. Trace: Redis GET oauth-token:{userId}:github → POST https://github.com/login/oauth/access_token → error.
- Thread evidence: user prompted to authorize GitHub multiple times within a week; separate thread shows stale auth links and repeated "I'll need you to authorize" prompts from the bot within the same session.
Scope
Bug 1 affects any provider whose OAuth app issues short-lived access tokens with a refresh token (confirmed: GitHub). Bug 2 is specific to providers using the plugin credential path (junior-github), but the same pattern risk exists in oauth-bearer-broker.ts for generic OAuth providers.
OAuth-connected providers (GitHub confirmed, others likely affected) require users to re-authorize more often than expected — sometimes within days of a fresh authorization. Two separate bugs contribute.
Bug 1 — Redis TTL derived from access-token expiry, not refresh-token lifetime
StateAdapterTokenStore.set()calculates TTL asexpiresAt - now + 24h. For GitHub OAuth apps with token expiration enabled, access tokens expire in 8 hours, so stored tokens get a Redis TTL of ~32h. After 32h of inactivity the Redis key is gone — including the still-valid refresh token (GitHub refresh tokens last 6 months). The next request getstokenSlot.get() → undefinedand prompts re-authorization.refresh_token_expires_in(returned by GitHub but not parsed) is never stored or used in TTL calculation.Fix: when a
refreshTokenis present, TTL should be based on the refresh token's lifetime, not the access token's. At minimum, useLONG_LIVED_TTL_MSunconditionally, or parse and storerefresh_token_expires_inand derive TTL from that.Bug 2 — No locking on concurrent refresh; failed refresh doesn't re-read the token slot
In
issueUserCredential(github plugin), multiple concurrent requests near the 5-minute refresh window all read the same near-expired stored token, then all attemptrefreshUserAccessTokenin parallel. The first request rotates the GitHub refresh token; all subsequent ones fail. The failure handler returnscredentialNeeded(...)directly without re-reading the token slot to check whether another concurrent request already refreshed successfully.Fix: add a per-user+provider distributed lock around the refresh path. After acquiring the lock, re-read the token slot; if another request already refreshed, use the fresh token instead of re-authenticating.
Evidence
sentry__junior-github.mjs parseOAuthTokenResponse. Trace: Redis GEToauth-token:{userId}:github→ POSThttps://github.com/login/oauth/access_token→ error.Scope
Bug 1 affects any provider whose OAuth app issues short-lived access tokens with a refresh token (confirmed: GitHub). Bug 2 is specific to providers using the plugin credential path (
junior-github), but the same pattern risk exists inoauth-bearer-broker.tsfor generic OAuth providers.