diff --git a/internal/api/oauthserver/errors.go b/internal/api/oauthserver/errors.go new file mode 100644 index 000000000..ac7fda922 --- /dev/null +++ b/internal/api/oauthserver/errors.go @@ -0,0 +1,58 @@ +package oauthserver + +import ( + "net/http" + + "github.com/supabase/auth/internal/api/apierrors" +) + +// Token endpoint error codes per RFC 6749 Section 5.2, extending the set in authorize.go. +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +const ( + oAuth2ErrorInvalidClient = "invalid_client" + oAuth2ErrorInvalidGrant = "invalid_grant" + oAuth2ErrorUnsupportedGrantType = "unsupported_grant_type" +) + +// Only codes meaning the grant itself is dead may map to invalid_grant: that is the code telling a client to stop retrying and re-authorize the user. +var grantErrorCodes = map[string]string{ + apierrors.ErrorCodeRefreshTokenNotFound: oAuth2ErrorInvalidGrant, + apierrors.ErrorCodeRefreshTokenAlreadyUsed: oAuth2ErrorInvalidGrant, + apierrors.ErrorCodeSessionNotFound: oAuth2ErrorInvalidGrant, + apierrors.ErrorCodeSessionExpired: oAuth2ErrorInvalidGrant, + apierrors.ErrorCodeUserBanned: oAuth2ErrorInvalidGrant, + + apierrors.ErrorCodeValidationFailed: oAuth2ErrorInvalidRequest, +} + +// oauthTokenError translates an error from the shared token service into an error response the token endpoint is allowed to return, per RFC 6749 Section 5.2 (https://datatracker.ietf.org/doc/html/rfc6749#section-5.2). The service is shared with /auth/v1/token, whose clients parse the HTTPError shape, so the translation has to happen at this boundary. +func oauthTokenError(err error) error { + switch e := err.(type) { + case nil: + return nil + + case *apierrors.OAuthError: + return e + + case *apierrors.HTTPError: + // A 409, 429 or 5xx says nothing about the grant, and every value in the spec's set asserts something about this request that retrying will not change. + if e.HTTPStatus != http.StatusBadRequest { + return e + } + + code, ok := grantErrorCodes[e.ErrorCode] + if !ok { + code = oAuth2ErrorInvalidRequest + } + + return apierrors.NewOAuthError(code, e.Message).WithInternalError(e) + + case interface{ Cause() error }: + // storage.CommitWithError, used where the transaction must still commit. + if cause := e.Cause(); cause != nil && cause != err { + return oauthTokenError(cause) + } + } + + return err +} diff --git a/internal/api/oauthserver/errors_test.go b/internal/api/oauthserver/errors_test.go new file mode 100644 index 000000000..2d1f0257b --- /dev/null +++ b/internal/api/oauthserver/errors_test.go @@ -0,0 +1,91 @@ +package oauthserver + +import ( + "testing" + + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/storage" +) + +func TestOAuthTokenError(t *testing.T) { + tests := []struct { + name string + err error + // the expected OAuth error code, empty when the error should pass through untranslated + expected string + // the expected error_description, when it needs asserting + expectedDescription string + // the status an untranslated error must keep + expectedStatus int + }{ + { + name: "refresh token not found should return invalid_grant", + err: apierrors.NewBadRequestError(apierrors.ErrorCodeRefreshTokenNotFound, "Invalid Refresh Token: Refresh Token Not Found"), + expected: "invalid_grant", + }, + { + name: "session expired should return invalid_grant", + err: apierrors.NewBadRequestError(apierrors.ErrorCodeSessionExpired, "Invalid Refresh Token: Session Expired"), + expected: "invalid_grant", + }, + { + name: "refresh token reuse wrapped in CommitWithError should return invalid_grant", + err: storage.NewCommitWithError(apierrors.NewBadRequestError(apierrors.ErrorCodeRefreshTokenAlreadyUsed, "Invalid Refresh Token: Already Used")), + expected: "invalid_grant", + }, + { + name: "the internal message should not reach the client", + err: apierrors.NewBadRequestError(apierrors.ErrorCodeRefreshTokenAlreadyUsed, "Invalid Refresh Token: Already Used").WithInternalMessage("Possible abuse attempt: %v", "6a3c1f0e"), + expected: "invalid_grant", + expectedDescription: "Invalid Refresh Token: Already Used", + }, + { + name: "validation failure should return invalid_request", + err: apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid Refresh Token: Not Issued By This Server"), + expected: "invalid_request", + }, + { + name: "unrecognized error code should return invalid_request, not invalid_grant", + err: apierrors.NewBadRequestError("some_future_error_code", "Something new"), + expected: "invalid_request", + }, + { + name: "concurrent refresh should pass through as a 409", + err: apierrors.NewConflictError("Too many concurrent token refresh requests on the same session or refresh token"), + expectedStatus: 409, + }, + { + name: "internal failure should pass through as a 500", + err: apierrors.NewInternalServerError("error generating jwt token"), + expectedStatus: 500, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := oauthTokenError(tt.err) + + if tt.expected == "" { + httpErr, ok := result.(*apierrors.HTTPError) + if !ok { + t.Fatalf("oauthTokenError() = %T, expected the error to pass through as *apierrors.HTTPError", result) + } + if httpErr.HTTPStatus != tt.expectedStatus { + t.Errorf("oauthTokenError() status = %v, expected %v", httpErr.HTTPStatus, tt.expectedStatus) + } + return + } + + oauthErr, ok := result.(*apierrors.OAuthError) + if !ok { + t.Fatalf("oauthTokenError() = %T, expected *apierrors.OAuthError", result) + } + if oauthErr.Err != tt.expected { + t.Errorf("oauthTokenError() error = %v, expected %v", oauthErr.Err, tt.expected) + } + if tt.expectedDescription != "" && oauthErr.Description != tt.expectedDescription { + t.Errorf("oauthTokenError() error_description = %v, expected %v", oauthErr.Description, tt.expectedDescription) + } + }) + } +} diff --git a/internal/api/oauthserver/handlers.go b/internal/api/oauthserver/handlers.go index bdf0b9b25..a9d82a5ec 100644 --- a/internal/api/oauthserver/handlers.go +++ b/internal/api/oauthserver/handlers.go @@ -271,12 +271,12 @@ func (s *Server) OAuthToken(w http.ResponseWriter, r *http.Request) error { contentType := r.Header.Get("Content-Type") if strings.Contains(contentType, "application/json") { if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - return apierrors.NewOAuthError("invalid_request", "Invalid JSON body") + return apierrors.NewOAuthError(oAuth2ErrorInvalidRequest, "Invalid JSON body") } } else { // Parse form data if err := r.ParseForm(); err != nil { - return apierrors.NewOAuthError("invalid_request", "Failed to parse form data") + return apierrors.NewOAuthError(oAuth2ErrorInvalidRequest, "Failed to parse form data") } params.GrantType = r.FormValue("grant_type") @@ -290,17 +290,17 @@ func (s *Server) OAuthToken(w http.ResponseWriter, r *http.Request) error { // Validate grant_type if params.GrantType == "" { - return apierrors.NewOAuthError("invalid_request", "grant_type is required") + return apierrors.NewOAuthError(oAuth2ErrorInvalidRequest, "grant_type is required") } client := shared.GetOAuthServerClient(ctx) if client == nil { - return apierrors.NewOAuthError("invalid_client", "Client authentication required") + return apierrors.NewOAuthError(oAuth2ErrorInvalidClient, "Client authentication required") } // Validate that the authenticated client is allowed to use the requested grant type if !client.IsGrantTypeAllowed(params.GrantType) { - return apierrors.NewOAuthError("unsupported_grant_type", "Client is not allowed to use grant type: "+params.GrantType) + return apierrors.NewOAuthError(oAuth2ErrorUnsupportedGrantType, "Client is not allowed to use grant type: "+params.GrantType) } switch params.GrantType { @@ -309,20 +309,20 @@ func (s *Server) OAuthToken(w http.ResponseWriter, r *http.Request) error { case GrantTypeRefreshToken: return s.handleRefreshTokenGrant(ctx, w, r, ¶ms) default: - return apierrors.NewOAuthError("unsupported_grant_type", "Unsupported grant type: "+params.GrantType) + return apierrors.NewOAuthError(oAuth2ErrorUnsupportedGrantType, "Unsupported grant type: "+params.GrantType) } } // handleAuthorizationCodeGrant handles the authorization_code grant type func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.ResponseWriter, r *http.Request, params *OAuthTokenParams) error { if params.Code == "" { - return apierrors.NewOAuthError("invalid_request", "code is required for authorization_code grant") + return apierrors.NewOAuthError(oAuth2ErrorInvalidRequest, "code is required for authorization_code grant") } // Get authenticated client from middleware client := shared.GetOAuthServerClient(ctx) if client == nil { - return apierrors.NewOAuthError("invalid_client", "Client authentication required") + return apierrors.NewOAuthError(oAuth2ErrorInvalidClient, "Client authentication required") } // Exchange authorization code for tokens @@ -336,51 +336,51 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon authorization, err := models.FindOAuthServerAuthorizationByCode(db, params.Code) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewOAuthError("invalid_grant", "Invalid authorization code") + return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Invalid authorization code") } return apierrors.NewInternalServerError("Error finding authorization code").WithInternalError(err) } // Check if the authorization has expired if authorization.IsExpired() { - return apierrors.NewOAuthError("invalid_grant", "Authorization code has expired") + return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Authorization code has expired") } // Validate that the authorization code was issued for this client if authorization.ClientID != client.ID { - return apierrors.NewOAuthError("invalid_grant", "Authorization code was not issued for this client") + return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Authorization code was not issued for this client") } // Validate that (if exists) the resource parameter matches the authorization code resource if params.Resource != "" && params.Resource != utilities.StringValue(authorization.Resource) { - return apierrors.NewOAuthError("invalid_grant", "Authorization code resource does not match the resource parameter") + return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Authorization code resource does not match the resource parameter") } // Validate redirect_uri if provided - must match the one used in authorization if params.RedirectURI != "" && params.RedirectURI != authorization.RedirectURI { - return apierrors.NewOAuthError("invalid_grant", "Invalid redirect_uri") + return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Invalid redirect_uri") } // Validate PKCE if used in the authorization if err := authorization.VerifyPKCE(params.CodeVerifier); err != nil { - return apierrors.NewOAuthError("invalid_grant", "PKCE verification failed: "+err.Error()) + return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "PKCE verification failed: "+err.Error()) } // Get the user for the authorization code if authorization.UserID == nil { - return apierrors.NewOAuthError("invalid_grant", "Authorization code has no associated user") + return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Authorization code has no associated user") } user, err := models.FindUserByID(db, *authorization.UserID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewOAuthError("invalid_grant", "User not found for authorization code") + return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "User not found for authorization code") } return apierrors.NewInternalServerError("Error finding user").WithInternalError(err) } if user.IsBanned() { - return apierrors.NewOAuthError("access_denied", "User is banned") + return apierrors.NewOAuthError(oAuth2ErrorAccessDenied, "User is banned") } // Exchange the authorization code for tokens @@ -396,7 +396,7 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon err = db.Transaction(func(tx *storage.Connection) error { if _, terr := models.FindOAuthServerAuthorizationByIDForUpdate(tx, authorization.AuthorizationID); terr != nil { if models.IsNotFoundError(terr) { - return apierrors.NewOAuthError("invalid_grant", "Invalid authorization code") + return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Invalid authorization code") } return apierrors.NewInternalServerError("Error locking authorization code").WithInternalError(terr) } @@ -429,7 +429,7 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon if err != nil { if httpErr, ok := err.(*apierrors.HTTPError); ok { - return httpErr + return oauthTokenError(httpErr) } if oauthErr, ok := err.(*apierrors.OAuthError); ok { return oauthErr @@ -478,7 +478,7 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon // handleRefreshTokenGrant handles the refresh_token grant type func (s *Server) handleRefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.Request, params *OAuthTokenParams) error { if params.RefreshToken == "" { - return apierrors.NewOAuthError("invalid_request", "refresh_token is required for refresh_token grant") + return apierrors.NewOAuthError(oAuth2ErrorInvalidRequest, "refresh_token is required for refresh_token grant") } // Use the token service to handle refresh token grant @@ -499,7 +499,7 @@ func (s *Server) handleRefreshTokenGrant(ctx context.Context, w http.ResponseWri ClientID: clientID, }) if err != nil { - return err + return oauthTokenError(err) } // Convert to OAuth-compliant response format (exclude user info for OAuth clients)