Skip to content

Revoke a token from Kotlin, unsigned, following no redirect - #270

Merged
jeremy merged 7 commits into
mainfrom
oncall/oauth-3
Oct 21, 2026
Merged

jeremy merged 7 commits into
mainfrom
oncall/oauth-3

Conversation

@jeremy

@jeremy jeremy commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Signing out of HEY ends the session behind a token with DELETE /oauth/tokens. The Kotlin client had no way to send it safely: written by hand through the client, the revocation goes out under /oauth/tokens.json, refreshes and resends on a 401, and follows a redirect with the token in its body.

What changes (Kotlin)

HeyClient.revokeToken(clientId, token):

  • sends client_id and token in a form body under the bare path, never in the query, where logs would keep the token;
  • is unsigned: the client's own credentials may be the very token being revoked, so a 401 (for example invalid_client for another app's token) is the answer, not a reason to refresh them and send the revocation again;
  • follows no redirect (new Operation.noRedirects()): a DELETE keeps its body on a 307/308, so a hop would hand the token to whatever host the Location names. Any 3xx is an api_error carrying its status, with no hop. unsigned() alone still follows, as the storage reads it was made for want;
  • treats HEY's 200 {} for a token it no longer knows as no error;
  • is heard by the hooks as OAuth.RevokeToken, a write.

/oauth routes sit outside the API model (drift-check-reverse skips them), and the Go, Rust and TypeScript token exchanges are hand-written for the same reason. So this is a hand-written convenience, not a generated operation.

Red first

