github.com/cozy/cozy-stack@v0.0.0-20240327093429-939e4a21320e/model/account/type.go (about)

     1  package account
     2  
     3  import (
     4  	"encoding/base64"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/url"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/cozy/cozy-stack/model/instance"
    15  	"github.com/cozy/cozy-stack/pkg/consts"
    16  	"github.com/cozy/cozy-stack/pkg/couchdb"
    17  	"github.com/cozy/cozy-stack/pkg/couchdb/mango"
    18  	"github.com/cozy/cozy-stack/pkg/prefixer"
    19  	"github.com/labstack/echo/v4"
    20  )
    21  
    22  var accountsClient = &http.Client{
    23  	Timeout: 15 * time.Second,
    24  }
    25  
    26  // This file contains the account_type object as defined in
    27  // docs/konnectors-workflow.md
    28  
    29  // Various grant types
    30  //   - AuthorizationCode is the server-side grant type.
    31  //   - ImplicitGrant is the implicit grant type
    32  //   - ImplicitGrantRedirectURL is the implicit grant type but with redirect_url
    33  //     instead of redirect_uri
    34  //   - BIWebauth is the specific webauth protocol from Budget Insight
    35  //   - SecretGrant is for other secrets (not OAuth)
    36  //   - BIWebauthAndSecret is a combination of BIWebauth and SecretGrant
    37  //   - BIWebview is the specific webview protocol from Budget Insight
    38  //   - BIWebviewAndSecret is a combination of BIWebview and SecretGrant
    39  const (
    40  	AuthorizationCode          = "authorization_code"
    41  	AuthorizationCodeAndSecret = "authorization_code+secret"
    42  	ImplicitGrant              = "token"
    43  	ImplicitGrantRedirectURL   = "token_redirect_url"
    44  	BIWebauth                  = "bi_webauth"
    45  	BIWebauthAndSecret         = "bi_webauth+secret"
    46  	BIWebview                  = "bi_webview"
    47  	BIWebviewAndSecret         = "bi_webview+secret"
    48  	SecretGrant                = "secret"
    49  )
    50  
    51  // Token Request authentication modes for AuthorizationCode grant type
    52  // normal is through form parameters
    53  // some services requires it as Basic
    54  const (
    55  	FormTokenAuthMode  = "form"
    56  	BasicTokenAuthMode = "basic"
    57  	GetTokenAuthMode   = "get"
    58  )
    59  
    60  // RefreshToken is the refresh grant type
    61  var RefreshToken = "refresh_token"
    62  
    63  // ErrUnrefreshable is the error when an account type or information
    64  // within an account does not allow refreshing it.
    65  var ErrUnrefreshable = errors.New("this account can not be refreshed")
    66  
    67  // AccountType holds configuration information for
    68  type AccountType struct {
    69  	DocID  string `json:"_id,omitempty"`
    70  	DocRev string `json:"_rev,omitempty"`
    71  	Slug   string `json:"slug,omitempty"`
    72  
    73  	// OAuth parameters
    74  	GrantMode             string            `json:"grant_mode,omitempty"`
    75  	ClientID              string            `json:"client_id,omitempty"`
    76  	ClientSecret          string            `json:"client_secret,omitempty"`
    77  	AuthEndpoint          string            `json:"auth_endpoint,omitempty"`
    78  	ManageEndpoint        string            `json:"manage_endpoint,omitempty"`
    79  	ReconnectEndpoint     string            `json:"reconnect_endpoint,omitempty"`
    80  	TokenEndpoint         string            `json:"token_endpoint,omitempty"`
    81  	TokenAuthMode         string            `json:"token_mode,omitempty"`
    82  	RegisteredRedirectURI string            `json:"redirect_uri,omitempty"`
    83  	ExtraAuthQuery        map[string]string `json:"extras,omitempty"`
    84  	SkipRedirectURI       bool              `json:"skip_redirect_uri_on_authorize,omitempty"`
    85  	SkipState             bool              `json:"skip_state_on_token,omitempty"`
    86  
    87  	// Other secrets that can be used by the konnectors
    88  	Secret interface{} `json:"secret,omitempty"`
    89  
    90  	// For sending notifications via Firebase Cloud Messaging
    91  	AndroidAPIKey string `json:"android_api_key"`
    92  }
    93  
    94  // ID is used to implement the couchdb.Doc interface
    95  func (at *AccountType) ID() string { return at.DocID }
    96  
    97  // Rev is used to implement the couchdb.Doc interface
    98  func (at *AccountType) Rev() string { return at.DocRev }
    99  
   100  // SetID is used to implement the couchdb.Doc interface
   101  func (at *AccountType) SetID(id string) { at.DocID = id }
   102  
   103  // SetRev is used to implement the couchdb.Doc interface
   104  func (at *AccountType) SetRev(rev string) { at.DocRev = rev }
   105  
   106  // DocType implements couchdb.Doc
   107  func (at *AccountType) DocType() string { return consts.AccountTypes }
   108  
   109  // Clone implements couchdb.Doc
   110  func (at *AccountType) Clone() couchdb.Doc {
   111  	cloned := *at
   112  	cloned.ExtraAuthQuery = make(map[string]string)
   113  	for k, v := range at.ExtraAuthQuery {
   114  		cloned.ExtraAuthQuery[k] = v
   115  	}
   116  	return &cloned
   117  }
   118  
   119  // ensure AccountType implements couchdb.Doc
   120  var _ couchdb.Doc = (*AccountType)(nil)
   121  
   122  // ServiceID is the ID, without the (optional) context prefix
   123  func (at *AccountType) ServiceID() string {
   124  	parts := strings.SplitN(at.DocID, "/", 2)
   125  	return parts[len(parts)-1]
   126  }
   127  
   128  // HasSecretGrant tells if the account type has non-OAuth secrets.
   129  func (at *AccountType) HasSecretGrant() bool {
   130  	return at.GrantMode == SecretGrant ||
   131  		at.GrantMode == BIWebauthAndSecret ||
   132  		at.GrantMode == BIWebviewAndSecret ||
   133  		at.GrantMode == AuthorizationCodeAndSecret
   134  }
   135  
   136  type tokenEndpointResponse struct {
   137  	RefreshToken     string `json:"refresh_token"`
   138  	AccessToken      string `json:"access_token"`
   139  	IDToken          string `json:"id_token"` // alternative name for access_token
   140  	ExpiresIn        int    `json:"expires_in"`
   141  	TokenType        string `json:"token_type"`
   142  	Error            string `json:"error"`
   143  	ErrorDescription string `json:"error_description"`
   144  }
   145  
   146  // RedirectURI returns the redirectURI for an account,
   147  // it can be either the
   148  func (at *AccountType) RedirectURI(i *instance.Instance) string {
   149  	redirectURI := i.PageURL("/accounts/"+at.ID()+"/redirect", nil)
   150  	if at.RegisteredRedirectURI != "" {
   151  		redirectURI = at.RegisteredRedirectURI
   152  	}
   153  	return redirectURI
   154  }
   155  
   156  // MakeOauthStartURL returns the url at which direct the user to start
   157  // the oauth flow
   158  func (at *AccountType) MakeOauthStartURL(i *instance.Instance, state string, params url.Values) (string, error) {
   159  	u, err := url.Parse(at.AuthEndpoint)
   160  	if err != nil {
   161  		return "", err
   162  	}
   163  	vv := u.Query()
   164  	redirectURI := at.RedirectURI(i)
   165  
   166  	// In theory, the scope and redirect_uri are mandatory, but some services
   167  	// don't support them and can even have an error 500 if they are present.
   168  	// See https://forum.cozy.io/t/custom-oauth/6835/3
   169  	if scope := params.Get("scope"); scope != "" {
   170  		vv.Add("scope", scope)
   171  	}
   172  	if !at.SkipRedirectURI && at.GrantMode != ImplicitGrantRedirectURL {
   173  		vv.Add("redirect_uri", redirectURI)
   174  	}
   175  
   176  	switch at.GrantMode {
   177  	case AuthorizationCode, AuthorizationCodeAndSecret:
   178  		vv.Add("response_type", "code")
   179  		vv.Add("client_id", at.ClientID)
   180  	case ImplicitGrant:
   181  		vv.Add("response_type", "token")
   182  		vv.Add("client_id", at.ClientID)
   183  	case ImplicitGrantRedirectURL:
   184  		vv.Add("response_type", "token")
   185  		vv.Add("redirect_url", redirectURI)
   186  	case BIWebview, BIWebviewAndSecret:
   187  		vv.Add("client_id", at.ClientID)
   188  		vv.Add("code", params.Get("token"))
   189  		if id := params.Get("id_connector"); id != "" {
   190  			vv.Add("connector_ids", id)
   191  		}
   192  		if id := params.Get("connector_uuids"); id != "" {
   193  			vv.Add("connector_uuids", id)
   194  		}
   195  	case BIWebauth, BIWebauthAndSecret:
   196  		vv.Add("client_id", at.ClientID)
   197  		vv.Add("token", params.Get("token"))
   198  		if source := params.Get("source"); source != "" {
   199  			vv.Add("source", source)
   200  		}
   201  		if id := params.Get("id_connector"); id != "" {
   202  			vv.Add("id_connector", id)
   203  		}
   204  		if id := params.Get("id_connection"); id != "" {
   205  			vv.Add("id_connection", id)
   206  		}
   207  	default:
   208  		return "", errors.New("Wrong account type")
   209  	}
   210  
   211  	vv.Add("state", state)
   212  	for k, v := range at.ExtraAuthQuery {
   213  		vv.Add(k, v)
   214  	}
   215  
   216  	u.RawQuery = vv.Encode()
   217  	return u.String(), nil
   218  }
   219  
   220  // RequestAccessToken asks the service an access token
   221  // https://tools.ietf.org/html/rfc6749#section-4
   222  func (at *AccountType) RequestAccessToken(i *instance.Instance, accessCode, state, nonce string) (*Account, error) {
   223  	data := url.Values{
   224  		"grant_type":   []string{AuthorizationCode},
   225  		"code":         []string{accessCode},
   226  		"redirect_uri": []string{at.RedirectURI(i)},
   227  	}
   228  
   229  	// Some OAuth providers require the state, and some others throw an error
   230  	// if it present. By default, the stack adds the state to the access token
   231  	// request, but this behavior can be disabled with an option on the account
   232  	// type. See https://forum.cozy.io/t/custom-oauth/6835/15
   233  	if !at.SkipState {
   234  		data.Add("state", state)
   235  	}
   236  
   237  	if nonce != "" {
   238  		data.Add("nonce", nonce)
   239  	}
   240  
   241  	if at.TokenAuthMode != BasicTokenAuthMode {
   242  		data.Add("client_id", at.ClientID)
   243  		data.Add("client_secret", at.ClientSecret)
   244  	}
   245  
   246  	body := data.Encode()
   247  	var req *http.Request
   248  	var err error
   249  	if at.TokenAuthMode == GetTokenAuthMode {
   250  		urlWithParams := at.TokenEndpoint + "?" + body
   251  		req, err = http.NewRequest("GET", urlWithParams, nil)
   252  		if err != nil {
   253  			return nil, err
   254  		}
   255  	} else {
   256  		req, err = http.NewRequest("POST", at.TokenEndpoint, strings.NewReader(body))
   257  		if err != nil {
   258  			return nil, err
   259  		}
   260  
   261  		req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationForm)
   262  		req.Header.Add(echo.HeaderAccept, echo.MIMEApplicationJSON)
   263  	}
   264  
   265  	if at.TokenAuthMode == BasicTokenAuthMode {
   266  		auth := []byte(at.ClientID + ":" + at.ClientSecret)
   267  		req.Header.Add(echo.HeaderAuthorization, "Basic "+base64.StdEncoding.EncodeToString(auth))
   268  	}
   269  
   270  	res, err := accountsClient.Do(req)
   271  	if err != nil {
   272  		return nil, err
   273  	}
   274  	defer res.Body.Close()
   275  
   276  	resBody, err := io.ReadAll(res.Body)
   277  	if res.StatusCode != 200 {
   278  		return nil, errors.New("oauth services responded with non-200 res: " + string(resBody))
   279  	}
   280  	if err != nil {
   281  		return nil, err
   282  	}
   283  
   284  	var out struct {
   285  		RefreshToken     string `json:"refresh_token"`
   286  		AccessToken      string `json:"access_token"`
   287  		IDToken          string `json:"id_token"` // alternative name for access_token
   288  		ExpiresIn        int    `json:"expires_in"`
   289  		TokenType        string `json:"token_type"`
   290  		Error            string `json:"error"`
   291  		ErrorDescription string `json:"error_description"`
   292  	}
   293  	err = json.Unmarshal(resBody, &out)
   294  	if err != nil {
   295  		return nil, err
   296  	}
   297  	if out.Error != "" {
   298  		return nil, fmt.Errorf("OauthError(%s) %s", out.Error, out.ErrorDescription)
   299  	}
   300  
   301  	var ExpiresAt time.Time
   302  	if out.ExpiresIn != 0 {
   303  		ExpiresAt = time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
   304  	}
   305  
   306  	account := &Account{
   307  		AccountType: at.ID(),
   308  		Oauth:       &OauthInfo{ExpiresAt: ExpiresAt},
   309  	}
   310  
   311  	if out.AccessToken == "" {
   312  		out.AccessToken = out.IDToken
   313  	}
   314  
   315  	if out.AccessToken == "" {
   316  		return nil, errors.New("server responded without access token")
   317  	}
   318  
   319  	account.Oauth.AccessToken = out.AccessToken
   320  	account.Oauth.RefreshToken = out.RefreshToken
   321  	account.Oauth.TokenType = out.TokenType
   322  
   323  	// decode same resBody into a map for non-standard fields
   324  	var extras map[string]interface{}
   325  	_ = json.Unmarshal(resBody, &extras)
   326  	delete(extras, "access_token")
   327  	delete(extras, "refresh_token")
   328  	delete(extras, "token_type")
   329  	delete(extras, "expires_in")
   330  
   331  	if len(extras) > 0 {
   332  		account.Extras = extras
   333  	}
   334  
   335  	return account, nil
   336  }
   337  
   338  // RefreshAccount requires a new AccessToken using the RefreshToken
   339  // as specified in https://tools.ietf.org/html/rfc6749#section-6
   340  func (at *AccountType) RefreshAccount(a Account) error {
   341  	if a.Oauth == nil {
   342  		return ErrUnrefreshable
   343  	}
   344  
   345  	// If no endpoint is specified for the account type, the stack just sends
   346  	// the client ID and client secret to the konnector and let it fetch the
   347  	// token its-self.
   348  	if a.Oauth.RefreshToken == "" {
   349  		a.Oauth.ClientID = at.ClientID
   350  		a.Oauth.ClientSecret = at.ClientSecret
   351  		return nil
   352  	}
   353  
   354  	res, err := http.PostForm(at.TokenEndpoint, url.Values{
   355  		"grant_type":    []string{RefreshToken},
   356  		"refresh_token": []string{a.Oauth.RefreshToken},
   357  		"client_id":     []string{at.ClientID},
   358  		"client_secret": []string{at.ClientSecret},
   359  	})
   360  
   361  	if err != nil {
   362  		return err
   363  	}
   364  
   365  	if res.StatusCode != 200 {
   366  		resBody, _ := io.ReadAll(res.Body)
   367  		return errors.New("oauth services responded with non-200 res: " + string(resBody))
   368  	}
   369  
   370  	var out tokenEndpointResponse
   371  	err = json.NewDecoder(res.Body).Decode(&out)
   372  	if err != nil {
   373  		return err
   374  	}
   375  
   376  	if out.Error != "" {
   377  		return fmt.Errorf("OauthError(%s) %s", out.Error, out.ErrorDescription)
   378  	}
   379  
   380  	if out.AccessToken != "" {
   381  		a.Oauth.AccessToken = out.AccessToken
   382  	}
   383  
   384  	if out.ExpiresIn != 0 {
   385  		a.Oauth.ExpiresAt = time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
   386  	}
   387  
   388  	if out.RefreshToken != "" {
   389  		a.Oauth.RefreshToken = out.RefreshToken
   390  	}
   391  
   392  	return nil
   393  }
   394  
   395  // MakeManageURL returns the url at which the user can be redirected to access
   396  // the BI manage webview
   397  func (at *AccountType) MakeManageURL(i *instance.Instance, state string, params url.Values) (string, error) {
   398  	switch at.GrantMode {
   399  	case BIWebauth, BIWebauthAndSecret, BIWebview, BIWebviewAndSecret:
   400  		// OK
   401  	default:
   402  		return "", errors.New("Wrong account type")
   403  	}
   404  
   405  	u, err := url.Parse(at.ManageEndpoint)
   406  	if err != nil {
   407  		return "", err
   408  	}
   409  	vv := u.Query()
   410  	vv.Add("client_id", at.ClientID)
   411  	vv.Add("code", params.Get("code"))
   412  	vv.Add("connection_id", params.Get("connection_id"))
   413  	vv.Add("redirect_uri", at.RedirectURI(i))
   414  	vv.Add("state", state)
   415  	u.RawQuery = vv.Encode()
   416  	return u.String(), nil
   417  }
   418  
   419  // MakeReconnectURL returns the url at which the user can be redirected for a
   420  // BI webauth reconnect flow.
   421  func (at *AccountType) MakeReconnectURL(i *instance.Instance, state string, params url.Values) (string, error) {
   422  	switch at.GrantMode {
   423  	case BIWebauth, BIWebauthAndSecret, BIWebview, BIWebviewAndSecret:
   424  		// OK
   425  	default:
   426  		return "", errors.New("Wrong account type")
   427  	}
   428  
   429  	u, err := url.Parse(at.ReconnectEndpoint)
   430  	if err != nil {
   431  		return "", err
   432  	}
   433  	vv := u.Query()
   434  	vv.Add("client_id", at.ClientID)
   435  	vv.Add("code", params.Get("code"))
   436  	vv.Add("connection_id", params.Get("connection_id"))
   437  	vv.Add("redirect_uri", at.RedirectURI(i))
   438  	vv.Add("state", state)
   439  	u.RawQuery = vv.Encode()
   440  	return u.String(), nil
   441  }
   442  
   443  // TypeInfo returns the AccountType document for a given id
   444  func TypeInfo(id, contextName string) (*AccountType, error) {
   445  	if id == "" {
   446  		return nil, errors.New("no account type id provided")
   447  	}
   448  	var a AccountType
   449  	err := couchdb.GetDoc(prefixer.SecretsPrefixer, consts.AccountTypes, contextName+"/"+id, &a)
   450  	if couchdb.IsNotFoundError(err) {
   451  		err = couchdb.GetDoc(prefixer.SecretsPrefixer, consts.AccountTypes, id, &a)
   452  	}
   453  	if err != nil {
   454  		return nil, err
   455  	}
   456  	return &a, nil
   457  }
   458  
   459  // FindAccountTypesBySlug returns the AccountType documents for the given slug
   460  func FindAccountTypesBySlug(slug, contextName string) ([]*AccountType, error) {
   461  	var docs []*AccountType
   462  	req := &couchdb.FindRequest{
   463  		UseIndex: "by-slug",
   464  		Selector: mango.Equal("slug", slug),
   465  		Limit:    100,
   466  	}
   467  	err := couchdb.FindDocs(prefixer.SecretsPrefixer, consts.AccountTypes, req, &docs)
   468  	if err != nil {
   469  		return nil, err
   470  	}
   471  	return filterByContext(docs, contextName), nil
   472  }
   473  
   474  func filterByContext(types []*AccountType, contextName string) []*AccountType {
   475  	var filtered []*AccountType
   476  
   477  	// First, take the account types specific to this context
   478  	for _, t := range types {
   479  		parts := strings.SplitN(t.DocID, "/", 2)
   480  		if len(parts) == 2 && parts[0] == contextName {
   481  			filtered = append(filtered, t)
   482  		}
   483  	}
   484  
   485  	// Then, take the global account types that have not been overloaded
   486  	for _, t := range types {
   487  		parts := strings.SplitN(t.DocID, "/", 2)
   488  		if len(parts) == 1 {
   489  			overloaded := false
   490  			for _, typ := range filtered {
   491  				if typ.DocID == contextName+"/"+t.DocID {
   492  					overloaded = true
   493  					break
   494  				}
   495  			}
   496  			if !overloaded {
   497  				filtered = append(filtered, t)
   498  			}
   499  		}
   500  	}
   501  
   502  	return filtered
   503  }