Skip to content

OAuth re-auth too frequent: short TTL and refresh race #634

Description

@dcramer

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions