From 62ca62bf4d9219c61d2791811b03d1d6448a1ae6 Mon Sep 17 00:00:00 2001 From: Adin Horovitz Date: Mon, 8 Jun 2026 18:07:36 -0700 Subject: [PATCH 1/3] Send /oauth/revoke as form-encoded per RFC 7009 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kit's /oauth/revoke is a standard RFC 7009 endpoint hosted at https://api.kit.com/v4/oauth/revoke and requires application/x-www-form-urlencoded. The gem's Connection class POSTs application/json, which the endpoint cannot parse; per RFC 7009 §2.2 it returns 200 to unparseable requests, so the previous implementation logged successes that never actually revoked tokens on Kit's side. Route revoke_token directly through Net::HTTP.post_form so the Content-Type is enforced by the standard library. token_type_hint is included per the RFC §2.1 recommendation. Non-2xx responses now raise ConvertKit::OauthError with the status and body so caller-side logging sees real failures. API docs: https://developers.kit.com/api-reference/oauth-token-revocation --- lib/convertkit/oauth.rb | 25 +++++++++++++++----- spec/lib/convertkit/oauth_spec.rb | 38 +++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/lib/convertkit/oauth.rb b/lib/convertkit/oauth.rb index 3317ebe..9053c00 100644 --- a/lib/convertkit/oauth.rb +++ b/lib/convertkit/oauth.rb @@ -1,3 +1,5 @@ +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 @@ -5,7 +7,9 @@ module ConvertKit class OAuth URL = 'https://app.convertkit.com/'.freeze TOKEN_PATH = 'oauth/token'.freeze - REVOKE_PATH = 'oauth/revoke'.freeze + # RFC 7009 token revocation endpoint. Lives under api.kit.com/v4 (not the legacy app.convertkit.com host) + # and requires application/x-www-form-urlencoded — see https://developers.kit.com/api-reference/oauth-token-revocation + REVOKE_URL = 'https://api.kit.com/v4/oauth/revoke'.freeze def initialize(client_id, client_secret, options = {}) @id = client_id @@ -41,15 +45,24 @@ def refresh_token(option = {}) AccessTokenResponse.new(response) end + # POSTs a form-encoded RFC 7009 revoke request directly 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. def revoke_token(token) - params = { + response = Net::HTTP.post_form( + URI(REVOKE_URL), + token: token, 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 - response = handle_response(@connection.post(REVOKE_PATH, params), true) - response.success? + true end private diff --git a/spec/lib/convertkit/oauth_spec.rb b/spec/lib/convertkit/oauth_spec.rb index fd00ce9..52e4be7 100644 --- a/spec/lib/convertkit/oauth_spec.rb +++ b/spec/lib/convertkit/oauth_spec.rb @@ -130,22 +130,42 @@ 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_path) { 'oauth/revoke' } + let(:revoke_uri) { URI('https://api.kit.com/v4/oauth/revoke') } let(:token) { 'random_token' } + let(:net_response) { double('net_response') } before do allow(ConvertKit::Connection).to receive(:new).with(url).and_return(connection) - allow(response).to receive(:success?).and_return(true) end - it 'returns success response' do - expect(connection).to receive(:post).with(revoke_path, { - client_id: client_id, - client_secret: client_secret, - token: token, - }).and_return(response) + context 'when Kit returns 2xx' do + before do + allow(net_response).to receive(:code).and_return('200') + end + + it 'POSTs form-encoded params to api.kit.com/v4/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) + + expect(oauth.revoke_token(token)).to eq(true) + end + end - expect(oauth.revoke_token(token)).to eq(true) + 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 + + it 'raises ConvertKit::OauthError including the HTTP status and body' do + expect { oauth.revoke_token(token) }.to raise_error(ConvertKit::OauthError, /401.*unauthorized/) + end end end From 8326f88887ec45d32b4587f509e534a66780a7fb Mon Sep 17 00:00:00 2001 From: Adin Horovitz Date: Mon, 8 Jun 2026 21:10:19 -0700 Subject: [PATCH 2/3] Keep revoke on legacy host for OAuth flow consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kit's docs name api.kit.com/v4/oauth/revoke as the canonical endpoint, but get_token and refresh_token continue to live at app.convertkit.com, and Speckel's authorize URL also points at app.convertkit.com. Splitting revoke onto the new host alone would introduce host asymmetry within a single OAuth flow with no functional benefit — the form-encoded Content-Type fix is what unblocks the bug, not the host change. Stay on app.convertkit.com for now. When Kit deprecates the legacy host we can migrate authorize, token, and revoke together in a coordinated change with proper end-to-end verification. --- lib/convertkit/oauth.rb | 8 +++++--- spec/lib/convertkit/oauth_spec.rb | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/convertkit/oauth.rb b/lib/convertkit/oauth.rb index 9053c00..1b02667 100644 --- a/lib/convertkit/oauth.rb +++ b/lib/convertkit/oauth.rb @@ -7,9 +7,11 @@ module ConvertKit class OAuth URL = 'https://app.convertkit.com/'.freeze TOKEN_PATH = 'oauth/token'.freeze - # RFC 7009 token revocation endpoint. Lives under api.kit.com/v4 (not the legacy app.convertkit.com host) - # and requires application/x-www-form-urlencoded — see https://developers.kit.com/api-reference/oauth-token-revocation - REVOKE_URL = 'https://api.kit.com/v4/oauth/revoke'.freeze + # RFC 7009 token revocation endpoint. Requires application/x-www-form-urlencoded — see + # https://developers.kit.com/api-reference/oauth-token-revocation. Kit's docs name api.kit.com/v4 + # as the canonical host; we stay on app.convertkit.com so the OAuth flow is host-consistent + # across authorize / token / revoke. Migrate when Kit deprecates the legacy host. + REVOKE_URL = 'https://app.convertkit.com/oauth/revoke'.freeze def initialize(client_id, client_secret, options = {}) @id = client_id diff --git a/spec/lib/convertkit/oauth_spec.rb b/spec/lib/convertkit/oauth_spec.rb index 52e4be7..6a02a4d 100644 --- a/spec/lib/convertkit/oauth_spec.rb +++ b/spec/lib/convertkit/oauth_spec.rb @@ -130,7 +130,7 @@ 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://api.kit.com/v4/oauth/revoke') } + let(:revoke_uri) { URI('https://app.convertkit.com/oauth/revoke') } let(:token) { 'random_token' } let(:net_response) { double('net_response') } @@ -143,7 +143,7 @@ allow(net_response).to receive(:code).and_return('200') end - it 'POSTs form-encoded params to api.kit.com/v4/oauth/revoke and returns true' do + 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, From b77cdaae1c32a60f43dc12799a2621725ff69891 Mon Sep 17 00:00:00 2001 From: Adin Horovitz Date: Mon, 8 Jun 2026 21:20:51 -0700 Subject: [PATCH 3/3] Restore REVOKE_PATH; compose URL inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier introduction of REVOKE_URL was over-engineering — the gem already has a URL constant for the host. Restore REVOKE_PATH to match the original constant name and compose the revoke URI inline via URI.join(URL, REVOKE_PATH). Shrinks the diff against main to just the require, the method body, and the comment. --- lib/convertkit/oauth.rb | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/lib/convertkit/oauth.rb b/lib/convertkit/oauth.rb index 1b02667..2c00d1a 100644 --- a/lib/convertkit/oauth.rb +++ b/lib/convertkit/oauth.rb @@ -7,11 +7,7 @@ module ConvertKit class OAuth URL = 'https://app.convertkit.com/'.freeze TOKEN_PATH = 'oauth/token'.freeze - # RFC 7009 token revocation endpoint. Requires application/x-www-form-urlencoded — see - # https://developers.kit.com/api-reference/oauth-token-revocation. Kit's docs name api.kit.com/v4 - # as the canonical host; we stay on app.convertkit.com so the OAuth flow is host-consistent - # across authorize / token / revoke. Migrate when Kit deprecates the legacy host. - REVOKE_URL = 'https://app.convertkit.com/oauth/revoke'.freeze + REVOKE_PATH = 'oauth/revoke'.freeze def initialize(client_id, client_secret, options = {}) @id = client_id @@ -47,13 +43,13 @@ def refresh_token(option = {}) AccessTokenResponse.new(response) end - # POSTs a form-encoded RFC 7009 revoke request directly 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. + # 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. def revoke_token(token) response = Net::HTTP.post_form( - URI(REVOKE_URL), + URI.join(URL, REVOKE_PATH), token: token, client_id: @id, client_secret: @secret,