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 }