Send /oauth/revoke as form-encoded per RFC 7009#28
Merged
Conversation
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
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.
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.
gmartinimighty
approved these changes
Jun 9, 2026
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rewrite
ConvertKit::OAuth#revoke_tokento POSTapplication/x-www-form-urlencodedinstead ofapplication/json. Same host as the rest of the OAuth flow (app.convertkit.com), same method signature for callers — only the Content-Type and a few RFC-7009-shaped semantics around it change. The previous JSON-bodied implementation had been silently no-op'ing every revoke for over a year.Problem
Kit's
/oauth/revokeis a standard RFC 7009 endpoint. Two clauses in the spec combine to make the previous code path silently broken:application/x-www-form-urlencoded. The gem'sConnectionclass sendsContent-Type: application/jsonand a JSON body. Kit's parser cannot read what we send.So every JSON-bodied revoke from the gem got HTTP 200 back from Kit's edge with no token actually invalidated.
revoke_tokenreturnedresponse.success?(which wastrue), Speckel's caller loggedKit /oauth/revoke succeeded, and Kit's database stayed untouched.Kit engineer's confirmation:
Kit API doc: https://developers.kit.com/api-reference/oauth-token-revocation
Production Evidence
OpenSearch logs, 2026-06-08 23:04–23:07 UTC, network 12165494 — four full disconnect cycles. Each cycle emitted the gem's own success log lines:
Kit's engineer confirmed they observed zero parseable revoke requests across the same window. The tokens remained valid server-side.
The post-merge state for the same scenario: identical pre-call log, identical success log when Kit returns 200, but Kit's records reflect actual revocation. Failure modes (non-2xx from Kit) flip from invisible to a
warn-level log entry with the HTTP status and body attached.Solution
The revoke call goes through Ruby's stdlib
Net::HTTP.post_formdirectly. That single method enforces both the URL-encoded body and theContent-Type: application/x-www-form-urlencodedheader — there's no way for a future maintainer to silently regress to JSON without seeing both pieces change.token_type_hint: 'access_token'is included in the body per RFC 7009 §2.1's recommendation.Wire-level result:
Error semantics tighten. Non-2xx now raises
ConvertKit::OauthErrorwith"Kit /oauth/revoke returned HTTP <code>: <body>". The only caller in production — Speckel'sConvertKitService#revoke_token_for_provider— already wraps inrescue StandardErrorand emits awarn-level structured log. Its behavior on a real failure changes from silently logging success to logging a warn with HTTP status and body. The success path is unchanged:revoke_tokenreturnstrueand the caller logssucceededas before.get_tokenandrefresh_tokenare untouched. They still use the gem'sConnectionclass againstapp.convertkit.com/oauth/token— the connect flow works there today, and this PR doesn't disturb it.Design Decisions
app.convertkit.com/oauth/revokerather than move to the canonicalapi.kit.com/v4/oauth/revokeapi.kit.com/v4/oauth/*, but the gem and Speckel currently useapp.convertkit.comfor authorize and token. Moving revoke alone would split a single OAuth flow across two hosts. The Content-Type fix is what unblocks the actual bug; the host name is cosmetic in comparison. When Kit deprecates the legacy host, all three OAuth endpoints will migrate together in a coordinated change with proper end-to-end verification.Net::HTTP.post_formdirectly, not a patchedConnectionContent-Typeheader in a single call. PatchingConnectionto accept a per-request Content-Type override would add coupling for one caller's benefit, and stdlib here exactly mirrors the snippet Kit's engineer recommended — useful when a future Kit-side debugger reads our wire traffic.response.success?return was the precise mechanism that hid this bug for over a year — an unparseable-request 200 looked identical to a real-revoke 200. Failures should be loud. The only caller already hasrescue StandardErrorand structured-warn logging, so this immediately surfaces real failures in our existing log infrastructure with no caller-side change.Blast Radius
Pure gem-internal change. Method signature is unchanged:
ConvertKit::OAuth.new(id, secret).revoke_token(token)works exactly as before. The only production caller (ConvertKitService#revoke_token_for_providerin Speckel) already wraps the call inrescue StandardError, so failures land in existing log infrastructure with no caller-side change required.What to watch after deploy:
mighty-loggerlines labelledoauth,kitwithmessage: "Kit /oauth/revoke failed". Pre-merge: never observed (the silent-200 path masked all real failures). Post-merge: any actual rejection from Kit (wrong client_id, expired credentials) becomes visible atwarnlevel with the HTTP status and body attached.Rollback Plan
Revert this PR via GitHub's Revert button. Returns to the silent-no-op state that's been running for over a year — a known steady state, not the state we want, but stable.
Test plan
Verifiable by agent before merging
.ruby-version): 140 examples, 0 failures, 99.51% coverage.main(Purchases#create,Subscribers#bulk_create); neither file is touched by this PR.oauth_spec.rbitself: 16/16 pass on both Rubies.https://app.convertkit.com/oauth/revoke), exact form parameters (token,client_id,client_secret,token_type_hint=access_token),truereturn on 2xx, andConvertKit::OauthErrorraise with HTTP status and body on non-2xx.Cannot be verified before merge