Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/convertkit/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 6 additions & 14 deletions lib/convertkit/oauth.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
27 changes: 27 additions & 0 deletions spec/lib/convertkit/connection_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"}') }
Expand Down
43 changes: 20 additions & 23 deletions spec/lib/convertkit/oauth_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading