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{}