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