# frozen_string_literal: true require "webmock/rspec" RSpec.describe Dispatch::Adapter::Claude::OAuth do let(:tmpdir) { Dir.mktmpdir("oauth_test") } let(:store_path) { File.join(tmpdir, "claude_oauth.json") } let(:token_store) { Dispatch::Adapter::Claude::TokenStore.new(path: store_path) } after { FileUtils.rm_rf(tmpdir) } describe ".build_authorize_url" do it "builds a valid authorization URL with required parameters" do url = described_class.build_authorize_url( state: "test_state", redirect_uri: "http://127.0.0.1:54545/callback", code_challenge: "challenge_abc" ) parsed = URI.parse(url) params = URI.decode_www_form(parsed.query).to_h expect(parsed.scheme).to eq("https") expect(parsed.host).to eq("claude.ai") expect(parsed.path).to eq("/oauth/authorize") expect(params["client_id"]).to eq(Dispatch::Adapter::Claude::OAuth::CLIENT_ID) expect(params["response_type"]).to eq("code") expect(params["scope"]).to eq(Dispatch::Adapter::Claude::OAuth::SCOPES) expect(params["state"]).to eq("test_state") expect(params["redirect_uri"]).to eq("http://127.0.0.1:54545/callback") expect(params["code_challenge"]).to eq("challenge_abc") expect(params["code_challenge_method"]).to eq("S256") end end describe ".split_code_fragment (via login flow)" do it "uses fragment as state override when non-empty" do stub = stub_request(:post, "https://api.anthropic.com/v1/oauth/token") .with do |req| body = JSON.parse(req.body) body["code"] == "auth_code" && body["state"] == "state-override" end .to_return( status: 200, body: JSON.generate({ "access_token" => "sk-ant-oat01-test", "refresh_token" => "rt-test", "expires_in" => 3600 }), headers: { "Content-Type" => "application/json" } ) # Simulate the callback server returning code with fragment allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:await_code).and_return(["auth_code#state-override", "original_state"]) allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:start) allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:stop) allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:callback_url).and_return("http://127.0.0.1:54545/callback") allow(described_class).to receive(:open_browser) described_class.login(token_store: token_store) expect(stub).to have_been_requested end it "keeps explicit state when fragment is empty" do allow(SecureRandom).to receive(:hex).and_call_original allow(SecureRandom).to receive(:hex).with(16).and_return("original_state") stub = stub_request(:post, "https://api.anthropic.com/v1/oauth/token") .with do |req| body = JSON.parse(req.body) body["code"] == "auth_code" && body["state"] == "original_state" end .to_return( status: 200, body: JSON.generate({ "access_token" => "sk-ant-oat01-test", "refresh_token" => "rt-test", "expires_in" => 3600 }), headers: { "Content-Type" => "application/json" } ) allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:await_code).and_return(["auth_code#", "original_state"]) allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:start) allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:stop) allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:callback_url).and_return("http://127.0.0.1:54545/callback") allow(described_class).to receive(:open_browser) described_class.login(token_store: token_store) expect(stub).to have_been_requested end end describe ".login" do before do stub_request(:post, "https://api.anthropic.com/v1/oauth/token") .to_return( status: 200, body: JSON.generate({ "access_token" => "sk-ant-oat01-success", "refresh_token" => "rt-success", "expires_in" => 3600, "email" => "user@example.com" }), headers: { "Content-Type" => "application/json" } ) allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:await_code).and_return(%w[auth_code_123 state_xyz]) allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:start) allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:stop) allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer) .to receive(:callback_url).and_return("http://127.0.0.1:54545/callback") allow(described_class).to receive(:open_browser) end it "persists credentials in the token store on success" do described_class.login(token_store: token_store) creds = token_store.load expect(creds).not_to be_nil expect(creds["access_token"]).to eq("sk-ant-oat01-success") expect(creds["refresh_token"]).to eq("rt-success") expect(creds["email"]).to eq("user@example.com") end it "stores expires_at_ms as current time + expires_in*1000 - 300_000" do before_ms = (Time.now.to_f * 1000).to_i described_class.login(token_store: token_store) after_ms = (Time.now.to_f * 1000).to_i creds = token_store.load expected_low = before_ms + (3600 * 1000) - 300_000 expected_high = after_ms + (3600 * 1000) - 300_000 expect(creds["expires_at_ms"]).to be_between(expected_low, expected_high) end it "raises AuthenticationError when token endpoint returns 401" do stub_request(:post, "https://api.anthropic.com/v1/oauth/token") .to_return( status: 401, body: JSON.generate({ "error" => { "message" => "Invalid code" } }), headers: { "Content-Type" => "application/json" } ) expect { described_class.login(token_store: token_store) } .to raise_error(Dispatch::Adapter::AuthenticationError) end end describe ".refresh" do it "raises AuthenticationError when no stored credentials exist" do expect { described_class.refresh(token_store: token_store) } .to raise_error(Dispatch::Adapter::AuthenticationError, /No stored credentials/) end it "raises AuthenticationError when stored credentials have no refresh_token" do token_store.save({ "access_token" => "tok", "refresh_token" => nil }) expect { described_class.refresh(token_store: token_store) } .to raise_error(Dispatch::Adapter::AuthenticationError, /No refresh_token/) end it "refreshes and persists updated credentials" do token_store.save({ "access_token" => "old-token", "refresh_token" => "rt-old", "expires_at_ms" => 0, "account_id" => "acct-1", "email" => "user@example.com" }) stub_request(:post, "https://api.anthropic.com/v1/oauth/token") .with do |req| body = JSON.parse(req.body) body["grant_type"] == "refresh_token" && body["refresh_token"] == "rt-old" end .to_return( status: 200, body: JSON.generate({ "access_token" => "new-access-token", "refresh_token" => "rt-new", "expires_in" => 7200 }), headers: { "Content-Type" => "application/json" } ) described_class.refresh(token_store: token_store) creds = token_store.load expect(creds["access_token"]).to eq("new-access-token") expect(creds["refresh_token"]).to eq("rt-new") expect(creds["email"]).to eq("user@example.com") end end describe ".refresh!" do it "returns a credentials hash with new access_token" do stub_request(:post, "https://api.anthropic.com/v1/oauth/token") .with do |req| body = JSON.parse(req.body) body["grant_type"] == "refresh_token" && body["refresh_token"] == "old-rt" end .to_return( status: 200, body: JSON.generate({ "access_token" => "new-access-token", "refresh_token" => "new-rt", "expires_in" => 3600 }), headers: { "Content-Type" => "application/json" } ) result = described_class.refresh!("old-rt") expect(result["access_token"]).to eq("new-access-token") expect(result["refresh_token"]).to eq("new-rt") expect(result["expires_at_ms"]).to be_a(Integer) end it "keeps the old refresh_token when the response omits it" do stub_request(:post, "https://api.anthropic.com/v1/oauth/token") .to_return( status: 200, body: JSON.generate({ "access_token" => "new-access-token", "expires_in" => 3600 }), headers: { "Content-Type" => "application/json" } ) result = described_class.refresh!("original-rt") expect(result["refresh_token"]).to eq("original-rt") end it "sets expires_at_ms to now + expires_in*1000 - EXPIRY_BUFFER_MS" do stub_request(:post, "https://api.anthropic.com/v1/oauth/token") .to_return( status: 200, body: JSON.generate({ "access_token" => "tok", "expires_in" => 3600 }), headers: { "Content-Type" => "application/json" } ) before_ms = (Time.now.to_f * 1000).to_i result = described_class.refresh!("rt") after_ms = (Time.now.to_f * 1000).to_i buffer = Dispatch::Adapter::Claude::OAuth::EXPIRY_BUFFER_MS expected_low = before_ms + (3600 * 1000) - buffer expected_high = after_ms + (3600 * 1000) - buffer expect(result["expires_at_ms"]).to be_between(expected_low, expected_high) end it "raises AuthenticationError on 401" do stub_request(:post, "https://api.anthropic.com/v1/oauth/token") .to_return( status: 401, body: JSON.generate({ "error" => { "message" => "Bad token" } }), headers: { "Content-Type" => "application/json" } ) expect { described_class.refresh!("bad-rt") } .to raise_error(Dispatch::Adapter::AuthenticationError, /Refresh failed/) end it "raises AuthenticationError on 400" do stub_request(:post, "https://api.anthropic.com/v1/oauth/token") .to_return( status: 400, body: JSON.generate({ "error" => { "message" => "Invalid grant" } }), headers: { "Content-Type" => "application/json" } ) expect { described_class.refresh!("expired-rt") } .to raise_error(Dispatch::Adapter::AuthenticationError) end end end