The first commit runs the tests against the revocation a caller would write by hand (request(DELETE, "/oauth/tokens").form(...)). Three of the five fail:

  • the path goes out as /oauth/tokens.json;
  • a 401 is refreshed and sent again (it hit the mock's "no more responses" 500);
  • a 307 is followed.

The two that pass (200 {} is no error; the hooks' name) pin what the fix must keep.

Verified

RevocationTest 5/5, make kt-check kt-check-drift, Kotlin conformance 219/219. Nothing outside kotlin/ changes. The repo-wide make check failures main already has (cargo deny's webpki-root-certs advisory; locally, Swift 6.3 warnings-as-errors) are untouched by this. Merges cleanly with #268 and #269.

Not done

  • No conformance case. No other SDK revokes a token. The runners read only top-level cases, and only TypeScript can mark one not applicable, so a case would need a RevokeToken in four SDKs this change doesn't touch.
  • Revocation for Go, Rust, TypeScript and Swift: none has one today. That's a follow-up if wanted.

Summary by cubic

Adds a revokeToken(clientId, token) extension on HeyClient so signing out of HEY revokes the session token over DELETE /oauth/tokens, a route hand-written like the /oauth exchanges in the other SDKs.

  • Sends client_id and token in a form body under the bare path, never in the query where logs would keep the token.
  • Sends unsigned, with none of a caller's own AuthStrategy headers or account filter: a 401 is the answer, not a reason to refresh credentials and resend the revocation.
  • Follows no redirect (new Operation.noRedirects()): a DELETE keeps its body on a 307/308, so a hop would hand the token to the redirect target. Any 3xx is taken as the answer without its body read, so a body lost mid-read cannot turn into a retried revocation; it is heard by the hooks as its status and surfaced as a HeyException.Api.
  • Treats HEY's 200 {} for a token it no longer knows as success and emits the hooks event OAuth.RevokeToken as a write; the README example shows the import a caller needs.

Written for commit 2dd3b3a. Summary will update on new commits.

View guided diff Turn on auto-fix

@jeremy

jeremy commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

Behaviour review of the whole diff: no material findings. Checked: unsigned() skips signing entirely (neither BearerAuth nor a caller's strategy touches the request) and skips the account scope; the redirect refusal is a non-retryable HeyException that the attempt loop neither rewraps nor resends, and fires before any hop; a 3xx with no usable Location maps to the same api_error; the token never reaches hooks, logs or toString; the form encodes + / = as Rack decodes them; HEY's revocation answers (200 {} for an unknown token, 401 invalid_client for another app's) match the tests. Each test fails if its guard (unsigned, noRedirects, withoutJsonSuffix) is removed.

Added from the review: cases holding a revocation to none of a caller's strategy headers and no account filter on a scoped client.

Not changing:

  • revokeToken is a top-level extension on HeyClient, where other calls are members or services. It keeps the OAuth endpoint, which sits outside the API model, out of the client core, the way the token exchange is its own module in the other SDKs. Callers importing com.basecamp.hey.* get it with the rest. Moving it to a member later is free under the library's no-binary-compat policy if that reads better.
  • The redirect refusal's hooks report status 0 and no request id. That is how the existing too-many-redirects refusal reports too; the caller's exception carries the 3xx status.

@jeremy
jeremy marked this pull request as ready for review October 21, 2026 08:18
Copilot AI balanced review requested due to automatic review settings October 21, 2026 08:18
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review 🔄 Running since 2026-10-21T11:09:59.830901Z 2dd3b3a New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Rejected redirects report status 0 to request hooks instead of the received HTTP status.

1 open issue
What changed in this PR

Adds safe token revocation to the Kotlin SDK’s sign-out flow.

Changes:

  • Sends revocations unsigned, with credentials in the form body.
  • Rejects redirects without forwarding the token.
  • Adds documentation and tests for revocation safety and operation hooks.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
kotlin/​sdk/​src/​commonTest/​kotlin/​com/​basecamp/​hey/​RevocationTest.kt Tests revocation behavior and safeguards.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​hey/​Revocation.kt Adds the token-revocation convenience.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​hey/​Operation.kt Adds internal redirect suppression.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​hey/​HeyClient.kt Rejects redirects when disabled.
kotlin/​README.md Documents token revocation.

Review effort: Balanced

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0e1c957ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread kotlin/README.md Resolved
…rect (red)

The tests run against the revocation a caller would write by hand: a
DELETE /oauth/tokens with the form body, through the client as any other
request. Three of the five fail:

- the path goes out as /oauth/tokens.json, which HEY does not route;
- a 401 is refreshed and the revocation sent again, with the credentials
  the caller set out to end;
- a 307 is followed, and the token in the body goes to the Location's
  host.

The two that pass (a 200 {} is no error; the hooks hear OAuth.RevokeToken)
pin what the fix must keep.
HeyClient.revokeToken(clientId, token) sends DELETE /oauth/tokens with
client_id and token in a form body under the bare path, as a sign-out
asks:

- never in the query, where logs would keep the token;
- unsigned: the client's own credentials may be the token being
  revoked, so a 401 (invalid_client for another app's token) is the
  answer, not a reason to refresh them and send the revocation again;
- following no redirect: a DELETE keeps its body on a 307, so a hop
  would hand the token to the Location's host. Operation.noRedirects()
  takes any 3xx as the answer, which maps to an api_error, with no hop.
  unsigned() alone keeps following, as the storage reads it serves want.

HEY answers 200 {} for a token it no longer knows; that is no error.
The hooks hear it as OAuth.RevokeToken, a write.

/oauth routes sit outside the API model (drift-check-reverse skips
them), as the Go, Rust and TypeScript token exchanges do, so this is
hand-written beside them rather than generated.

Verified: RevocationTest 5/5 (three red on the hand-written revocation
in the previous commit), make kt-check kt-check-drift, Kotlin
conformance 219/219.

Not done: a conformance case. No other SDK revokes a token, the runners
read only top-level cases, and only TypeScript can mark one not
applicable, so a case would need a RevokeToken in four SDKs this change
does not touch.
The check now sits in transmit's redirect branch rather than on the line
that picks the next hop: the caller's api_error carries the 3xx's
status, and the line other changes to transmit touch stays as it was.
Review: the code already keeps a caller's own AuthStrategy off an
unsigned request and leaves a scoped client's account filter off it;
two cases now say so, so a later change that strips Authorization
rather than skipping the signing, or scopes the path, fails here.
Review: refusing the hop by throwing from transmit sent the 3xx through
the attempt loop's failure path, so the request hooks heard status 0,
which means no response arrived, and the request id was lost. A request
that follows no redirect now takes the 3xx as its answer, and the error
mapping refuses it as for any status it does not accept: the caller
still gets an api_error carrying the status, and the hooks hear 307.
Shown red on the throw: the hooks heard [0], not [307].

revokeToken is a top-level extension, so the README's example now
carries the import a caller needs.
@jeremy

jeremy commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

Review threads: 1 resolved (0 fixed, 1 declined with the reasoning in the thread).
Declined: #270 (comment) — /oauth/tokens is outside the Smithy model by design (drift-check-reverse excludes ^/oauth); other SDKs' OAuth calls are hand-written too.

Outside review: under noRedirects a 3xx was still read for its body. A
body lost mid-read became a network failure, which the retry loop
answered by sending the revocation again (a DELETE is idempotent), and
the hooks heard status 0 for both sends. A 3xx a request follows no
redirect for now carries nothing the SDK reads, as a form's captured
redirect does: its status and headers are kept, its body is not read,
and the error mapping refuses it as an api_error with that status.

Shown red first: a 307 whose body breaks mid-read surfaced a network
error; now it is api_error 307, sent once, and the hooks hear [307].
@jeremy

jeremy commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

Outside review round: under noRedirects a 3xx was still read for its body, so a body lost mid-read became a network failure. The retry loop answered that by revoking again (a DELETE is idempotent), and the hooks heard 0. A refused redirect's body is now not read at all: one send, api_error with the 3xx status, and the hooks hear the status. Red first: the broken-body 307 surfaced network.

Brings in #268 and #269, which change HeyClient.kt's redirect and proof-refusal handling beside #270's revocation. Git merged them cleanly; make kt-check passes on the result.

* origin/main:
  Refresh a redirected write's hop 401 without making the write again (#269)
  Resend a 401 that refuses only the DPoP proof, with no refresh (#268)
  Answer the rotation test's first two requests by token, not by arrival (#274)
  Wait for a reviewer before tagging the Go module and publishing Kotlin (#246)
  Bump the cargo group across 1 directory with 3 updates (#252)
  Find true on PATH in the release test, so make release runs on macOS (#266)
  Bump the gradle group across 1 directory with 4 updates (#255)
  Bump the cargo group across 1 directory with 4 updates (#253)
  Bump the typescript-dev group across 1 directory with 2 updates (#249)
  Bump golang.org/x/net from 0.60.0 to 0.61.0 in /conformance/runner/go in the go group across 1 directory (#251)
  Bump vite from 8.3.0 to 8.3.5 in /typescript (#272)
  Bump rustls-webpki and webpki-root-certs past the emSign advisories (#273)
@jeremy
jeremy merged commit 488380d into main Oct 21, 2026
35 checks passed
@jeremy
jeremy deleted the oncall/oauth-3 branch October 21, 2026 11:23
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