Skip to content

Route revoke through Connection#post_form, restore typed error mapping#29

Open
ahorovit wants to merge 1 commit into
mainfrom
adin/revoke-through-connection
Open

Route revoke through Connection#post_form, restore typed error mapping#29
ahorovit wants to merge 1 commit into
mainfrom
adin/revoke-through-connection

Conversation

@ahorovit

@ahorovit ahorovit commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Route OAuth#revoke_token through @connection and handle_response instead of calling Net::HTTP.post_form directly. Add Connection#post_form to make form-encoded posts a first-class operation on the gem's existing HTTP pipeline. Wire format is identical to what's running on main today — only the in-gem plumbing changes.

Problem

PR #28 fixed the silent-no-op revoke bug by sending application/x-www-form-urlencoded instead of application/json per RFC 7009. The bytes leaving the process are correct, but the implementation reached for Net::HTTP.post_form to enforce that Content-Type and bypassed the gem's Connection class entirely. Two costs:

Two HTTP code paths in one gem. Every other method — get_token, refresh_token, every Resources::* method — goes through @connection (Faraday). Only revoke_token went through stdlib Net::HTTP. Two HTTP libraries, two response-shape contracts, two places to patch if the gem's HTTP behavior ever changes globally.

Lost typed error mapping. The gem's handle_response already maps OAuth 2.0 error codes to typed Ruby exception classes:

OAuth error Exception raised
unauthorized_client ConvertKit::UnauthorizedClientError
invalid_request ConvertKit::InvalidRequestError
access_denied ConvertKit::AccessDeniedError
unsupported_response_type ConvertKit::UnsupportedResponseTypeError
invalid_scope ConvertKit::InvalidScopeError
anything else ConvertKit::OauthError

The Net::HTTP path bypassed all of that and raised a flat ConvertKit::OauthError for every non-2xx, with the OAuth error code only readable as text inside the message string. Callers wanting to react differently to unauthorized_client (prompt user to reconnect) vs invalid_request (transient retry) couldn't.

This isn't theoretical. A staging4 disconnect attempt this week returned unauthorized_client from Kit. Speckel's caller code rescues StandardError today, but the next iteration of that caller will want to distinguish auth-failure from other failures — and right now it can't.

Solution

Connection#post_form

A new public method on Connection that POSTs with application/x-www-form-urlencoded instead of the connection's default application/json:

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

Six lines. The response object is a Faraday response — same shape as every other method on Connection returns — so process_response's JSON-body parsing and HTTP-status error mapping apply unchanged. Downstream handle_response works against it without modification.

OAuth#revoke_token

Drops require 'net/http', drops the inline status-code check, drops the bespoke error message. Matches the shape of get_token and refresh_token:

def revoke_token(token)
  params = {
    client_id: @id,
    client_secret: @secret,
    token: token,
    token_type_hint: 'access_token'
  }

  handle_response(@connection.post_form(REVOKE_PATH, params), true)
  true
end

When Kit returns 2xx with an empty body (the RFC 7009 success path), handle_response returns the raw response and revoke_token returns true. When Kit returns a 4xx with an OAuth error body, handle_response raises the matching typed exception — ConvertKit::UnauthorizedClientError for the unauthorized_client case we've already seen in production.

Wire format

Unchanged from what main produces today:

POST /oauth/revoke HTTP/1.1
Host: app.convertkit.com
Content-Type: application/x-www-form-urlencoded
Content-Length: <n>

token=<token>&client_id=<id>&client_secret=<secret>&token_type_hint=access_token

URI.encode_www_form produces the same byte sequence Net::HTTP.post_form does internally — both are calling into the same URI stdlib code under the covers. If the wire format on main satisfies Kit, this PR's does too.

Design Decisions

Decision Context
Add post_form as a public method instead of an option to existing post The existing post is dynamically defined inside an HTTP_METHODS.each block that doesn't naturally take an options hash. A second public method is less invasive than restructuring the dispatch loop, and post_form is a meaningfully different operation worth its own name.
Keep Net::HTTP out of the gem entirely We were down a dependency surface for one method. Putting revoke_token back on Faraday means future maintainers think about one HTTP library, not two.
Reuse handle_response rather than parse errors inline handle_response is the gem's existing convention for the OAuth endpoint family (get_token, refresh_token already use it). Restoring revoke_token to that pattern means the OAuth class has one error-handling shape, not two.

Blast Radius

Gem-internal change with a public method signature unchanged. ConvertKit::OAuth.new(id, secret).revoke_token(token) works exactly the same way for callers.

Behavior change on failure paths: callers that were catching ConvertKit::OauthError for revoke errors will now catch typed exceptions instead. In practice the only caller is Speckel's ConvertKitService#revoke_token_for_provider, which uses rescue StandardError => error — all the typed exceptions are StandardError subclasses, so existing logic still works. The improvement is opt-in: callers who want to distinguish error types can now do so.

No change to the success path. Same return value (true), same wire format.

Rollback Plan

Revert via GitHub's Revert button. Restores the Net::HTTP.post_form path from PR #28 — a known steady state that's been running since PR #28 merged.

Test plan

Verifiable by agent before merging

  • Full rspec suite under Ruby 2.7.7 (gem's .ruby-version): 141 examples, 0 failures, 99.51% coverage. The new Connection#post_form spec brings the count up from 140.
  • Connection#post_form spec asserts: correct path, body equals URI.encode_www_form(params), header Content-Type: application/x-www-form-urlencoded.
  • OAuth#revoke_token success spec asserts: connection.post_form called with the exact params hash including token_type_hint: 'access_token', returns true.
  • OAuth#revoke_token failure spec asserts: when Kit returns {"error":"unauthorized_client", ...}, the method raises ConvertKit::UnauthorizedClientError with the error_description. Directly motivated by the staging4 production observation.

Cannot be verified before merge

  • Confirm Kit's server actually invalidates the token end-to-end — requires this gem to ship into Speckel, a live disconnect against a real Kit account, and Kit's engineer (or Kit's logs) confirming the revoke landed. The wire format is unchanged from what already ships on main, so if main's wire format is right, this one is too — but only a real round-trip proves Kit's side processes it.

The previous revoke_token implementation bypassed both @connection and
handle_response by calling Net::HTTP.post_form directly. That worked
for the wire format but had two costs:

1. Two HTTP code paths in one gem. get_token / refresh_token / every
   Resource method go through @connection (Faraday). revoke_token went
   through Net::HTTP. Two libraries, two error-handling surfaces, two
   places to patch if the gem ever changes HTTP behavior globally.

2. Lost typed error mapping. handle_response raises
   ConvertKit::UnauthorizedClientError, InvalidRequestError, etc.
   based on the OAuth 2.0 error code in the response body. The
   Net::HTTP path raised a flat ConvertKit::OauthError for every
   non-2xx, with the error code only available as text in the message
   string. Callers wanting to react differently to unauthorized_client
   vs invalid_request can't.

Add Connection#post_form — a small, well-scoped method that POSTs
with application/x-www-form-urlencoded instead of the connection's
default application/json. Then revoke_token routes through
@connection.post_form + handle_response, restoring the typed error
mapping and unifying the HTTP path with the rest of the gem.

OAuth 2.0 actually requires form-encoded for /oauth/token too
(RFC 6749 §3.2); Kit currently tolerates JSON there. If that ever
changes, post_form is reusable without further surgery.
@ahorovit ahorovit self-assigned this Jun 9, 2026
@ahorovit ahorovit marked this pull request as ready for review June 9, 2026 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants