{"record":{"id":"40f241cc5b02fa29","repo":"instructure/canvas-lms","slug":"no-refresh-token-available-for-account","errorCode":null,"errorMessage":"No refresh token available for account","messagePattern":"No refresh token available for account","errorType":"exception","errorClass":"LlmConversation::Errors::ConversationError","httpStatus":null,"severity":"error","filePath":"lib/llm_conversation/http_client.rb","lineNumber":66,"sourceCode":"    end\n\n    def post(path, payload: nil)\n      request(:post, path, payload:)\n    end\n\n    def patch(path, payload: nil)\n      request(:patch, path, payload:)\n    end\n\n    def delete(path)\n      request(:delete, path)\n    end\n\n    private\n\n    def refresh_v2_token!\n      refresh_token = @account.settings.dig(:llm_conversation_service, :refresh_jwt_token)\n      raise LlmConversation::Errors::ConversationError, \"No refresh token available for account\" if refresh_token.blank?\n\n      uri = URI(\"#{@base_url}/token/refresh\")\n      http = Net::HTTP.new(uri.host, uri.port)\n      if uri.scheme.casecmp?(\"https\")\n        http.use_ssl = true\n        http.verify_mode = OpenSSL::SSL::VERIFY_PEER\n      end\n\n      req = Net::HTTP::Post.new(uri.request_uri,\n                                \"Content-Type\" => \"application/json\",\n                                \"Authorization\" => \"Bearer #{refresh_token}\",\n                                \"x-account-id\" => @account.uuid)\n\n      response = http.request(req)\n      raise LlmConversation::Errors::ConversationError, \"Token refresh failed\" unless response.is_a?(Net::HTTPSuccess)\n\n      result = JSON.parse(response.body)\n      new_api_token = result[\"api_token\"]","sourceCodeStart":48,"sourceCodeEnd":84,"githubUrl":"https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/lib/llm_conversation/http_client.rb#L48-L84","documentation":"In v2 auth mode, HttpClient refreshes expired API tokens by POSTing to /token/refresh with the refresh JWT stored at account.settings[:llm_conversation_service][:refresh_jwt_token]. refresh_v2_token! raises ConversationError if that stored refresh token is blank, because it cannot authenticate the refresh call.","triggerScenarios":"A v2-auth account (ai_experiences_v2_auth enabled) gets a 401 during request(), triggering refresh_v2_token!, but account.settings[:llm_conversation_service][:refresh_jwt_token] was never set or has been cleared.","commonSituations":"Fresh account that never completed the initial token exchange, so only the API token (or nothing) was stored; settings hash wiped or overwritten by another integration; the refresh token key stored under a different name/shape after a code change; TokenCache invalidated while the account record lacks the refresh token.","solutions":["Seed the account's refresh token: set account.settings[:llm_conversation_service] = { api_jwt_token: ..., refresh_jwt_token: ... } (e.g. by running the initial token exchange) and save the account.","Verify the settings hash shape with account.settings.dig(:llm_conversation_service, :refresh_jwt_token) — fix any key renaming or nesting mismatches.","Re-run whatever bootstrap/provisioning flow issues the first token pair for the account.","Check for code paths that overwrite account.settings[:llm_conversation_service] and preserve refresh_jwt_token."],"exampleFix":"# before (account settings)\naccount.settings[:llm_conversation_service] = { api_jwt_token: \"eyJ...\" }\naccount.save!\n\n# after\naccount.settings[:llm_conversation_service] = {\n  api_jwt_token: \"eyJ...\",\n  refresh_jwt_token: \"eyJ...\" # required for refresh_v2_token!\n}\naccount.save!","handlingStrategy":"validation","validationCode":"refresh_token = account.settings.dig(:llm_conversation_service, :refresh_jwt_token)\nraise 'provision account tokens first' if refresh_token.blank?\nclient = LlmConversation::HttpClient.new(account: account)","typeGuard":"def v2_tokens_provisioned?(account)\n  account.settings.dig(:llm_conversation_service, :refresh_jwt_token).present?\nend","tryCatchPattern":"begin\n  client.get(path)\nrescue LlmConversation::Errors::ConversationError => e\n  if e.message == \"No refresh token available for account\"\n    LlmConversation::TokenService.provision!(account)\n    retry\n  end\n  raise\nend","preventionTips":["Provision both api_jwt_token and refresh_jwt_token when enabling v2 auth on an account","Avoid overwriting account.settings[:llm_conversation_service] wholesale; merge keys instead","Alert on accounts with the v2 flag but missing refresh tokens"],"tags":["ruby","auth","oauth","token-refresh"],"backgroundTag":"missing-credentials","analyzedSha":"1c9f0bb8013ed69c4f2efe11fd483025469b7e6c","analyzedAt":"2026-09-15T20:33:18.891Z","contentChangedAt":"2026-09-15T20:33:18.891Z","schemaVersion":2},"datasetVersion":"2026-09-23T02:17:17.105Z"}