{"record":{"id":"1aba8e48f0c49b4f","repo":"charmbracelet/crush","slug":"provider-s-does-not-have-an-oauth-token","errorCode":null,"errorMessage":"provider %s does not have an OAuth token","messagePattern":"provider (.+?) does not have an OAuth token","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/config/store.go","lineNumber":676,"sourceCode":"func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {\n\tkey := fmt.Sprintf(\"%d\\x00%s\", scope, providerID)\n\t_, err, _ := s.refreshSF.Do(key, func() (any, error) {\n\t\treturn nil, s.refreshOAuthTokenLocked(ctx, scope, providerID)\n\t})\n\treturn err\n}\n\n// refreshOAuthTokenLocked performs the cross-process single-flighted\n// refresh. It is invoked through refreshSF, so at most one goroutine per\n// provider runs it at a time within this process.\nfunc (s *ConfigStore) refreshOAuthTokenLocked(ctx context.Context, scope Scope, providerID string) error {\n\tcfg := s.Config()\n\tproviderConfig, exists := cfg.Providers.Get(providerID)\n\tif !exists {\n\t\treturn fmt.Errorf(\"provider %s not found\", providerID)\n\t}\n\tif providerConfig.OAuthToken == nil {\n\t\treturn fmt.Errorf(\"provider %s does not have an OAuth token\", providerID)\n\t}\n\tentryToken := providerConfig.OAuthToken\n\n\t// Acquire the per-provider cross-process refresh lock. This is a\n\t// dedicated lock file, not the config-write lock, and it does not take\n\t// s.mu — so the network exchange below cannot stall unrelated config\n\t// operations. The deadline exceeds the exchange timeout so that a peer\n\t// mid-exchange has time to publish a token we can adopt. Lock ordering:\n\t// the refresh lock is always taken before the config-write lock (via\n\t// SetConfigFields), never the reverse, so no deadlock is possible.\n\tlockCtx, cancel := context.WithTimeout(ctx, refreshLockDeadline)\n\tdefer cancel()\n\trelease, lockErr := lock.File(lockCtx, s.refreshLockPath(providerID))\n\tif lockErr != nil {\n\t\t// Could not acquire the lock (peer wedged or deadline hit). Prefer a\n\t\t// usable token already on disk over forcing our own exchange, which\n\t\t// would risk reusing a rotated refresh token.\n\t\tif diskToken := s.usableDiskToken(scope, providerID, entryToken); diskToken != nil {","sourceCodeStart":658,"sourceCodeEnd":694,"githubUrl":"https://github.com/charmbracelet/crush/blob/7944b8e52225d8805e31eacbf7ef24856b0dfb7a/internal/config/store.go#L658-L694","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Re-authenticate the provider with OAuth (crush login <provider>) so a token exists","If you intend to use an API key instead, remove the OAuth refresh flow for that provider","Check the token was written to the same scope (project vs global) the runtime reads","Restart to drop stale refresh timers pointing at de-OAuthed providers"],"exampleFix":"// before\n// provider authed with API key only, but refresh still requested:\nerr := refresh(ctx, \"hyper\")\n// after\npc, _ := store.Config().Providers.Get(\"hyper\")\nif pc.OAuthToken == nil {\n    return nil // API-key provider: nothing to refresh; skip\n}\nerr := refresh(ctx, \"hyper\")","handlingStrategy":"type-guard","validationCode":"if pc, ok := store.Config().Providers.Get(providerID); !ok || pc.OAuthToken == nil {\n    return fmt.Errorf(\"provider %q has no OAuth token; use api-key auth or re-run 'crush login'\", providerID)\n}","typeGuard":"func hasOAuthToken(store *config.ConfigStore, id string) bool {\n    pc, ok := store.Config().Providers.Get(id)\n    return ok && pc.OAuthToken != nil\n}","tryCatchPattern":"err := doAuthenticatedRequest(ctx, providerID)\nif err != nil && strings.Contains(err.Error(), \"does not have an OAuth token\") {\n    // non-OAuth provider: fall back to api-key auth path\n    return doAPIKeyRequest(ctx, providerID)\n}","preventionTips":["Check OAuthToken presence before wiring up refresh single-flight for a provider","Drop refresh timers when a provider switches from OAuth to API-key auth","Save tokens and API keys to the same scope the runtime reads","Re-run 'crush login' if a hand-edit removed providers.<id>.oauth_token"],"tags":["oauth","token-refresh","configuration"],"backgroundTag":"oauth-token-missing","analyzedSha":"7944b8e52225d8805e31eacbf7ef24856b0dfb7a","analyzedAt":"2026-08-29T12:48:59.079Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}