different-ai/openwork · error · EnterpriseMcpOAuthContractError

MCP_OAUTH_CREDENTIAL_EXPIRED

MCP_OAUTH_CREDENTIAL_EXPIRED

Error message

The OAuth access token has expired and no refresh token is available.

What it means

Thrown from tokens when the persisted access token's expiresAt is at or past now (minus the expiration skew window) and the record has no refresh_token. Without a refresh token there is no way to renew silently, so the library invalidates the stored credential and forces a full re-authorization. Note: an expired token WITH a refresh token does not throw — the flow refreshes instead.

Source

Thrown at packages/enterprise-mcp-client/src/oauth-provider.ts:392

  async tokens(context?: OAuthClientInformationContext): Promise<StoredOAuthTokens | undefined> {
    const record = await this.persistence.credentials.load(this.context())
    if (!record) {
      this.loadedCredential = undefined
      return undefined
    }
    const tokens = this.storedTokens(record.tokens, context)
    if (!record.revision.trim()) {
      throw new EnterpriseMcpOAuthContractError(
        "MCP_OAUTH_PERSISTENCE_INVALID",
        "The OAuth credential is missing its persistence revision.",
      )
    }
    if (record.expiresAt !== undefined) {
      assertFiniteEpoch(record.expiresAt, "token expiration")
      if (record.expiresAt <= this.clock.now() + this.expirationSkewMs && !tokens.refresh_token) {
        await this.persistence.credentials.invalidate({ context: this.context(), reason: "expired" })
        throw new EnterpriseMcpOAuthContractError(
          "MCP_OAUTH_CREDENTIAL_EXPIRED",
          "The OAuth access token has expired and no refresh token is available.",
        )
      }
    }
    this.loadedCredential = { ...record, tokens }
    return tokens
  }

  async saveTokens(tokens: StoredOAuthTokens, context?: OAuthClientInformationContext): Promise<void> {
    const validated = this.storedTokens(tokens, context)
    const source = this.authorizationHandle ? "authorization-code" : "refresh"
    const existing = source === "refresh"
      ? (this.loadedCredential ?? await this.persistence.credentials.load(this.context()))
      : undefined
    const merged = source === "refresh" && !validated.refresh_token && existing?.tokens.refresh_token
      ? { ...validated, refresh_token: existing.tokens.refresh_token }
      : validated

View on GitHub (pinned to 2b7df46e8a)

Solutions

  1. Start a new connection/authorization flow to obtain fresh tokens.
  2. Ensure the authorization flow requests the offline_access/refresh_token scope so a refresh token is stored.
  3. Check server config for refresh token rotation or revocation that leaves the stored token unrefreshable; verify system clock is not skewed.

Example fix

// before
scope: "read"
// after — request a refresh token
scope: "read offline_access"
Defensive patterns

Strategy: try-catch

Validate before calling

const record = await persistence.credentials.load(context)
const expiredNoRefresh = record?.expiresAt !== undefined
  && record.expiresAt <= Date.now()
  && !record.tokens.refresh_token

Try / catch

try { tokens = await provider.tokens() }
catch (e) {
  if (e instanceof EnterpriseMcpOAuthContractError && e.code === "MCP_OAUTH_CREDENTIAL_EXPIRED") {
    return promptReauthorization() // silent renewal impossible
  }
  throw e
}

Prevention

When it happens

Trigger: tokens() is called for a stored record where expiresAt <= clock.now() + expirationSkewMs and tokens.refresh_token is undefined.

Common situations: Authorization server that issues short-lived access tokens without refresh tokens (or refresh revoked/one-time-use consumed); long-lived app session outliving a short token; clock skew between client and server trimming the usable window.

Related errors


AI-assisted analysis of different-ai/openwork@2b7df46e8a (2026-09-01). Data as JSON: /api/errors/f6915b4e6add9606. Report an issue: GitHub.