diff --git a/lib/convertkit/connection.rb b/lib/convertkit/connection.rb index 78cfba2..9c78096 100644 --- a/lib/convertkit/connection.rb +++ b/lib/convertkit/connection.rb @@ -32,6 +32,17 @@ def initialize(url, options = {}) end end + # POSTs application/x-www-form-urlencoded params, overriding the connection's default + # JSON Content-Type for this single request. Required for OAuth 2.0 endpoints whose RFCs + # (e.g., RFC 7009 token revocation) mandate form-encoded bodies. + def post_form(path, params) + response = @connection.post(path) do |request| + request.headers['Content-Type'] = 'application/x-www-form-urlencoded' + request.body = URI.encode_www_form(params) + end + process_response(response) + end + private def default_headers(options) diff --git a/lib/convertkit/oauth.rb b/lib/convertkit/oauth.rb index 2c00d1a..e9058c9 100644 --- a/lib/convertkit/oauth.rb +++ b/lib/convertkit/oauth.rb @@ -1,5 +1,3 @@ -require 'net/http' - module ConvertKit # Use this class to get an access token or refresh token using the OAuth2 flow. The client id and client secret # should be obtained from ConvertKit and is required for this flow. The code should be obtained when initially connect @@ -43,23 +41,17 @@ def refresh_token(option = {}) AccessTokenResponse.new(response) end - # POSTs a form-encoded RFC 7009 revoke request via Net::HTTP. The gem's Connection class - # sends application/json, which Kit's /oauth/revoke cannot parse; per RFC 7009 the endpoint - # returns 200 even for unparseable requests, so any Content-Type other than form-encoded - # would silently no-op without actually revoking the token. + # RFC 7009 requires application/x-www-form-urlencoded — see + # https://developers.kit.com/api-reference/oauth-token-revocation def revoke_token(token) - response = Net::HTTP.post_form( - URI.join(URL, REVOKE_PATH), - token: token, + params = { client_id: @id, client_secret: @secret, + token: token, token_type_hint: 'access_token' - ) - - unless response.code.to_i.between?(200, 299) - raise ConvertKit::OauthError, "Kit /oauth/revoke returned HTTP #{response.code}: #{response.body}" - end + } + handle_response(@connection.post_form(REVOKE_PATH, params), true) true end diff --git a/spec/lib/convertkit/connection_spec.rb b/spec/lib/convertkit/connection_spec.rb index 2744425..e63078e 100644 --- a/spec/lib/convertkit/connection_spec.rb +++ b/spec/lib/convertkit/connection_spec.rb @@ -44,6 +44,33 @@ end end + describe '#post_form' do + let!(:faraday_conn) { double('faraday_connection') } + let!(:request_headers) { {} } + let!(:request) { double('request', headers: request_headers) } + let!(:env) { double('Env', body: '') } + let!(:response) { double('response', env: env, status: 200) } + + before do + allow(Faraday).to receive(:new).and_return(faraday_conn) + allow(response).to receive(:body).and_return(response.env.body) + end + + it 'posts a form-encoded body with the right Content-Type' do + received_body = nil + expect(faraday_conn).to receive(:post).with('oauth/revoke') do |&block| + allow(request).to receive(:body=) { |b| received_body = b } + block.call(request) + response + end + + ConvertKit::Connection.new(url).post_form('oauth/revoke', { token: 'abc', client_id: 'foo' }) + + expect(request_headers['Content-Type']).to eq('application/x-www-form-urlencoded') + expect(received_body).to eq('token=abc&client_id=foo') + end + end + describe '#delete' do let!(:builder) { double('builder') } let!(:env) { double('Env', body: '{"message":"response_hash"}') } diff --git a/spec/lib/convertkit/oauth_spec.rb b/spec/lib/convertkit/oauth_spec.rb index 6a02a4d..9eb4186 100644 --- a/spec/lib/convertkit/oauth_spec.rb +++ b/spec/lib/convertkit/oauth_spec.rb @@ -130,41 +130,38 @@ describe '#revoke_token' do let(:oauth) { ConvertKit::OAuth.new(client_id, client_secret, redirect_uri: redirect_uri, code: code, refresh_token: refresh_token) } - let(:revoke_uri) { URI('https://app.convertkit.com/oauth/revoke') } + let(:revoke_path) { 'oauth/revoke' } let(:token) { 'random_token' } - let(:net_response) { double('net_response') } + let(:revoke_params) do + { + client_id: client_id, + client_secret: client_secret, + token: token, + token_type_hint: 'access_token' + } + end before do allow(ConvertKit::Connection).to receive(:new).with(url).and_return(connection) end - context 'when Kit returns 2xx' do - before do - allow(net_response).to receive(:code).and_return('200') - end - - it 'POSTs form-encoded params to app.convertkit.com/oauth/revoke and returns true' do - expect(Net::HTTP).to receive(:post_form).with( - revoke_uri, - token: token, - client_id: client_id, - client_secret: client_secret, - token_type_hint: 'access_token' - ).and_return(net_response) + context 'when Kit returns success' do + it 'posts form-encoded params via the connection and returns true' do + expect(connection).to receive(:post_form).with(revoke_path, revoke_params).and_return(response) + allow(response).to receive(:success?).and_return(true) expect(oauth.revoke_token(token)).to eq(true) end end - context 'when Kit returns a non-2xx response' do - before do - allow(net_response).to receive(:code).and_return('401') - allow(net_response).to receive(:body).and_return('{"error":"unauthorized"}') - allow(Net::HTTP).to receive(:post_form).and_return(net_response) - end + context 'when Kit rejects the client (unauthorized_client)' do + it 'raises ConvertKit::UnauthorizedClientError with the error description' do + error_response = { 'error' => 'unauthorized_client', 'error_description' => 'You are not authorized to revoke this token' } + expect(connection).to receive(:post_form).with(revoke_path, revoke_params).and_return(response) + allow(response).to receive(:success?).and_return(false) + allow(response).to receive(:body).and_return(error_response) - it 'raises ConvertKit::OauthError including the HTTP status and body' do - expect { oauth.revoke_token(token) }.to raise_error(ConvertKit::OauthError, /401.*unauthorized/) + expect { oauth.revoke_token(token) }.to raise_error(ConvertKit::UnauthorizedClientError, error_response['error_description']) end end end