github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/sharing/oauth.go (about)

     1  package sharing
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"errors"
     7  	"net/http"
     8  	"net/url"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/cozy/cozy-stack/client/auth"
    13  	"github.com/cozy/cozy-stack/client/request"
    14  	"github.com/cozy/cozy-stack/model/bitwarden/settings"
    15  	"github.com/cozy/cozy-stack/model/contact"
    16  	"github.com/cozy/cozy-stack/model/instance"
    17  	"github.com/cozy/cozy-stack/model/oauth"
    18  	"github.com/cozy/cozy-stack/model/permission"
    19  	csettings "github.com/cozy/cozy-stack/model/settings"
    20  	"github.com/cozy/cozy-stack/model/vfs"
    21  	"github.com/cozy/cozy-stack/pkg/consts"
    22  	"github.com/cozy/cozy-stack/pkg/couchdb"
    23  	"github.com/cozy/cozy-stack/pkg/couchdb/mango"
    24  	"github.com/cozy/cozy-stack/pkg/jsonapi"
    25  	"github.com/cozy/cozy-stack/pkg/safehttp"
    26  	jwt "github.com/golang-jwt/jwt/v5"
    27  	"github.com/labstack/echo/v4"
    28  )
    29  
    30  // CreateSharingRequest sends information about the sharing to the recipient's cozy
    31  func (m *Member) CreateSharingRequest(inst *instance.Instance, s *Sharing, c *Credentials, u *url.URL) error {
    32  	if len(c.XorKey) == 0 {
    33  		return ErrInvalidSharing
    34  	}
    35  
    36  	rules := make([]Rule, 0, len(s.Rules))
    37  	for _, rule := range s.Rules {
    38  		if rule.Local {
    39  			continue
    40  		}
    41  		if rule.FilesByID() && len(rule.Values) > 0 {
    42  			if fileDoc, err := inst.VFS().FileByID(rule.Values[0]); err == nil {
    43  				// err != nil means that the target is a directory and not
    44  				// a file, and we leave the mime blank in that case.
    45  				rule.Mime = fileDoc.Mime
    46  			}
    47  		}
    48  		values := make([]string, len(rule.Values))
    49  		for i, v := range rule.Values {
    50  			switch rule.Selector {
    51  			case "", "id", "_id", "organization_id":
    52  				values[i] = XorID(v, c.XorKey)
    53  			case couchdb.SelectorReferencedBy:
    54  				parts := strings.SplitN(v, "/", 2)
    55  				values[i] = parts[0] + "/" + XorID(parts[1], c.XorKey)
    56  			default:
    57  				values[i] = v
    58  			}
    59  		}
    60  		rule.Values = values
    61  		rules = append(rules, rule)
    62  	}
    63  	members := make([]Member, len(s.Members))
    64  	for i, m := range s.Members {
    65  		// Instance and name are private...
    66  		members[i] = Member{
    67  			Status:       m.Status,
    68  			PublicName:   m.PublicName,
    69  			Email:        m.Email,
    70  			ReadOnly:     m.ReadOnly,
    71  			OnlyInGroups: m.OnlyInGroups,
    72  			Groups:       m.Groups,
    73  		}
    74  		// ... except for the sharer and the recipient of this request
    75  		if i == 0 || &s.Credentials[i-1] == c {
    76  			members[i].Instance = m.Instance
    77  		}
    78  	}
    79  	sh := APISharing{
    80  		&Sharing{
    81  			SID:         s.SID,
    82  			Active:      false,
    83  			Owner:       false,
    84  			Open:        s.Open,
    85  			Description: s.Description,
    86  			AppSlug:     s.AppSlug,
    87  			PreviewPath: s.PreviewPath,
    88  			CreatedAt:   s.CreatedAt,
    89  			UpdatedAt:   s.UpdatedAt,
    90  			Rules:       rules,
    91  			Members:     members,
    92  			Groups:      s.Groups,
    93  			NbFiles:     s.countFiles(inst),
    94  		},
    95  		nil,
    96  		nil,
    97  	}
    98  	data, err := jsonapi.MarshalObject(&sh)
    99  	if err != nil {
   100  		return err
   101  	}
   102  	body, err := json.Marshal(jsonapi.Document{Data: &data})
   103  	if err != nil {
   104  		return err
   105  	}
   106  	opts := request.Options{
   107  		Method: http.MethodPut,
   108  		Scheme: u.Scheme,
   109  		Domain: u.Host,
   110  		Path:   "/sharings/" + s.SID,
   111  		Headers: request.Headers{
   112  			echo.HeaderAccept:      jsonapi.ContentType,
   113  			echo.HeaderContentType: jsonapi.ContentType,
   114  		},
   115  		Queries: u.Query(),
   116  		Body:    bytes.NewReader(body),
   117  	}
   118  	res, err := request.Req(&opts)
   119  	if res != nil && res.StatusCode == http.StatusConflict {
   120  		return ErrAlreadyAccepted
   121  	}
   122  	if err != nil {
   123  		return err
   124  	}
   125  	res.Body.Close()
   126  	return nil
   127  }
   128  
   129  // countFiles returns the number of files that should be uploaded on the
   130  // initial synchronisation.
   131  func (s *Sharing) countFiles(inst *instance.Instance) int {
   132  	count := 0
   133  	for _, rule := range s.Rules {
   134  		if rule.DocType != consts.Files || rule.Local || len(rule.Values) == 0 {
   135  			continue
   136  		}
   137  
   138  		if rule.Selector == "" || rule.Selector == "id" {
   139  			fs := inst.VFS()
   140  			for _, fileID := range rule.Values {
   141  				dir, _, err := fs.DirOrFileByID(fileID)
   142  				if err != nil {
   143  					continue
   144  				}
   145  				if dir != nil {
   146  					nb, err := countFilesInDirectory(inst, dir)
   147  					if err != nil {
   148  						continue
   149  					}
   150  					count += nb
   151  				} else {
   152  					count++
   153  				}
   154  			}
   155  		} else {
   156  			var resCount couchdb.ViewResponse
   157  			for _, val := range rule.Values {
   158  				reqCount := &couchdb.ViewRequest{Key: val, Reduce: true}
   159  				err := couchdb.ExecView(inst, couchdb.FilesReferencedByView, reqCount, &resCount)
   160  				if err == nil && len(resCount.Rows) > 0 {
   161  					count += int(resCount.Rows[0].Value.(float64))
   162  				}
   163  			}
   164  		}
   165  	}
   166  	return count
   167  }
   168  
   169  func countFilesInDirectory(inst *instance.Instance, dir *vfs.DirDoc) (int, error) {
   170  	// Find the subdirectories
   171  	start := dir.Fullpath + "/"
   172  	stop := dir.Fullpath + "0" // 0 is the next ascii character after /
   173  	if dir.DocID == consts.RootDirID {
   174  		start = "/"
   175  		stop = "0"
   176  	}
   177  	sel := mango.And(
   178  		mango.Gt("path", start),
   179  		mango.Lt("path", stop),
   180  		mango.Equal("type", consts.DirType),
   181  	)
   182  	req := &couchdb.FindRequest{
   183  		UseIndex: "dir-by-path",
   184  		Selector: sel,
   185  		Fields:   []string{"_id"},
   186  		Limit:    10000,
   187  	}
   188  	var children []couchdb.JSONDoc
   189  	err := couchdb.FindDocs(inst, consts.Files, req, &children)
   190  	if err != nil {
   191  		return 0, err
   192  	}
   193  	keys := make([]interface{}, len(children)+1)
   194  	keys[0] = dir.DocID
   195  	for i, child := range children {
   196  		keys[i+1] = child.ID()
   197  	}
   198  
   199  	// Get the number of files for the directory and each of its sub-directory
   200  	var resp couchdb.ViewResponse
   201  	err = couchdb.ExecView(inst, couchdb.DiskUsageView, &couchdb.ViewRequest{
   202  		Keys:  keys,
   203  		Limit: 100_000,
   204  	}, &resp)
   205  	if err != nil {
   206  		return 0, err
   207  	}
   208  	return len(resp.Rows), nil
   209  }
   210  
   211  // RegisterCozyURL saves a new Cozy URL for a member
   212  func (s *Sharing) RegisterCozyURL(inst *instance.Instance, m *Member, cozyURL string) error {
   213  	if !s.Owner {
   214  		return ErrInvalidSharing
   215  	}
   216  	if m.Status == MemberStatusReady {
   217  		return ErrAlreadyAccepted
   218  	}
   219  	if m.Status == MemberStatusOwner || m.Status == MemberStatusRevoked {
   220  		return ErrMemberNotFound
   221  	}
   222  
   223  	cozyURL = strings.TrimSpace(cozyURL)
   224  	if !strings.Contains(cozyURL, "://") {
   225  		cozyURL = "https://" + cozyURL
   226  	}
   227  	u, err := url.Parse(cozyURL)
   228  	if err != nil || u.Host == "" {
   229  		return ErrInvalidURL
   230  	}
   231  	u.Path = ""
   232  	u.RawPath = ""
   233  	u.RawQuery = ""
   234  	u.Fragment = ""
   235  	m.Instance = u.String()
   236  
   237  	creds := s.FindCredentials(m)
   238  	if creds == nil {
   239  		return ErrInvalidSharing
   240  	}
   241  	if err = m.CreateSharingRequest(inst, s, creds, u); err != nil {
   242  		inst.Logger().WithNamespace("sharing").Warnf("Error on sharing request: %s", err)
   243  		if errors.Is(err, ErrAlreadyAccepted) {
   244  			return err
   245  		}
   246  		return ErrRequestFailed
   247  	}
   248  	return couchdb.UpdateDoc(inst, s)
   249  }
   250  
   251  // GenerateOAuthURL takes care of creating a correct OAuth request for
   252  // the given member of the sharing.
   253  func (m *Member) GenerateOAuthURL(s *Sharing) (string, error) {
   254  	if !s.Owner || len(s.Members) != len(s.Credentials)+1 {
   255  		return "", ErrInvalidSharing
   256  	}
   257  	creds := s.FindCredentials(m)
   258  	if creds == nil {
   259  		return "", ErrInvalidSharing
   260  	}
   261  	if m.Instance == "" {
   262  		return "", ErrNoOAuthClient
   263  	}
   264  
   265  	u, err := url.Parse(m.Instance)
   266  	if err != nil {
   267  		return "", err
   268  	}
   269  	u.Path = "/auth/authorize/sharing"
   270  
   271  	q := url.Values{
   272  		"sharing_id": {s.SID},
   273  		"state":      {creds.State},
   274  	}
   275  	u.RawQuery = q.Encode()
   276  
   277  	return u.String(), nil
   278  }
   279  
   280  // CreateOAuthClient creates an OAuth client for a recipient of the given sharing
   281  func CreateOAuthClient(inst *instance.Instance, m *Member) (*oauth.Client, error) {
   282  	if m.Instance == "" {
   283  		return nil, ErrInvalidURL
   284  	}
   285  	cli := oauth.Client{
   286  		RedirectURIs: []string{m.Instance + "/sharings/answer"},
   287  		ClientName:   "Sharing " + m.PublicName,
   288  		ClientKind:   "sharing",
   289  		SoftwareID:   "github.com/cozy/cozy-stack",
   290  		ClientURI:    m.Instance + "/",
   291  	}
   292  	if err := cli.Create(inst, oauth.NotPending); err != nil {
   293  		return nil, ErrInternalServerError
   294  	}
   295  	return &cli, nil
   296  }
   297  
   298  // DeleteOAuthClient removes the client associated to the given member
   299  func DeleteOAuthClient(inst *instance.Instance, m *Member, cred *Credentials) error {
   300  	if m.Instance == "" {
   301  		return ErrInvalidURL
   302  	}
   303  	clientID := cred.InboundClientID
   304  	if clientID == "" {
   305  		return nil
   306  	}
   307  	client, err := oauth.FindClient(inst, clientID)
   308  	if err != nil {
   309  		if couchdb.IsNotFoundError(err) {
   310  			return nil
   311  		}
   312  		return err
   313  	}
   314  	if cerr := client.Delete(inst); cerr != nil {
   315  		return errors.New(cerr.Error)
   316  	}
   317  	return nil
   318  }
   319  
   320  // ConvertOAuthClient converts an OAuth client from one type
   321  // (model/oauth.Client) to another (client/auth.Client)
   322  func ConvertOAuthClient(c *oauth.Client) *auth.Client {
   323  	return &auth.Client{
   324  		ClientID:          c.ClientID,
   325  		ClientSecret:      c.ClientSecret,
   326  		SecretExpiresAt:   c.SecretExpiresAt,
   327  		RegistrationToken: c.RegistrationToken,
   328  		RedirectURIs:      c.RedirectURIs,
   329  		ClientName:        c.ClientName,
   330  		ClientKind:        c.ClientKind,
   331  		ClientURI:         c.ClientURI,
   332  		LogoURI:           c.LogoURI,
   333  		PolicyURI:         c.PolicyURI,
   334  		SoftwareID:        c.SoftwareID,
   335  		SoftwareVersion:   c.SoftwareVersion,
   336  	}
   337  }
   338  
   339  // CreateAccessToken creates an access token for the given OAuth client,
   340  // with a scope on this sharing.
   341  func CreateAccessToken(inst *instance.Instance, cli *oauth.Client, sharingID string, verb permission.VerbSet) (*auth.AccessToken, error) {
   342  	scope := consts.Sharings + ":" + verb.String() + ":" + sharingID
   343  	cli.CouchID = cli.ClientID // XXX CouchID is required by CreateJWT
   344  	refresh, err := cli.CreateJWT(inst, consts.RefreshTokenAudience, scope)
   345  	if err != nil {
   346  		return nil, err
   347  	}
   348  	access, err := cli.CreateJWT(inst, consts.AccessTokenAudience, scope)
   349  	if err != nil {
   350  		return nil, err
   351  	}
   352  	return &auth.AccessToken{
   353  		TokenType:    "bearer",
   354  		AccessToken:  access,
   355  		RefreshToken: refresh,
   356  		Scope:        scope,
   357  	}, nil
   358  }
   359  
   360  // SendAnswer says to the sharer's Cozy that the sharing has been accepted, and
   361  // materialize that by an exchange of credentials.
   362  func (s *Sharing) SendAnswer(inst *instance.Instance, state string) error {
   363  	if s.Owner || len(s.Members) < 2 || len(s.Credentials) != 1 {
   364  		return ErrInvalidSharing
   365  	}
   366  	u, err := url.Parse(s.Members[0].Instance)
   367  	if s.Members[0].Instance == "" || err != nil {
   368  		return ErrInvalidSharing
   369  	}
   370  	cli, err := CreateOAuthClient(inst, &s.Members[0])
   371  	if err != nil {
   372  		return err
   373  	}
   374  	token, err := CreateAccessToken(inst, cli, s.SID, permission.ALL)
   375  	if err != nil {
   376  		return err
   377  	}
   378  	name, err := csettings.PublicName(inst)
   379  	if err != nil {
   380  		inst.Logger().WithNamespace("sharing").
   381  			Infof("No name for instance %v", inst)
   382  	}
   383  	ac := APICredentials{
   384  		Credentials: &Credentials{
   385  			State:       state,
   386  			Client:      ConvertOAuthClient(cli),
   387  			AccessToken: token,
   388  		},
   389  		PublicName: name,
   390  		CID:        s.SID,
   391  	}
   392  	if s.FirstBitwardenOrganizationRule() != nil {
   393  		setting, err := settings.Get(inst)
   394  		if err != nil {
   395  			return err
   396  		}
   397  		ac.Bitwarden = &APIBitwarden{
   398  			UserID:    inst.ID(),
   399  			PublicKey: setting.PublicKey,
   400  		}
   401  	}
   402  	data, err := jsonapi.MarshalObject(&ac)
   403  	if err != nil {
   404  		return err
   405  	}
   406  	body, err := json.Marshal(jsonapi.Document{Data: &data})
   407  	if err != nil {
   408  		return err
   409  	}
   410  	res, err := request.Req(&request.Options{
   411  		Method: http.MethodPost,
   412  		Scheme: u.Scheme,
   413  		Domain: u.Host,
   414  		Path:   "/sharings/" + s.SID + "/answer",
   415  		Headers: request.Headers{
   416  			echo.HeaderAccept:      jsonapi.ContentType,
   417  			echo.HeaderContentType: jsonapi.ContentType,
   418  		},
   419  		Body: bytes.NewReader(body),
   420  	})
   421  	if err != nil {
   422  		return err
   423  	}
   424  	defer res.Body.Close()
   425  
   426  	for i, m := range s.Members {
   427  		if i > 0 && m.Instance != "" {
   428  			if m.Status == MemberStatusMailNotSent ||
   429  				m.Status == MemberStatusPendingInvitation ||
   430  				m.Status == MemberStatusSeen {
   431  				s.Members[i].Status = MemberStatusReady
   432  			}
   433  		}
   434  	}
   435  
   436  	if err = s.SetupReceiver(inst); err != nil {
   437  		return err
   438  	}
   439  
   440  	var creds Credentials
   441  	if _, err = jsonapi.Bind(res.Body, &creds); err != nil {
   442  		return ErrRequestFailed
   443  	}
   444  	s.Credentials[0].XorKey = creds.XorKey
   445  	s.Credentials[0].InboundClientID = cli.ClientID
   446  	s.Credentials[0].AccessToken = creds.AccessToken
   447  	s.Credentials[0].Client = creds.Client
   448  	s.Active = true
   449  	s.Initial = s.NbFiles > 0
   450  	return couchdb.UpdateDoc(inst, s)
   451  }
   452  
   453  // ProcessAnswer takes somes credentials and update the sharing with those.
   454  func (s *Sharing) ProcessAnswer(inst *instance.Instance, creds *APICredentials) (*APICredentials, error) {
   455  	if !s.Owner || len(s.Members) != len(s.Credentials)+1 {
   456  		return nil, ErrInvalidSharing
   457  	}
   458  	for i, c := range s.Credentials {
   459  		if c.State == creds.State {
   460  			s.Members[i+1].Status = MemberStatusReady
   461  			s.Members[i+1].PublicName = creds.PublicName
   462  			s.Credentials[i].Client = creds.Client
   463  			s.Credentials[i].AccessToken = creds.AccessToken
   464  			ac := APICredentials{
   465  				CID: s.SID,
   466  				Credentials: &Credentials{
   467  					XorKey: c.XorKey,
   468  				},
   469  			}
   470  			// Create the credentials for the recipient
   471  			cli, err := CreateOAuthClient(inst, &s.Members[i+1])
   472  			if err != nil {
   473  				return &ac, nil
   474  			}
   475  			s.Credentials[i].InboundClientID = cli.ClientID
   476  			ac.Credentials.Client = ConvertOAuthClient(cli)
   477  			var verb permission.VerbSet
   478  			// In case of read-only, the recipient only needs read access on the
   479  			// sharing, e.g. to notify the sharer of a revocation
   480  			if s.ReadOnlyRules() || s.Members[i+1].ReadOnly {
   481  				verb = permission.Verbs(permission.GET)
   482  			} else {
   483  				verb = permission.ALL
   484  			}
   485  			token, err := CreateAccessToken(inst, cli, s.SID, verb)
   486  			if err != nil {
   487  				return &ac, nil
   488  			}
   489  			ac.Credentials.AccessToken = token
   490  
   491  			// Update the contact to fill the name if missing
   492  			if email := s.Members[i+1].Email; email != "" {
   493  				if c, err := contact.FindByEmail(inst, email); err == nil {
   494  					if err := c.AddNameIfMissing(inst, s.Members[i+1].PublicName, email); err != nil {
   495  						inst.Logger().WithNamespace("sharing").
   496  							Warnf("Error on saving contact: %s", err)
   497  					}
   498  				}
   499  			}
   500  
   501  			s.Active = true
   502  			if err := couchdb.UpdateDoc(inst, s); err != nil {
   503  				if !couchdb.IsConflictError(err) {
   504  					return nil, err
   505  				}
   506  				// A conflict can occur when several users accept a sharing at
   507  				// the same time, and we should just retry in that case
   508  				s2, err2 := FindSharing(inst, s.SID)
   509  				if err2 != nil {
   510  					return nil, err
   511  				}
   512  				s2.Members[i+1] = s.Members[i+1]
   513  				s2.Credentials[i] = s.Credentials[i]
   514  				if err2 := couchdb.UpdateDoc(inst, s2); err2 != nil {
   515  					return nil, err
   516  				}
   517  				s = s2
   518  			}
   519  			if creds.Bitwarden != nil {
   520  				if err := s.SaveBitwarden(inst, &s.Members[i+1], creds.Bitwarden); err != nil {
   521  					return nil, err
   522  				}
   523  			}
   524  			go s.Setup(inst, &s.Members[i+1])
   525  			return &ac, nil
   526  		}
   527  	}
   528  	return nil, ErrMemberNotFound
   529  }
   530  
   531  // ChangeOwnerAddress is used when the owner of the sharing has moved their
   532  // instance to a new URL and the other members of the sharing are informed of
   533  // the new URL.
   534  func (s *Sharing) ChangeOwnerAddress(inst *instance.Instance, params APIMoved) error {
   535  	s.Members[0].Instance = params.NewInstance
   536  	s.Credentials[0].AccessToken.AccessToken = params.AccessToken
   537  	s.Credentials[0].AccessToken.RefreshToken = params.RefreshToken
   538  	updateContactAddress(inst, s.Members[0].Email, params.NewInstance)
   539  	return couchdb.UpdateDoc(inst, s)
   540  }
   541  
   542  // ChangeMemberAddress is used when a recipient of the sharing has moved their
   543  // instance to a new URL and the owner if informed of the new URL.
   544  func (s *Sharing) ChangeMemberAddress(inst *instance.Instance, m *Member, params APIMoved) error {
   545  	m.Instance = params.NewInstance
   546  	for i := range s.Members {
   547  		if i == 0 {
   548  			continue
   549  		}
   550  		if m.Same(s.Members[i]) {
   551  			s.Credentials[i-1].AccessToken.AccessToken = params.AccessToken
   552  			s.Credentials[i-1].AccessToken.RefreshToken = params.RefreshToken
   553  		}
   554  	}
   555  	updateContactAddress(inst, m.Email, params.NewInstance)
   556  	return couchdb.UpdateDoc(inst, s)
   557  }
   558  
   559  func updateContactAddress(inst *instance.Instance, email, newInstance string) {
   560  	if email == "" {
   561  		return
   562  	}
   563  	c, err := contact.FindByEmail(inst, email)
   564  	if err != nil {
   565  		return
   566  	}
   567  	_ = c.ChangeCozyURL(inst, newInstance)
   568  }
   569  
   570  // RefreshToken is used after a failed request with a 4xx error code.
   571  // It checks if the targeted instance has moved, and tries on the new instance
   572  // if it is the case. And, if needed, it renews the access token and retries
   573  // the request.
   574  func RefreshToken(
   575  	inst *instance.Instance,
   576  	reqErr error,
   577  	s *Sharing,
   578  	m *Member,
   579  	creds *Credentials,
   580  	opts *request.Options,
   581  	body []byte,
   582  ) (*http.Response, error) {
   583  	if err, ok := reqErr.(*request.Error); ok && err.Status == http.StatusText(http.StatusGone) {
   584  		tryUpdateMemberInstance(err, m, opts)
   585  	}
   586  
   587  	if err := creds.Refresh(inst, s, m); err != nil {
   588  		return nil, err
   589  	}
   590  	opts.Headers["Authorization"] = "Bearer " + creds.AccessToken.AccessToken
   591  	if body != nil {
   592  		opts.Body = bytes.NewReader(body)
   593  	}
   594  	res, err := request.Req(opts)
   595  	if res != nil && res.StatusCode/100 == 5 {
   596  		return nil, ErrInternalServerError
   597  	}
   598  	return res, err
   599  }
   600  
   601  func tryUpdateMemberInstance(reqErr *request.Error, m *Member, opts *request.Options) {
   602  	m.Instance = reqErr.Title
   603  	u, err := url.Parse(m.Instance)
   604  	if err != nil {
   605  		return
   606  	}
   607  	opts.Scheme = u.Scheme
   608  	opts.Domain = u.Host
   609  }
   610  
   611  // ParseRequestError is used to parse an error in a request.Options, and it
   612  // keeps the new instance URL when a Cozy has moved in Title.
   613  func ParseRequestError(res *http.Response, body []byte) error {
   614  	if res.StatusCode != http.StatusGone {
   615  		return &request.Error{
   616  			Status: http.StatusText(res.StatusCode),
   617  			Title:  http.StatusText(res.StatusCode),
   618  			Detail: string(body),
   619  		}
   620  	}
   621  
   622  	var errors struct {
   623  		List jsonapi.ErrorList `json:"errors"`
   624  	}
   625  	if err := json.Unmarshal(body, &errors); err != nil {
   626  		return &request.Error{
   627  			Status: http.StatusText(res.StatusCode),
   628  			Title:  http.StatusText(res.StatusCode),
   629  			Detail: string(body),
   630  		}
   631  	}
   632  	var newInstance string
   633  	if len(errors.List) == 1 && errors.List[0].Links != nil && errors.List[0].Links.Related != "" {
   634  		newInstance = errors.List[0].Links.Related
   635  	}
   636  	return &request.Error{
   637  		Status: http.StatusText(res.StatusCode),
   638  		Title:  newInstance,
   639  		Detail: string(body),
   640  	}
   641  }
   642  
   643  // TryTokenForMovedSharing is used when a Cozy has been moved, and a sharing
   644  // was not updated on the other Cozy for some reasons. When the other Cozy will
   645  // try to make a request to the source Cozy, it will get a 410 Gone error. This
   646  // error will also tell it the URL of the new Cozy. Thus, it can try to refresh
   647  // the token on the destination Cozy. And, as the refresh token was emitted on
   648  // the source Cozy (and not the target Cozy), we need to do some tricks to
   649  // manage this refresh. This function is here for that.
   650  func TryTokenForMovedSharing(i *instance.Instance, c *oauth.Client, token string) (string, permission.Claims, bool) {
   651  	// Extract the sharing ID from the scope of the refresh token
   652  	claims := permission.Claims{}
   653  	if token == "" {
   654  		return "", claims, false
   655  	}
   656  	_, _, err := new(jwt.Parser).ParseUnverified(token, &claims)
   657  	if err != nil {
   658  		return "", claims, false
   659  	}
   660  	parts := strings.Split(claims.Scope, ":")
   661  	if len(parts) != 3 || parts[0] != consts.Sharings {
   662  		return "", claims, false
   663  	}
   664  
   665  	// Find the sharing and check that it has been moved from another instance
   666  	s, err := FindSharing(i, parts[2])
   667  	if err != nil || s.MovedFrom == "" {
   668  		return "", claims, false
   669  	}
   670  	validUntil := s.UpdatedAt.Add(consts.AccessTokenValidityDuration)
   671  	if validUntil.Before(time.Now().UTC()) {
   672  		// This trick is only accepted in the week following the move, not after
   673  		return "", claims, false
   674  	}
   675  
   676  	// Call the other instance and check the response
   677  	q := url.Values{
   678  		"grant_type":    {"refresh_token"},
   679  		"refresh_token": {token},
   680  		"client_id":     {c.ClientID},
   681  		"client_secret": {c.ClientSecret},
   682  	}
   683  	if c.ClientID == "" {
   684  		q.Set("client_id", c.CouchID)
   685  	}
   686  	payload := strings.NewReader(q.Encode())
   687  	req, err := http.NewRequest("POST", s.MovedFrom+"/auth/access_token", payload)
   688  	if err != nil {
   689  		return "", claims, false
   690  	}
   691  	req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationForm)
   692  	req.Header.Add(echo.HeaderAccept, echo.MIMEApplicationJSON)
   693  	res, err := safehttp.ClientWithKeepAlive.Do(req)
   694  	if err != nil || res.StatusCode != http.StatusOK {
   695  		return "", claims, false
   696  	}
   697  	defer res.Body.Close()
   698  	body := &auth.AccessToken{}
   699  	if err = json.NewDecoder(res.Body).Decode(&body); err != nil || body.AccessToken == "" {
   700  		return "", claims, false
   701  	}
   702  	other := permission.Claims{}
   703  	_, _, err = new(jwt.Parser).ParseUnverified(body.AccessToken, &other)
   704  	if err != nil {
   705  		return "", claims, false
   706  	}
   707  
   708  	// Create a new refresh token
   709  	refresh, err := c.CreateJWT(i, consts.RefreshTokenAudience, claims.Scope)
   710  	return refresh, claims, err == nil
   711  }