fix: return RFC 6749 errors from the /oauth/token refresh grant - #2660
fix: return RFC 6749 errors from the /oauth/token refresh grant#2660nishant-iyengar wants to merge 1 commit into
Conversation
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>
e499f2c to
ec55c3f
Compare
xlgmokha
left a comment
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) |
There was a problem hiding this comment.
question: is there a test covering this to ensure the error key is in the response body?
cstockton
left a comment
There was a problem hiding this comment.
I think it's important we follow the spec and appreciate you raising this.
Right now this PR is doing two separate things:
- Adding logic to change the errors based on some conditional logic
- Introducing new constant names to replace the string literals
- Introducing error codes outside of apierrors package
For 1. I don't think this is the right approach, it's continuing some patterns I think lead to this kind of problem to begin with - I've been slowly working on removing concrete type switches as I can.
For 2. it leaves string literals for NewOAuthError in other places such as the web3 package. I'm not against declaring constants for OAuth error codes but they should have public names matching conventions in other packages. If we did do this it should likely land where OAuthError is declared and other error codes exist, apierrors.
If you would like to break this up into two separate PRS, targeting 1 or 2/3 separately OR focusing this PR on either of those that would be fine by me. Otherwise an auth team member can pick this up.
|
@cstockton I opened up a PR for the error code improvements: #2676 I suggest Nishant's PR can get rebased on top of mine after it merges. |
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.