github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/oauth/client.go (about) 1 // Package oauth declares the OAuth client, and things related to them, from 2 // the certification of the flagship app to the creation of the access codes in 3 // the OAuth2 flow. 4 package oauth 5 6 import ( 7 "encoding/json" 8 "errors" 9 "fmt" 10 "net/http" 11 "net/url" 12 "sort" 13 "strconv" 14 "strings" 15 "time" 16 17 "github.com/cozy/cozy-stack/model/bitwarden/settings" 18 "github.com/cozy/cozy-stack/model/feature" 19 "github.com/cozy/cozy-stack/model/instance" 20 "github.com/cozy/cozy-stack/model/job" 21 "github.com/cozy/cozy-stack/model/notification" 22 "github.com/cozy/cozy-stack/model/permission" 23 "github.com/cozy/cozy-stack/pkg/consts" 24 "github.com/cozy/cozy-stack/pkg/couchdb" 25 "github.com/cozy/cozy-stack/pkg/couchdb/mango" 26 "github.com/cozy/cozy-stack/pkg/crypto" 27 "github.com/cozy/cozy-stack/pkg/metadata" 28 "github.com/cozy/cozy-stack/pkg/prefixer" 29 "github.com/cozy/cozy-stack/pkg/registry" 30 31 jwt "github.com/golang-jwt/jwt/v5" 32 ) 33 34 const ( 35 // PlatformFirebase platform using Firebase Cloud Messaging (FCM) 36 PlatformFirebase = "firebase" 37 // PlatformAPNS platform using APNS/2 38 PlatformAPNS = "apns" 39 // PlatformHuawei platform using Huawei Push Kit 40 PlatformHuawei = "huawei" 41 ) 42 43 // DocTypeVersion represents the doctype version. Each time this document 44 // structure is modified, update this value 45 const DocTypeVersion = "1" 46 47 // ClientSecretLen is the number of random bytes used for generating the client secret 48 const ClientSecretLen = 24 49 50 // ChallengeLen is the number of random bytes used for generating a nonce for 51 // certifying an android/iOS app. 52 const ChallengeLen = 24 53 54 // ScopeLogin is the special scope used by the manager or any other client 55 // for login/authentication purposes. 56 const ScopeLogin = "login" 57 58 // CleanMessage is used for messages to the clean-clients worker. 59 type CleanMessage struct { 60 ClientID string `json:"client_id"` 61 } 62 63 // Client is a struct for OAuth2 client. Most of the fields are described in 64 // the OAuth 2.0 Dynamic Client Registration Protocol. The exception is 65 // `client_kind`, and it is an optional field. 66 // See https://tools.ietf.org/html/rfc7591 67 // 68 // CouchID and ClientID are the same. They are just two ways to serialize to 69 // JSON, one for CouchDB and the other for the Dynamic Client Registration 70 // Protocol. 71 type Client struct { 72 CouchID string `json:"_id,omitempty"` // Generated by CouchDB 73 CouchRev string `json:"_rev,omitempty"` // Generated by CouchDB 74 75 ClientID string `json:"client_id,omitempty"` // Same as CouchID 76 ClientSecret string `json:"client_secret,omitempty"` // Generated by the server 77 SecretExpiresAt int `json:"client_secret_expires_at"` // Forced by the server to 0 (no expiration) 78 RegistrationToken string `json:"registration_access_token,omitempty"` // Generated by the server 79 AllowLoginScope bool `json:"allow_login_scope,omitempty"` // Allow to generate token for a "login" scope (no permissions) 80 Pending bool `json:"pending,omitempty"` // True until a token is generated 81 82 RedirectURIs []string `json:"redirect_uris"` // Declared by the client (mandatory) 83 GrantTypes []string `json:"grant_types"` // Forced by the server to ["authorization_code", "refresh_token"] 84 ResponseTypes []string `json:"response_types"` // Forced by the server to ["code"] 85 ClientName string `json:"client_name"` // Declared by the client (mandatory) 86 ClientKind string `json:"client_kind,omitempty"` // Declared by the client (optional, can be "desktop", "mobile", "browser", etc.) 87 ClientURI string `json:"client_uri,omitempty"` // Declared by the client (optional) 88 LogoURI string `json:"logo_uri,omitempty"` // Declared by the client (optional) 89 PolicyURI string `json:"policy_uri,omitempty"` // Declared by the client (optional) 90 SoftwareID string `json:"software_id"` // Declared by the client (mandatory) 91 SoftwareVersion string `json:"software_version,omitempty"` // Declared by the client (optional) 92 ClientOS string `json:"client_os,omitempty"` // Inferred by the server from the user-agent 93 94 // Notifications parameters 95 Notifications map[string]notification.Properties `json:"notifications,omitempty"` 96 97 NotificationPlatform string `json:"notification_platform,omitempty"` // Declared by the client (optional) 98 NotificationDeviceToken string `json:"notification_device_token,omitempty"` // Declared by the client (optional) 99 100 // XXX omitempty does not work for time.Time, thus the interface{} type 101 SynchronizedAt interface{} `json:"synchronized_at,omitempty"` // Date of the last synchronization, updated by /settings/synchronized 102 LastRefreshedAt interface{} `json:"last_refreshed_at,omitempty"` // Date of the last refresh of the OAuth token 103 104 Flagship bool `json:"flagship,omitempty"` 105 CertifiedFromStore bool `json:"certified_from_store,omitempty"` 106 CreatedAtOnboarding bool `json:"created_at_onboarding,omitempty"` 107 108 Metadata *metadata.CozyMetadata `json:"cozyMetadata,omitempty"` 109 } 110 111 // ID returns the client qualified identifier 112 func (c *Client) ID() string { return c.CouchID } 113 114 // Rev returns the client revision 115 func (c *Client) Rev() string { return c.CouchRev } 116 117 // DocType returns the client document type 118 func (c *Client) DocType() string { return consts.OAuthClients } 119 120 // Clone implements couchdb.Doc 121 func (c *Client) Clone() couchdb.Doc { 122 cloned := *c 123 cloned.RedirectURIs = make([]string, len(c.RedirectURIs)) 124 copy(cloned.RedirectURIs, c.RedirectURIs) 125 126 cloned.GrantTypes = make([]string, len(c.GrantTypes)) 127 copy(cloned.GrantTypes, c.GrantTypes) 128 129 cloned.ResponseTypes = make([]string, len(c.ResponseTypes)) 130 copy(cloned.ResponseTypes, c.ResponseTypes) 131 132 cloned.Notifications = make(map[string]notification.Properties) 133 for k, v := range c.Notifications { 134 props := (&v).Clone() 135 cloned.Notifications[k] = *props 136 } 137 if c.Metadata != nil { 138 cloned.Metadata = c.Metadata.Clone() 139 } 140 return &cloned 141 } 142 143 // SetID changes the client qualified identifier 144 func (c *Client) SetID(id string) { c.CouchID = id } 145 146 // SetRev changes the client revision 147 func (c *Client) SetRev(rev string) { c.CouchRev = rev } 148 149 // TransformIDAndRev makes the translation from the JSON of CouchDB to the 150 // one used in the dynamic client registration protocol 151 func (c *Client) TransformIDAndRev() { 152 c.ClientID = c.CouchID 153 c.CouchID = "" 154 c.CouchRev = "" 155 } 156 157 // GetAll loads all the clients from the database, without the secret 158 func GetAll(inst *instance.Instance, limit int, bookmark string) ([]*Client, string, error) { 159 res, err := couchdb.NormalDocs(inst, consts.OAuthClients, 0, limit, bookmark, false) 160 if err != nil { 161 return nil, "", err 162 } 163 clients := make([]*Client, len(res.Rows)) 164 for i, row := range res.Rows { 165 var client Client 166 if err := json.Unmarshal(row, &client); err != nil { 167 return nil, "", err 168 } 169 client.ClientSecret = "" 170 clients[i] = &client 171 } 172 return clients, res.Bookmark, nil 173 } 174 175 // GetNotifiables loads all the clients from the database containing a non-empty 176 // `notification_plaform` field. 177 func GetNotifiables(i *instance.Instance) ([]*Client, error) { 178 var clients []*Client 179 req := &couchdb.FindRequest{ 180 UseIndex: "by-notification-platform", 181 Selector: mango.And( 182 mango.Exists("notification_platform"), 183 mango.Exists("notification_device_token"), 184 ), 185 Limit: 100, 186 } 187 err := couchdb.FindDocs(i, consts.OAuthClients, req, &clients) 188 if err != nil { 189 return nil, err 190 } 191 // XXX the sort is done here, not via the mango request as some old clients 192 // can have no cozyMetadata 193 SortClientsByCreatedAtDesc(clients) 194 return clients, nil 195 } 196 197 func GetConnectedUserClients(i *instance.Instance, limit int, bookmark string) ([]*Client, string, error) { 198 // Return clients with client_kind mobile, browser and desktop 199 var clients []*Client 200 req := &couchdb.FindRequest{ 201 UseIndex: "connected-user-clients", 202 Selector: mango.And(mango.Gt("client_kind", ""), mango.Gt("client_name", "")), 203 Bookmark: bookmark, 204 Limit: limit, 205 } 206 res, err := couchdb.FindDocsRaw(i, consts.OAuthClients, req, &clients) 207 if err != nil { 208 return nil, "", err 209 } 210 211 for _, client := range clients { 212 client.ClientSecret = "" 213 } 214 215 return clients, res.Bookmark, nil 216 } 217 218 func SortClientsByCreatedAtDesc(clients []*Client) { 219 sort.SliceStable(clients, func(i, j int) bool { 220 a := clients[i] 221 b := clients[j] 222 if a.Metadata == nil { 223 return false 224 } 225 if b.Metadata == nil { 226 return true 227 } 228 return b.Metadata.CreatedAt.Before(a.Metadata.CreatedAt) 229 }) 230 } 231 232 // FindClient loads a client from the database 233 func FindClient(i *instance.Instance, id string) (*Client, error) { 234 var c Client 235 if err := couchdb.GetDoc(i, consts.OAuthClients, id, &c); err != nil { 236 return nil, err 237 } 238 if c.ClientID == "" { 239 c.ClientID = c.CouchID 240 } 241 return &c, nil 242 } 243 244 // FindClientBySoftwareID loads a client from the database 245 func FindClientBySoftwareID(i *instance.Instance, softwareID string) (*Client, error) { 246 var results []*Client 247 248 req := couchdb.FindRequest{ 249 Selector: mango.Equal("software_id", softwareID), 250 Limit: 1, 251 } 252 // We should have very few requests. Only on instance creation. 253 err := couchdb.FindDocsUnoptimized(i, consts.OAuthClients, &req, &results) 254 if err != nil { 255 return nil, err 256 } 257 if len(results) == 1 { 258 return results[0], nil 259 } 260 return nil, fmt.Errorf("Could not find client with software_id %s", softwareID) 261 } 262 263 // FindClientByOnBoardingSecret loads a client from the database with an OnboardingSecret 264 func FindClientByOnBoardingSecret(i *instance.Instance, onboardingSecret string) (*Client, error) { 265 var results []*Client 266 267 req := couchdb.FindRequest{ 268 Selector: mango.Equal("onboarding_secret", onboardingSecret), 269 Limit: 1, 270 } 271 // We should have very few requests. Only on instance creation. 272 err := couchdb.FindDocsUnoptimized(i, consts.OAuthClients, &req, &results) 273 if err != nil { 274 return nil, err 275 } 276 if len(results) == 1 { 277 return results[0], nil 278 } 279 return nil, fmt.Errorf("Could not find client with onboarding_secret %s", onboardingSecret) 280 } 281 282 // FindOnboardingClient loads a client from the database with an OnboardingSecret 283 func FindOnboardingClient(i *instance.Instance) (*Client, error) { 284 var results []*Client 285 286 req := couchdb.FindRequest{ 287 Selector: mango.Exists("onboarding_secret"), 288 Limit: 1, 289 } 290 // We should have very few requests. Only on instance creation. 291 err := couchdb.FindDocsUnoptimized(i, consts.OAuthClients, &req, &results) 292 if err != nil { 293 return nil, err 294 } 295 if len(results) == 1 { 296 return results[0], nil 297 } 298 return nil, fmt.Errorf("Could not find client with an onboarding_secret") 299 } 300 301 // ClientRegistrationError is a Client Registration Error Response, as described 302 // in the Client Dynamic Registration Protocol 303 // See https://tools.ietf.org/html/rfc7591#section-3.2.2 for errors 304 type ClientRegistrationError struct { 305 Code int `json:"-"` 306 Error string `json:"error"` 307 Description string `json:"error_description,omitempty"` 308 } 309 310 func (c *Client) checkMandatoryFields(i *instance.Instance) *ClientRegistrationError { 311 if len(c.RedirectURIs) == 0 { 312 return &ClientRegistrationError{ 313 Code: http.StatusBadRequest, 314 Error: "invalid_redirect_uri", 315 Description: "redirect_uris is mandatory", 316 } 317 } 318 for _, redirectURI := range c.RedirectURIs { 319 u, err := url.Parse(redirectURI) 320 if err != nil || 321 u.Host == i.Domain || 322 u.Fragment != "" { 323 return &ClientRegistrationError{ 324 Code: http.StatusBadRequest, 325 Error: "invalid_redirect_uri", 326 Description: fmt.Sprintf("%s is invalid", redirectURI), 327 } 328 } 329 } 330 if c.ClientName == "" { 331 return &ClientRegistrationError{ 332 Code: http.StatusBadRequest, 333 Error: "invalid_client_metadata", 334 Description: "client_name is mandatory", 335 } 336 } 337 if c.SoftwareID == "" { 338 return &ClientRegistrationError{ 339 Code: http.StatusBadRequest, 340 Error: "invalid_client_metadata", 341 Description: "software_id is mandatory", 342 } 343 } 344 c.NotificationPlatform = strings.ToLower(c.NotificationPlatform) 345 switch c.NotificationPlatform { 346 case "", PlatformFirebase, PlatformAPNS, PlatformHuawei: 347 case "ios", "android": // retro-compatibility 348 default: 349 return &ClientRegistrationError{ 350 Code: http.StatusBadRequest, 351 Error: "invalid_client_metadata", 352 } 353 } 354 return nil 355 } 356 357 // CheckSoftwareID checks if a SoftwareID is valid 358 func (c *Client) CheckSoftwareID(instance *instance.Instance) *ClientRegistrationError { 359 if strings.HasPrefix(c.SoftwareID, "registry://") { 360 appSlug := strings.TrimPrefix(c.SoftwareID, "registry://") 361 if appSlug == consts.StoreSlug || appSlug == consts.SettingsSlug { 362 return &ClientRegistrationError{ 363 Code: http.StatusBadRequest, 364 Error: "unapproved_software_id", 365 Description: "Link with store/settings is forbidden", 366 } 367 } 368 _, err := registry.GetApplication(appSlug, instance.Registries()) 369 if err != nil { 370 return &ClientRegistrationError{ 371 Code: http.StatusBadRequest, 372 Error: "unapproved_software_id", 373 Description: "Application was not found on instance registries", 374 } 375 } 376 } 377 return nil 378 } 379 380 // CreateOptions can be used to give options when creating an OAuth client 381 type CreateOptions int 382 383 const ( 384 // NotPending option won't set the pending flag, and will avoid creating a 385 // trigger to check if the client should be cleaned. It is used for 386 // sharings by example, as a token is created just after the client 387 // creation. 388 NotPending CreateOptions = iota + 1 389 ) 390 391 func hasOptions(needle CreateOptions, haystack []CreateOptions) bool { 392 for _, opt := range haystack { 393 if opt == needle { 394 return true 395 } 396 } 397 return false 398 } 399 400 func (c *Client) ensureClientNameUnicity(i *instance.Instance) error { 401 var results []*Client 402 req := &couchdb.FindRequest{ 403 UseIndex: "by-client-name", 404 Selector: mango.StartWith("client_name", c.ClientName), 405 Limit: 1000, 406 } 407 err := couchdb.FindDocsUnoptimized(i, consts.OAuthClients, req, &results) 408 if err != nil && !couchdb.IsNoDatabaseError(err) { 409 i.Logger().WithNamespace("oauth"). 410 Warnf("Cannot find clients by name: %s", err) 411 return err 412 } 413 414 // Find the correct suffix to apply to the client name in case it is already 415 // used. 416 suffix := "" 417 if len(results) > 0 { 418 n := 1 419 found := false 420 prefix := c.ClientName + "-" 421 for _, r := range results { 422 name := r.ClientName 423 if name == c.ClientName { 424 found = true 425 continue 426 } 427 if !strings.HasPrefix(name, prefix) { 428 continue 429 } 430 var m int 431 m, err = strconv.Atoi(name[len(prefix):]) 432 if err == nil && m > n { 433 n = m 434 } 435 } 436 if found { 437 suffix = strconv.Itoa(n + 1) 438 } 439 } 440 if suffix != "" { 441 c.ClientName = c.ClientName + "-" + suffix 442 } 443 444 return nil 445 } 446 447 // Create is a function that sets some fields, and then save it in Couch. 448 func (c *Client) Create(i *instance.Instance, opts ...CreateOptions) *ClientRegistrationError { 449 if err := c.checkMandatoryFields(i); err != nil { 450 return err 451 } 452 if err := c.CheckSoftwareID(i); err != nil { 453 return err 454 } 455 456 if err := c.ensureClientNameUnicity(i); err != nil { 457 return &ClientRegistrationError{ 458 Code: http.StatusInternalServerError, 459 Error: "internal_server_error", 460 } 461 } 462 463 if !hasOptions(NotPending, opts) { 464 c.Pending = true 465 } 466 c.CouchID = "" 467 c.CouchRev = "" 468 c.ClientID = "" 469 secret := crypto.GenerateRandomBytes(ClientSecretLen) 470 c.ClientSecret = string(crypto.Base64Encode(secret)) 471 c.SecretExpiresAt = 0 472 c.RegistrationToken = "" 473 c.GrantTypes = []string{"authorization_code", "refresh_token"} 474 c.ResponseTypes = []string{"code"} 475 476 // Adding Metadata 477 md := metadata.New() 478 if strings.HasPrefix(c.SoftwareID, "registry://") { 479 md.CreatedByApp = strings.TrimPrefix(c.SoftwareID, "registry://") 480 md.CreatedByAppVersion = c.SoftwareVersion 481 } 482 md.DocTypeVersion = DocTypeVersion 483 c.Metadata = md 484 485 if err := couchdb.CreateDoc(i, c); err != nil { 486 i.Logger().WithNamespace("oauth"). 487 Warnf("Cannot create client: %s", err) 488 return &ClientRegistrationError{ 489 Code: http.StatusInternalServerError, 490 Error: "internal_server_error", 491 } 492 } 493 494 if !hasOptions(NotPending, opts) { 495 if err := setupTrigger(i, c.CouchID); err != nil { 496 i.Logger().WithNamespace("oauth"). 497 Warnf("Cannot create trigger: %s", err) 498 } 499 } 500 501 var err error 502 c.RegistrationToken, err = crypto.NewJWT(i.OAuthSecret, jwt.RegisteredClaims{ 503 Audience: jwt.ClaimStrings{consts.RegistrationTokenAudience}, 504 Issuer: i.Domain, 505 IssuedAt: jwt.NewNumericDate(time.Now()), 506 Subject: c.CouchID, 507 }) 508 if err != nil { 509 i.Logger().WithNamespace("oauth"). 510 Errorf("Failed to create the registration access token: %s", err) 511 return &ClientRegistrationError{ 512 Code: http.StatusInternalServerError, 513 Error: "internal_server_error", 514 } 515 } 516 517 c.TransformIDAndRev() 518 519 if !c.Pending { 520 flags, err := feature.GetFlags(i) 521 if err != nil { 522 i.Logger().WithNamespace("oauth"). 523 Errorf("Failed to get the OAuth clients limit: %s", err) 524 return nil 525 } 526 527 limit := -1 528 if clientsLimit, ok := flags.M["cozy.oauthclients.max"].(float64); ok && clientsLimit >= 0 { 529 limit = int(clientsLimit) 530 } 531 _, exceeded := CheckOAuthClientsLimitReached(i, limit) 532 if exceeded { 533 PushClientsLimitAlert(i, c.ClientName, limit) 534 } 535 return nil 536 } 537 return nil 538 } 539 540 func setupTrigger(inst *instance.Instance, clientID string) error { 541 sched := job.System() 542 msg := &CleanMessage{ClientID: clientID} 543 t, err := job.NewTrigger(inst, job.TriggerInfos{ 544 Type: "@in", 545 WorkerType: "clean-clients", 546 Arguments: "1h", 547 }, msg) 548 if err != nil { 549 return err 550 } 551 return sched.AddTrigger(t) 552 } 553 554 // Update will update the client metadata 555 func (c *Client) Update(i *instance.Instance, old *Client) *ClientRegistrationError { 556 if c.ClientID != old.ClientID { 557 return &ClientRegistrationError{ 558 Code: http.StatusBadRequest, 559 Error: "invalid_client_id", 560 Description: "client_id is mandatory", 561 } 562 } 563 564 if err := c.checkMandatoryFields(i); err != nil { 565 return err 566 } 567 568 switch c.ClientSecret { 569 case "": 570 c.ClientSecret = old.ClientSecret 571 case old.ClientSecret: 572 secret := crypto.GenerateRandomBytes(ClientSecretLen) 573 c.ClientSecret = string(crypto.Base64Encode(secret)) 574 default: 575 return &ClientRegistrationError{ 576 Code: http.StatusBadRequest, 577 Error: "invalid_client_secret", 578 Description: "client_secret is invalid", 579 } 580 } 581 582 c.CouchID = old.CouchID 583 c.CouchRev = old.CouchRev 584 c.ClientID = "" 585 c.SecretExpiresAt = 0 586 c.RegistrationToken = "" 587 c.GrantTypes = []string{"authorization_code", "refresh_token"} 588 c.ResponseTypes = []string{"code"} 589 c.AllowLoginScope = old.AllowLoginScope 590 591 if c.ClientName != old.ClientName { 592 if err := c.ensureClientNameUnicity(i); err != nil { 593 return &ClientRegistrationError{ 594 Code: http.StatusInternalServerError, 595 Error: "internal_server_error", 596 } 597 } 598 } 599 600 c.Flagship = old.Flagship 601 c.CertifiedFromStore = old.CertifiedFromStore 602 603 // Updating metadata 604 md := metadata.New() 605 if strings.HasPrefix(c.SoftwareID, "registry://") { 606 md.CreatedByApp = strings.TrimPrefix(c.SoftwareID, "registry://") 607 md.CreatedByAppVersion = c.SoftwareVersion 608 } 609 md.DocTypeVersion = DocTypeVersion 610 611 if old.Metadata == nil { 612 c.Metadata = md 613 } else { 614 c.Metadata = old.Metadata 615 c.Metadata.ChangeUpdatedAt() 616 } 617 618 if err := couchdb.UpdateDoc(i, c); err != nil { 619 if couchdb.IsConflictError(err) { 620 return &ClientRegistrationError{ 621 Code: http.StatusConflict, 622 Error: "conflict", 623 } 624 } 625 return &ClientRegistrationError{ 626 Code: http.StatusInternalServerError, 627 Error: "internal_server_error", 628 } 629 } 630 631 c.TransformIDAndRev() 632 return nil 633 } 634 635 // Delete is a function that unregister a client 636 func (c *Client) Delete(i *instance.Instance) *ClientRegistrationError { 637 if err := couchdb.DeleteDoc(i, c); err != nil { 638 return &ClientRegistrationError{ 639 Code: http.StatusInternalServerError, 640 Error: "internal_server_error", 641 } 642 } 643 644 var last *time.Time 645 if at, ok := c.LastRefreshedAt.(string); ok { 646 if t, err := time.Parse(time.RFC3339Nano, at); err == nil { 647 last = &t 648 } 649 } 650 if at, ok := c.SynchronizedAt.(string); ok { 651 if t, err := time.Parse(time.RFC3339Nano, at); err == nil { 652 if last == nil || last.Before(t) { 653 last = &t 654 } 655 } 656 } 657 if last != nil { 658 if i.LastActivityFromDeletedOAuthClients == nil || i.LastActivityFromDeletedOAuthClients.Before(*last) { 659 i.LastActivityFromDeletedOAuthClients = last 660 if err := couchdb.UpdateDoc(prefixer.GlobalPrefixer, i); err != nil { 661 i.Logger().Warnf("Cannot update last activity for %q: %s", i.Domain, err) 662 } 663 } 664 } 665 666 return nil 667 } 668 669 // CreateChallenge can be used to generate a challenge for certifying the app. 670 func (c *Client) CreateChallenge(inst *instance.Instance) (string, error) { 671 nonce := crypto.GenerateRandomString(ChallengeLen) 672 store := GetStore() 673 if err := store.SaveChallenge(inst, c.ID(), nonce); err != nil { 674 return "", err 675 } 676 inst.Logger().Debugf("OAuth client %s has requested a challenge: %s", c.ID(), nonce) 677 return nonce, nil 678 } 679 680 // AttestationRequest is what an OAuth client can send to attest that it is the 681 // flagship app. 682 type AttestationRequest struct { 683 Platform string `json:"platform"` 684 Issuer string `json:"issuer"` 685 Challenge string `json:"challenge"` 686 Attestation string `json:"attestation"` 687 KeyID []byte `json:"keyId"` 688 } 689 690 // Attest can be used to check an attestation for certifying the app. 691 func (c *Client) Attest(inst *instance.Instance, req AttestationRequest) error { 692 var err error 693 switch req.Platform { 694 case "android": 695 if req.Issuer == "playintegrity" { 696 err = c.checkPlayIntegrityAttestation(inst, req) 697 } else { 698 err = c.checkSafetyNetAttestation(inst, req) 699 } 700 case "ios": 701 err = c.checkAppleAttestation(inst, req) 702 default: 703 err = errors.New("invalid platform") 704 } 705 if err != nil { 706 return err 707 } 708 709 c.CertifiedFromStore = true 710 return c.SetFlagship(inst) 711 } 712 713 // SetFlagship updates the client in CouchDB with flagship set to true. 714 func (c *Client) SetFlagship(inst *instance.Instance) error { 715 c.Flagship = true 716 c.ClientID = "" 717 if c.Metadata == nil { 718 md := metadata.New() 719 md.DocTypeVersion = DocTypeVersion 720 c.Metadata = md 721 } else { 722 c.Metadata.ChangeUpdatedAt() 723 } 724 return couchdb.UpdateDoc(inst, c) 725 } 726 727 // SetCreatedAtOnboarding updates the client in CouchDB with 728 // created_at_onboarding set to true. 729 func (c *Client) SetCreatedAtOnboarding(inst *instance.Instance) error { 730 c.CreatedAtOnboarding = true 731 c.ClientID = "" 732 if c.Metadata == nil { 733 md := metadata.New() 734 md.DocTypeVersion = DocTypeVersion 735 c.Metadata = md 736 } else { 737 c.Metadata.ChangeUpdatedAt() 738 } 739 return couchdb.UpdateDoc(inst, c) 740 } 741 742 // AcceptRedirectURI returns true if the given URI matches the registered 743 // redirect_uris 744 func (c *Client) AcceptRedirectURI(u string) bool { 745 for _, uri := range c.RedirectURIs { 746 if u == uri { 747 return true 748 } 749 } 750 return false 751 } 752 753 // CreateJWT returns a new JSON Web Token for the given instance and audience 754 func (c *Client) CreateJWT(i *instance.Instance, audience, scope string) (string, error) { 755 token, err := crypto.NewJWT(i.OAuthSecret, permission.Claims{ 756 RegisteredClaims: jwt.RegisteredClaims{ 757 Audience: jwt.ClaimStrings{audience}, 758 Issuer: i.Domain, 759 IssuedAt: jwt.NewNumericDate(time.Now()), 760 Subject: c.CouchID, 761 }, 762 Scope: scope, 763 }) 764 if err != nil { 765 i.Logger().WithNamespace("oauth"). 766 Errorf("Failed to create the %s token: %s", audience, err) 767 } 768 return token, err 769 } 770 771 func validToken(i *instance.Instance, audience, token string) (permission.Claims, bool) { 772 claims := permission.Claims{} 773 if token == "" { 774 return claims, false 775 } 776 keyFunc := func(token *jwt.Token) (interface{}, error) { 777 return i.OAuthSecret, nil 778 } 779 if err := crypto.ParseJWT(token, keyFunc, &claims); err != nil { 780 i.Logger().WithNamespace("oauth"). 781 Errorf("Failed to verify the %s token: %s", audience, err) 782 return claims, false 783 } 784 if claims.Expired() { 785 i.Logger().WithNamespace("oauth"). 786 Errorf("Failed to verify the %s token: expired", audience) 787 return claims, false 788 } 789 // Note: the refresh and registration tokens don't expire, no need to check its issue date 790 if claims.AudienceString() != audience { 791 i.Logger().WithNamespace("oauth"). 792 Errorf("Unexpected audience for %s token: %v", audience, claims.Audience) 793 return claims, false 794 } 795 if claims.Issuer != i.Domain { 796 i.Logger().WithNamespace("oauth"). 797 Errorf("Expected %s issuer for %s token, but was: %s", audience, i.Domain, claims.Issuer) 798 return claims, false 799 } 800 return claims, true 801 } 802 803 // ValidTokenWithSStamp checks that the JWT is valid and returns the associate 804 // claims. You should use client.ValidToken if you know the client, as it also 805 // checks that the claims are associated to this client. 806 func ValidTokenWithSStamp(i *instance.Instance, audience, token string) (permission.Claims, bool) { 807 claims, valid := validToken(i, audience, token) 808 if !valid { 809 return claims, valid 810 } 811 settings, err := settings.Get(i) 812 if err != nil { 813 i.Logger().WithNamespace("oauth"). 814 Errorf("Error while getting bitwarden settings: %s", err) 815 return claims, false 816 } 817 if claims.SStamp != settings.SecurityStamp { 818 i.Logger().WithNamespace("oauth"). 819 Errorf("Expected %s security stamp for %s token, but was: %s", 820 settings.SecurityStamp, claims.Subject, claims.SStamp) 821 return claims, false 822 } 823 return claims, true 824 } 825 826 // ValidToken checks that the JWT is valid and returns the associate claims. 827 // It is expected to be used for registration token and refresh token, and 828 // it doesn't check when they were issued as they don't expire. 829 func (c *Client) ValidToken(i *instance.Instance, audience, token string) (permission.Claims, bool) { 830 claims, valid := validToken(i, audience, token) 831 if !valid { 832 return claims, valid 833 } 834 if claims.Subject != c.CouchID { 835 i.Logger().WithNamespace("oauth"). 836 Errorf("Expected %s subject for %s token, but was: %s", audience, c.CouchID, claims.Subject) 837 return claims, false 838 } 839 return claims, true 840 } 841 842 // IsLinkedApp checks if an OAuth client has a linked app 843 func IsLinkedApp(softwareID string) bool { 844 return strings.HasPrefix(softwareID, "registry://") 845 } 846 847 // GetLinkedAppSlug returns a linked app slug from a softwareID 848 func GetLinkedAppSlug(softwareID string) string { 849 if !IsLinkedApp(softwareID) { 850 return "" 851 } 852 return strings.TrimPrefix(softwareID, "registry://") 853 } 854 855 // BuildLinkedAppScope returns a formatted scope for a linked app 856 func BuildLinkedAppScope(slug string) string { 857 return fmt.Sprintf("@%s/%s", consts.Apps, slug) 858 } 859 860 func CheckOAuthClientsLimitReached(i *instance.Instance, limit int) (reached, exceeded bool) { 861 if limit == -1 { 862 return 863 } 864 865 clients, _, err := GetConnectedUserClients(i, 100, "") 866 if err != nil { 867 i.Logger().Errorf("Could not fetch connected OAuth clients: %s", err) 868 return 869 } 870 count := len(clients) 871 872 reached = count >= limit 873 exceeded = count > limit 874 return 875 } 876 877 var cbClientsLimitAlert func(i *instance.Instance, clientName string, clientsLimit int) 878 879 // RegisterClientsLimitAlertCallback allows to register a callback function 880 // called when the connected OAuth clients limit (if present) is exceeded. 881 func RegisterClientsLimitAlertCallback(cb func(i *instance.Instance, clientName string, clientsLimit int)) { 882 cbClientsLimitAlert = cb 883 } 884 885 // PushClientsLimitAlert can be used to notify when the connected OAuth clients 886 // limit (if present) is exceeded. 887 func PushClientsLimitAlert(i *instance.Instance, clientName string, clientsLimit int) { 888 if cbClientsLimitAlert != nil { 889 cbClientsLimitAlert(i, clientName, clientsLimit) 890 } 891 } 892 893 var _ couchdb.Doc = &Client{}