fix: return RFC 6749 errors from the /oauth/token refresh grant - #2660
Open
nishant-iyengar wants to merge 1 commit into
Open
fix: return RFC 6749 errors from the /oauth/token refresh grant#2660nishant-iyengar wants to merge 1 commit into
nishant-iyengar wants to merge 1 commit into
Conversation
nishant-iyengar
force-pushed
the
fix/oauth-token-endpoint-rfc6749-errors
branch
3 times, most recently
from
July 30, 2026 22:37
4f17115 to
e499f2c
Compare
The refresh_token grant returns the shared token service's HTTPError
shape ({"code","error_code","msg"}) instead of the RFC 6749 Section 5.2
shape the authorization_code grant returns, so OAuth clients cannot
classify a dead grant and never prompt a re-authorization.
Translate at the OAuth handler boundary, which is where
handleAuthorizationCodeGrant already translates the errors it raises
itself. The token service is shared with /auth/v1/token, whose clients
parse error_code, so it cannot be changed to return OAuthError.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nishant-iyengar
force-pushed
the
fix/oauth-token-endpoint-rfc6749-errors
branch
from
July 30, 2026 22:44
e499f2c to
ec55c3f
Compare
xlgmokha
reviewed
Jul 31, 2026
xlgmokha
left a comment
Contributor
There was a problem hiding this comment.
LGTM. However, I would like to ensure that we reproduce the original defect in an integration test from the API entrypoint. I can see that we added unit tests for the new code but it's not clear to me if we ensure it from the API level. Can you confirm?
| }) | ||
| if err != nil { | ||
| return err | ||
| return oauthTokenError(err) |
Contributor
There was a problem hiding this comment.
question: is there a test covering this to ensure the error key is in the response body?
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.
The problem
POST /oauth/tokenreturns two different error shapes, depending on the grant type:The second one has no
errorfield, so an OAuth client cannot read it. RFC 6749 §5.2 requires that field.The client therefore never learns that the grant is dead. It does not ask the user to re-authorize. It just presents the same dead refresh token again, forever.
Evidence
We found this through an FDX / Plaid Core Exchange integration. Plaid's error contract is RFC 6749 §5.2, so it cannot classify the response. It reports "an unexpected error occurred" and leaves the connection in place.
Seven days of
/oauth/tokenon one project:400 refresh_token_not_found400 session_expired200Eleven failures in a row from one client IP, all
refresh_token_not_found:A fixed six-hour interval, no backoff, and no point where the client gives up. That is what a client does when it never got an error it could understand. A refresh token going dead is normal. Retrying it every six hours forever is not.
Cause
handleAuthorizationCodeGrantgets this right because it builds each of its own errors withNewOAuthError(...), where the correct code is obvious as you write the line.handleRefreshTokenGranthas no errors of its own. They all come fromtokens.Service.RefreshTokenGrant, which builds grant failures withNewBadRequestError(...)— anHTTPError— and the handler returns it as is:That service is also used by
/auth/v1/token, whose clients readerror_code— the reason for the// do not rename the JSON tags!comments inapierrors. So the service cannot returnOAuthErrorinstead. The conversion has to happen in the OAuth handler, and that is what was missing.This is not a regression. It has been there since the endpoint was added. #2135 moved the refresh logic into a shared package and kept the error shape its only caller expected at the time. #2159 then added both grants at once, and the refresh grant inherited an error shape meant for a different endpoint.
#2339 is the same seam failing a different way.
The fix
Two lines at the call sites, plus a small converter:
error_coderefresh_token_not_found,refresh_token_already_used,session_not_found,session_expired,user_bannedinvalid_grantvalidation_failedinvalid_requestinvalid_requestTwo rules decide that table.
invalid_grantis only sent for codes we have listed. It tells the client the grant is dead, which sends a real user back through a consent screen, so we only send it when we are sure. Every other error, including anyerror_codewe have never seen, becomesinvalid_request. The client reports that and leaves the grant alone. Guessinginvalid_grantfor an unknown error would break working connections over something we do not understand.Anything that is not a 400 is left alone. Each code in the spec says something permanent about the request. A 409 (two refreshes at once), a 429, or a 5xx is temporary, and turning one into a spec code would make a short outage look like a dead grant to every client refreshing at that moment. It also matters in practice:
HandleResponseErroralways sends*OAuthErroras a 400, so converting a 503 would change its status too.Errors that are already in the right shape are passed through. This endpoint does return correct bodies on some paths, and rewriting everything would break those.
refresh_token_already_usedarrives wrapped instorage.CommitWithError, because that transaction still has to commit. The wrapper hasCause()but notUnwrap(), so a normal type check misses it. The converter unwraps it the same wayHandleResponseErrordoes.The same call is added to the
HTTPErrorbranch ofhandleAuthorizationCodeGrant. That handler builds its own errors correctly, but the one error it gets back from the token service — from theIssueRefreshTokentransaction — was also being returned as is.Tests
internal/api/oauthserver/errors_test.go, one table-driven test, no database needed. The rows that matter are the ones that stop this getting worse:error_codebecomesinvalid_request, neverinvalid_grantrefresh_token_already_usedis converted correctly through itsCommitWithErrorwrapperMessage, notError(), so an internal note like"Possible abuse attempt: <token id>"never reaches the clientCompatibility
/auth/v1/tokenis untouched. Noerror_codethat asupabase-jsclient reads has changed./oauth/token,refresh_tokenerror bodies change shape. That is the fix. They now match what theauthorization_codegrant on the same endpoint already returned.