charmbracelet/crush · error

provider %s does not have an OAuth token

Error message

provider %s does not have an OAuth token

What it means

refreshOAuthTokenLocked found the provider but its config has no OAuth token (providerConfig.OAuthToken == nil). Refreshing only applies to OAuth-based providers; API-key providers cannot be refreshed, so the store returns this error instead of attempting an exchange.

Source

Thrown at internal/config/store.go:676

func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
	key := fmt.Sprintf("%d\x00%s", scope, providerID)
	_, err, _ := s.refreshSF.Do(key, func() (any, error) {
		return nil, s.refreshOAuthTokenLocked(ctx, scope, providerID)
	})
	return err
}

// refreshOAuthTokenLocked performs the cross-process single-flighted
// refresh. It is invoked through refreshSF, so at most one goroutine per
// provider runs it at a time within this process.
func (s *ConfigStore) refreshOAuthTokenLocked(ctx context.Context, scope Scope, providerID string) error {
	cfg := s.Config()
	providerConfig, exists := cfg.Providers.Get(providerID)
	if !exists {
		return fmt.Errorf("provider %s not found", providerID)
	}
	if providerConfig.OAuthToken == nil {
		return fmt.Errorf("provider %s does not have an OAuth token", providerID)
	}
	entryToken := providerConfig.OAuthToken

	// Acquire the per-provider cross-process refresh lock. This is a
	// dedicated lock file, not the config-write lock, and it does not take
	// s.mu — so the network exchange below cannot stall unrelated config
	// operations. The deadline exceeds the exchange timeout so that a peer
	// mid-exchange has time to publish a token we can adopt. Lock ordering:
	// the refresh lock is always taken before the config-write lock (via
	// SetConfigFields), never the reverse, so no deadlock is possible.
	lockCtx, cancel := context.WithTimeout(ctx, refreshLockDeadline)
	defer cancel()
	release, lockErr := lock.File(lockCtx, s.refreshLockPath(providerID))
	if lockErr != nil {
		// Could not acquire the lock (peer wedged or deadline hit). Prefer a
		// usable token already on disk over forcing our own exchange, which
		// would risk reusing a rotated refresh token.
		if diskToken := s.usableDiskToken(scope, providerID, entryToken); diskToken != nil {

View on GitHub (pinned to 7944b8e522)

Solutions

  1. Re-authenticate the provider with OAuth (crush login <provider>) so a token exists
  2. If you intend to use an API key instead, remove the OAuth refresh flow for that provider
  3. Check the token was written to the same scope (project vs global) the runtime reads
  4. Restart to drop stale refresh timers pointing at de-OAuthed providers

Example fix

// before
// provider authed with API key only, but refresh still requested:
err := refresh(ctx, "hyper")
// after
pc, _ := store.Config().Providers.Get("hyper")
if pc.OAuthToken == nil {
    return nil // API-key provider: nothing to refresh; skip
}
err := refresh(ctx, "hyper")
Defensive patterns

Strategy: type-guard

Validate before calling

if pc, ok := store.Config().Providers.Get(providerID); !ok || pc.OAuthToken == nil {
    return fmt.Errorf("provider %q has no OAuth token; use api-key auth or re-run 'crush login'", providerID)
}

Type guard

func hasOAuthToken(store *config.ConfigStore, id string) bool {
    pc, ok := store.Config().Providers.Get(id)
    return ok && pc.OAuthToken != nil
}

Try / catch

err := doAuthenticatedRequest(ctx, providerID)
if err != nil && strings.Contains(err.Error(), "does not have an OAuth token") {
    // non-OAuth provider: fall back to api-key auth path
    return doAPIKeyRequest(ctx, providerID)
}

Prevention

When it happens

Trigger: A refresh is scheduled/triggered for a provider that was authenticated with a plain API key (api_key set, no OAuth token), or the token field was removed from the config (hand edit, migration, or partial write) while refresh bookkeeping still references the provider.

Common situations: Switching a provider from OAuth login to a static API key while a refresh timer is still alive; manually editing the config to strip providers.<id>.oauth_token; a scope mismatch where the token was saved to a different config scope than the one being refreshed.

Related errors


AI-assisted analysis of charmbracelet/crush@7944b8e522 (2026-08-29). Data as JSON: /api/errors/1aba8e48f0c49b4f. Report an issue: GitHub.