# frozen_string_literal: true require_relative "oauth/callback_server" module Dispatch module Adapter class Claude < Base module OAuth CLIENT_ID = Base64.decode64("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl") AUTHORIZE_URL = "https://claude.ai/oauth/authorize" TOKEN_URL = "https://api.anthropic.com/v1/oauth/token" # Full Claude Code scope set (matches recent Claude Code releases as # noted in oh-my-pi's CHANGELOG). Anthropic silently drops scopes # that don't apply to a given account type (e.g. `org:create_api_key` # is dropped for Pro/Max accounts), so this set is safe for all users # and produces a token that is indistinguishable from a real Claude # Code session in the user's authorized-apps list. SCOPES = "org:create_api_key user:profile user:inference " \ "user:sessions:claude_code user:mcp_servers user:file_upload" CALLBACK_PORT = 54_545 CALLBACK_PATH = "/callback" EXPIRY_BUFFER_MS = 5 * 60 * 1000 module_function # Perform the full PKCE OAuth login flow. # Opens a browser (best effort), waits for the callback, exchanges the # code for tokens, and persists them via the given token_store. # # @param token_store [TokenStore] # @param port [Integer] local callback port (default 54545) # @param timeout [Integer] seconds to wait for the browser callback # @return [Hash] the persisted credential hash def login(token_store:, port: CALLBACK_PORT, timeout: 300) pkce = PKCE.generate state = SecureRandom.hex(16) server = CallbackServer.new(port: port, timeout: timeout) server.start redirect_uri = server.callback_url auth_url = build_authorize_url( state: state, redirect_uri: redirect_uri, code_challenge: pkce[:challenge] ) warn "\n=== Anthropic Claude OAuth Login ===" warn "Opening browser for authentication..." warn "If your browser did not open, paste this URL into it:" warn auth_url warn "" open_browser(auth_url) raw_code, _returned_state = server.await_code server.stop # Split code on '#' — if the fragment is non-empty it overrides state # for the token exchange (mirrors oh-my-pi behaviour). exchange_code, exchange_state = split_code_fragment(raw_code, state) creds = exchange_code_for_tokens( code: exchange_code, state: exchange_state, redirect_uri: redirect_uri, code_verifier: pkce[:verifier] ) token_store.save(creds) creds end # Refresh an existing OAuth access token using the refresh token. # # @param token_store [TokenStore] # @return [Hash] the updated credential hash def refresh(token_store:) creds = token_store.load raise AuthenticationError.new("No stored credentials to refresh.", provider: ClaudeErrors::PROVIDER) unless creds refresh_token = creds["refresh_token"] raise AuthenticationError.new("No refresh_token in stored credentials.", provider: ClaudeErrors::PROVIDER) unless refresh_token body = JSON.generate({ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refresh_token }) data = post_token(body) updated = build_creds_hash(data, creds) token_store.save(updated) updated end # Build the authorise URL for the PKCE flow. def build_authorize_url(state:, redirect_uri:, code_challenge:) params = URI.encode_www_form( code: "true", client_id: CLIENT_ID, response_type: "code", redirect_uri: redirect_uri, scope: SCOPES, code_challenge: code_challenge, code_challenge_method: "S256", state: state ) "#{AUTHORIZE_URL}?#{params}" end # Exchange a refresh token for a new access token. # The refresh token is rotated only if the response includes a new one. # # @param refresh_token [String] the current refresh token # @return [Hash] new credentials hash (access_token, refresh_token, expires_at_ms) def refresh!(refresh_token) body = JSON.generate( grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refresh_token ) uri = URI(TOKEN_URL) req = Net::HTTP::Post.new( uri, "Content-Type" => "application/json", "Accept" => "application/json" ) req.body = body resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) } unless resp.is_a?(Net::HTTPSuccess) raise AuthenticationError.new( "Refresh failed: #{resp.body}", status_code: resp.code.to_i, provider: ClaudeErrors::PROVIDER ) end data = JSON.parse(resp.body) { "access_token" => data["access_token"], "refresh_token" => data["refresh_token"] || refresh_token, "expires_at_ms" => (Time.now.to_f * 1000).to_i + (data["expires_in"].to_i * 1000) - EXPIRY_BUFFER_MS } end # Split `raw_code` on '#'. If the fragment part is non-empty it # overrides the state for the token exchange. def split_code_fragment(raw_code, state) code_part, fragment = raw_code.split("#", 2) exchange_state = fragment && !fragment.empty? ? fragment : state [code_part, exchange_state] end # POST the authorisation code to the token endpoint. def exchange_code_for_tokens(code:, state:, redirect_uri:, code_verifier:) body = JSON.generate({ grant_type: "authorization_code", client_id: CLIENT_ID, code: code, state: state, redirect_uri: redirect_uri, code_verifier: code_verifier }) data = post_token(body) build_creds_hash(data) end # HTTP POST to the token endpoint; returns the parsed JSON body. def post_token(json_body) uri = URI(TOKEN_URL) req = Net::HTTP::Post.new(uri) req["Content-Type"] = "application/json" req["Accept"] = "application/json" req.body = json_body http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true http.open_timeout = 30 http.read_timeout = 30 response = http.start { |h| h.request(req) } ClaudeErrors.handle_response!(response) JSON.parse(response.body) rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout, SocketError => e raise ConnectionError.new("OAuth token exchange failed: #{e.message}", provider: ClaudeErrors::PROVIDER) end # Build the credential hash to persist. def build_creds_hash(data, existing = {}) expires_in_ms = (data["expires_in"].to_i * 1000) - 300_000 { "access_token" => data["access_token"], "refresh_token" => data["refresh_token"] || existing["refresh_token"], "expires_at_ms" => (Time.now.to_f * 1000).to_i + expires_in_ms, "account_id" => data["account_id"] || existing["account_id"], "email" => data["email"] || existing["email"] } end # Best-effort browser opener — silent on failure. def open_browser(url) case RUBY_PLATFORM when /linux/i system("xdg-open", url, out: File::NULL, err: File::NULL) when /darwin/i system("open", url, out: File::NULL, err: File::NULL) when /mswin|mingw|cygwin/i system("start", url, out: File::NULL, err: File::NULL) end rescue StandardError # Ignore all errors — the URL is already printed to stderr end end end end end