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 }