github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/oidc/oidc.go (about)

     1  package oidc
     2  
     3  import (
     4  	"crypto/rsa"
     5  	"crypto/subtle"
     6  	"encoding/base64"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"math/big"
    12  	"net/http"
    13  	"net/url"
    14  	"runtime"
    15  	"strings"
    16  	"time"
    17  
    18  	"github.com/cozy/cozy-stack/model/instance"
    19  	"github.com/cozy/cozy-stack/model/instance/lifecycle"
    20  	"github.com/cozy/cozy-stack/model/oauth"
    21  	"github.com/cozy/cozy-stack/model/session"
    22  	build "github.com/cozy/cozy-stack/pkg/config"
    23  	"github.com/cozy/cozy-stack/pkg/config/config"
    24  	"github.com/cozy/cozy-stack/pkg/consts"
    25  	"github.com/cozy/cozy-stack/pkg/couchdb"
    26  	"github.com/cozy/cozy-stack/pkg/limits"
    27  	"github.com/cozy/cozy-stack/pkg/logger"
    28  	"github.com/cozy/cozy-stack/web/auth"
    29  	"github.com/cozy/cozy-stack/web/middlewares"
    30  	"github.com/cozy/cozy-stack/web/statik"
    31  	jwt "github.com/golang-jwt/jwt/v5"
    32  	"github.com/labstack/echo/v4"
    33  )
    34  
    35  var (
    36  	ErrInvalidToken         = errors.New("invalid token")
    37  	ErrInvalidConfiguration = errors.New("invalid configuration")
    38  	ErrAuthenticationFailed = errors.New("the authentication has failed")
    39  	ErrFranceConnectFailed  = errors.New("the FranceConnect authentication has failed")
    40  	ErrIdentityProvider     = errors.New("error from the identity provider")
    41  )
    42  
    43  // Start is the route to start the OpenID Connect dance.
    44  func Start(c echo.Context) error {
    45  	inst := middlewares.GetInstance(c)
    46  	conf, err := getGenericConfig(inst.ContextName)
    47  	if err != nil {
    48  		inst.Logger().WithNamespace("oidc").Infof("Start error: %s", err)
    49  		return renderError(c, nil, http.StatusNotFound, "Sorry, the context was not found.")
    50  	}
    51  	u, err := makeStartURL(inst.Domain, c.QueryParam("redirect"), c.QueryParam("confirm_state"), conf)
    52  	if err != nil {
    53  		return renderError(c, nil, http.StatusNotFound, "Sorry, the server is not configured for OpenID Connect.")
    54  	}
    55  	return c.Redirect(http.StatusSeeOther, u)
    56  }
    57  
    58  // StartFranceConnect is the route to start the FranceConnect dance.
    59  func StartFranceConnect(c echo.Context) error {
    60  	inst := middlewares.GetInstance(c)
    61  	conf, err := getFranceConnectConfig(inst.ContextName)
    62  	if err != nil {
    63  		inst.Logger().WithNamespace("oidc").Infof("StartFranceConnect error: %s", err)
    64  		return renderError(c, nil, http.StatusNotFound, "Sorry, the context was not found.")
    65  	}
    66  	u, err := makeStartURL(inst.Domain, c.QueryParam("redirect"), c.QueryParam("confirm_state"), conf)
    67  	if err != nil {
    68  		return renderError(c, nil, http.StatusNotFound, "Sorry, the server is not configured for OpenID Connect.")
    69  	}
    70  	return c.Redirect(http.StatusSeeOther, u)
    71  }
    72  
    73  // Redirect is the route after the Identity Provider has redirected the user to
    74  // the stack. The redirection is made to a generic domain, like
    75  // oauthcallback.cozy.localhost and the association with an instance is made via a
    76  // call to the UserInfo endpoint. It redirects to the cozy instance to login
    77  // the user.
    78  func Redirect(c echo.Context) error {
    79  	code := c.QueryParam("code")
    80  	stateID := c.QueryParam("state")
    81  	state := getStorage().Find(stateID)
    82  	if state == nil {
    83  		return renderError(c, nil, http.StatusNotFound, "Sorry, the session has expired.")
    84  	}
    85  
    86  	domain := state.Instance
    87  	if contextName, ok := FindLoginDomain(domain); ok {
    88  		conf, err := getGenericConfig(contextName)
    89  		if err != nil || !conf.AllowOAuthToken {
    90  			return renderError(c, nil, http.StatusBadRequest, "No OpenID Connect is configured.")
    91  		}
    92  		token := c.QueryParam("access_token")
    93  		domain, err = getDomainFromUserInfo(conf, token)
    94  		if err != nil {
    95  			return renderError(c, nil, http.StatusNotFound, "Sorry, the cozy was not found.")
    96  		}
    97  	}
    98  	inst, err := lifecycle.GetInstance(domain)
    99  	if err != nil {
   100  		return renderError(c, nil, http.StatusNotFound, "Sorry, the cozy was not found.")
   101  	}
   102  
   103  	u := url.Values{
   104  		"code":  {code},
   105  		"state": {stateID},
   106  	}
   107  	if state.Redirect != "" {
   108  		u.Add("redirect", state.Redirect)
   109  	}
   110  	if state.Confirm != "" {
   111  		u.Add("confirm_state", state.Confirm)
   112  	}
   113  	if state.Provider == FranceConnectProvider {
   114  		u.Add("franceconnect", "true")
   115  		if c.QueryParam("nonce") != state.Nonce {
   116  			return renderError(c, nil, http.StatusBadRequest, "Sorry, an error occurred.")
   117  		}
   118  	}
   119  	redirect := inst.PageURL("/oidc/login", u)
   120  	return c.Redirect(http.StatusSeeOther, redirect)
   121  }
   122  
   123  // Login checks that the OpenID Connect has been successful and logs in the user.
   124  func Login(c echo.Context) error {
   125  	inst := middlewares.GetInstance(c)
   126  
   127  	var conf *Config
   128  	var err error
   129  	if c.QueryParam("franceconnect") == "" {
   130  		conf, err = getGenericConfig(inst.ContextName)
   131  	} else {
   132  		conf, err = getFranceConnectConfig(inst.ContextName)
   133  	}
   134  	if err != nil {
   135  		return renderError(c, inst, http.StatusBadRequest, "No OpenID Connect is configured.")
   136  	}
   137  
   138  	redirect := c.QueryParam("redirect")
   139  	confirm := c.QueryParam("confirm_state")
   140  	idToken := c.QueryParam("id_token")
   141  
   142  	err = config.GetRateLimiter().CheckRateLimit(inst, limits.AuthType)
   143  	if limits.IsLimitReachedOrExceeded(err) {
   144  		if err = auth.LoginRateExceeded(inst); err != nil {
   145  			inst.Logger().WithNamespace("oidc").Warn(err.Error())
   146  		}
   147  		return renderError(c, nil, http.StatusNotFound, "Sorry, the session has expired.")
   148  	}
   149  
   150  	if idToken != "" && conf.IDTokenKeyURL != "" {
   151  		if err := checkIDToken(conf, inst, idToken); err != nil {
   152  			return renderError(c, inst, http.StatusBadRequest, err.Error())
   153  		}
   154  	} else {
   155  		var token string
   156  		if conf.AllowOAuthToken {
   157  			token = c.QueryParam("access_token")
   158  		}
   159  		if token == "" {
   160  			stateID := c.QueryParam("state")
   161  			state := getStorage().Find(stateID)
   162  			if state == nil {
   163  				return renderError(c, nil, http.StatusNotFound, "Sorry, the session has expired.")
   164  			}
   165  			code := c.QueryParam("code")
   166  			token, err = getToken(conf, code)
   167  			if err != nil {
   168  				logger.WithNamespace("oidc").Errorf("Error on getToken: %s", err)
   169  				return renderError(c, inst, http.StatusBadGateway, "Error from the identity provider.")
   170  			}
   171  		}
   172  
   173  		// Check 2FA if enabled, and if yes, render an HTML page to check if
   174  		// the browser has a trusted device token in its local storage.
   175  		if inst.HasAuthMode(instance.TwoFactorMail) {
   176  			return c.Render(http.StatusOK, "oidc_twofactor.html", echo.Map{
   177  				"Domain":      inst.ContextualDomain(),
   178  				"AccessToken": token,
   179  				"Redirect":    redirect,
   180  				"Confirm":     confirm,
   181  			})
   182  		}
   183  
   184  		if err := checkDomainFromUserInfo(conf, inst, token); err != nil {
   185  			return renderError(c, inst, http.StatusBadRequest, err.Error())
   186  		}
   187  	}
   188  
   189  	return createSessionAndRedirect(c, inst, redirect, confirm)
   190  }
   191  
   192  func TwoFactor(c echo.Context) error {
   193  	accessToken := c.FormValue("access-token")
   194  	redirect := c.FormValue("redirect")
   195  	confirm := c.FormValue("confirm")
   196  	trustedDeviceToken := []byte(c.FormValue("trusted-device-token"))
   197  
   198  	inst := middlewares.GetInstance(c)
   199  	conf, err := getGenericConfig(inst.ContextName)
   200  	if err != nil {
   201  		return renderError(c, inst, http.StatusBadRequest, "No OpenID Connect is configured.")
   202  	}
   203  	if err := checkDomainFromUserInfo(conf, inst, accessToken); err != nil {
   204  		return renderError(c, inst, http.StatusBadRequest, err.Error())
   205  	}
   206  
   207  	if inst.ValidateTwoFactorTrustedDeviceSecret(c.Request(), trustedDeviceToken) {
   208  		return createSessionAndRedirect(c, inst, redirect, confirm)
   209  	}
   210  
   211  	twoFactorToken, err := lifecycle.SendTwoFactorPasscode(inst)
   212  	if err != nil {
   213  		return err
   214  	}
   215  	v := url.Values{}
   216  	v.Add("two_factor_token", string(twoFactorToken))
   217  	if redirect != "" {
   218  		v.Add("redirect", redirect)
   219  	}
   220  	if confirm != "" {
   221  		v.Add("confirm", "true")
   222  		v.Add("state", confirm)
   223  	}
   224  	return c.Redirect(http.StatusSeeOther, inst.PageURL("/auth/twofactor", v))
   225  }
   226  
   227  func createSessionAndRedirect(c echo.Context, inst *instance.Instance, redirect, confirm string) error {
   228  	// The OIDC danse has been made to confirm the identity of the user, not
   229  	// for creating a new session.
   230  	if confirm != "" {
   231  		return auth.ConfirmSuccess(c, inst, confirm)
   232  	}
   233  
   234  	sessionID, err := auth.SetCookieForNewSession(c, session.NormalRun)
   235  	if err != nil {
   236  		return err
   237  	}
   238  	if err = session.StoreNewLoginEntry(inst, sessionID, "", c.Request(), "OIDC", true); err != nil {
   239  		inst.Logger().Errorf("Could not store session history %q: %s", sessionID, err)
   240  	}
   241  	if redirect == "" {
   242  		redirect = inst.DefaultRedirection().String()
   243  	}
   244  	return c.Redirect(http.StatusSeeOther, redirect)
   245  }
   246  
   247  // AccessToken delivers an access_token and a refresh_token if the client gives
   248  // a valid token for OIDC.
   249  func AccessToken(c echo.Context) error {
   250  	inst := middlewares.GetInstance(c)
   251  	var reqBody struct {
   252  		ClientID       string `json:"client_id"`
   253  		ClientSecret   string `json:"client_secret"`
   254  		Scope          string `json:"scope"`
   255  		OIDCToken      string `json:"oidc_token"`
   256  		IDToken        string `json:"id_token"`
   257  		Code           string `json:"code"`
   258  		TwoFactorToken string `json:"two_factor_token"`
   259  		TwoFactorCode  string `json:"two_factor_passcode"`
   260  	}
   261  	if err := c.Bind(&reqBody); err != nil {
   262  		return err
   263  	}
   264  
   265  	if reqBody.Code != "" {
   266  		sub := getStorage().GetSub(reqBody.Code)
   267  		invalidCode := sub == ""
   268  		if sub != inst.OIDCID && sub != inst.FranceConnectID && sub != inst.Domain {
   269  			invalidCode = true
   270  		}
   271  		if invalidCode {
   272  			inst.Logger().WithNamespace("oidc").Infof("AccessToken invalid code: %s (%s - %s - %s)",
   273  				sub, inst.OIDCID, inst.FranceConnectID, inst.Domain)
   274  			return c.JSON(http.StatusBadRequest, echo.Map{
   275  				"error": "invalid code",
   276  			})
   277  		}
   278  	} else {
   279  		conf, err := getGenericConfig(inst.ContextName)
   280  		if err != nil || !conf.AllowOAuthToken {
   281  			return c.JSON(http.StatusBadRequest, echo.Map{
   282  				"error": "this endpoint is not enabled",
   283  			})
   284  		}
   285  		// Check the token from the remote URL.
   286  		if reqBody.IDToken != "" {
   287  			err = checkIDToken(conf, inst, reqBody.IDToken)
   288  		} else {
   289  			err = checkDomainFromUserInfo(conf, inst, reqBody.OIDCToken)
   290  		}
   291  		if err != nil {
   292  			return c.JSON(http.StatusBadRequest, echo.Map{
   293  				"error": err.Error(),
   294  			})
   295  		}
   296  	}
   297  
   298  	// Load the OAuth client
   299  	client, err := oauth.FindClient(inst, reqBody.ClientID)
   300  	if err != nil {
   301  		if couchErr, isCouchErr := couchdb.IsCouchError(err); isCouchErr && couchErr.StatusCode >= 500 {
   302  			return err
   303  		}
   304  		return c.JSON(http.StatusBadRequest, echo.Map{
   305  			"error": "the client must be registered",
   306  		})
   307  	}
   308  	if subtle.ConstantTimeCompare([]byte(reqBody.ClientSecret), []byte(client.ClientSecret)) == 0 {
   309  		return c.JSON(http.StatusBadRequest, echo.Map{
   310  			"error": "invalid client_secret",
   311  		})
   312  	}
   313  
   314  	if inst.HasAuthMode(instance.TwoFactorMail) {
   315  		token := []byte(reqBody.TwoFactorToken)
   316  		if len(token) == 0 {
   317  			twoFactorToken, err := lifecycle.SendTwoFactorPasscode(inst)
   318  			if err != nil {
   319  				return err
   320  			}
   321  			return c.JSON(http.StatusUnauthorized, echo.Map{
   322  				"error":            "two factor needed",
   323  				"two_factor_token": string(twoFactorToken),
   324  			})
   325  		}
   326  		if ok := inst.ValidateTwoFactorPasscode(token, reqBody.TwoFactorCode); !ok {
   327  			return c.JSON(http.StatusForbidden, echo.Map{
   328  				"error": inst.Translate(auth.TwoFactorErrorKey),
   329  			})
   330  		}
   331  	}
   332  
   333  	// Prepare the scope
   334  	out := auth.AccessTokenReponse{
   335  		Type:  "bearer",
   336  		Scope: reqBody.Scope,
   337  	}
   338  	if !client.Flagship {
   339  		if slug := oauth.GetLinkedAppSlug(client.SoftwareID); slug != "" {
   340  			if err := auth.CheckLinkedAppInstalled(inst, slug); err != nil {
   341  				return err
   342  			}
   343  			out.Scope = oauth.BuildLinkedAppScope(slug)
   344  		}
   345  	}
   346  	if out.Scope == "" {
   347  		return c.JSON(http.StatusBadRequest, echo.Map{
   348  			"error": "invalid scope",
   349  		})
   350  	}
   351  	if out.Scope == "*" {
   352  		if !client.Flagship {
   353  			return auth.ReturnSessionCode(c, http.StatusAccepted, inst)
   354  		}
   355  	}
   356  
   357  	// Remove the pending flag on the OAuth client (if needed)
   358  	if client.Pending {
   359  		client.Pending = false
   360  		client.ClientID = ""
   361  		_ = couchdb.UpdateDoc(inst, client)
   362  		client.ClientID = client.CouchID
   363  	}
   364  
   365  	if err := session.SendNewRegistrationNotification(inst, client.ClientID); err != nil {
   366  		return c.JSON(http.StatusInternalServerError, echo.Map{
   367  			"error": err.Error(),
   368  		})
   369  	}
   370  
   371  	// Generate the access/refresh tokens
   372  	accessToken, err := client.CreateJWT(inst, consts.AccessTokenAudience, out.Scope)
   373  	if err != nil {
   374  		return c.JSON(http.StatusInternalServerError, echo.Map{
   375  			"error": "Can't generate access token",
   376  		})
   377  	}
   378  	out.Access = accessToken
   379  	refreshToken, err := client.CreateJWT(inst, consts.RefreshTokenAudience, out.Scope)
   380  	if err != nil {
   381  		return c.JSON(http.StatusInternalServerError, echo.Map{
   382  			"error": "Can't generate refresh token",
   383  		})
   384  	}
   385  	out.Refresh = refreshToken
   386  
   387  	return c.JSON(http.StatusOK, out)
   388  }
   389  
   390  // Config is the config to log in a user with an OpenID Connect identity
   391  // provider.
   392  type Config struct {
   393  	Provider            ProviderOIDC
   394  	AllowOAuthToken     bool
   395  	AllowCustomInstance bool
   396  	ClientID            string
   397  	ClientSecret        string
   398  	Scope               string
   399  	RedirectURI         string
   400  	AuthorizeURL        string
   401  	TokenURL            string
   402  	UserInfoURL         string
   403  	UserInfoField       string
   404  	UserInfoPrefix      string
   405  	UserInfoSuffix      string
   406  	IDTokenKeyURL       string
   407  }
   408  
   409  func getGenericConfig(context string) (*Config, error) {
   410  	oidc, ok := config.GetOIDC(context)
   411  	if !ok {
   412  		return nil, errors.New("No OIDC is configured for this context")
   413  	}
   414  
   415  	// Optional fields
   416  	allowOAuthToken, _ := oidc["allow_oauth_token"].(bool)
   417  	allowCustomInstance, _ := oidc["allow_custom_instance"].(bool)
   418  	userInfoPrefix, _ := oidc["userinfo_instance_prefix"].(string)
   419  	userInfoSuffix, _ := oidc["userinfo_instance_suffix"].(string)
   420  	idTokenKeyURL, _ := oidc["id_token_jwk_url"].(string)
   421  
   422  	// Mandatory fields
   423  	clientID, ok := oidc["client_id"].(string)
   424  	if !ok {
   425  		return nil, errors.New("The client_id is missing for this context")
   426  	}
   427  	clientSecret, ok := oidc["client_secret"].(string)
   428  	if !ok {
   429  		return nil, errors.New("The client_secret is missing for this context")
   430  	}
   431  	scope, ok := oidc["scope"].(string)
   432  	if !ok {
   433  		return nil, errors.New("The scope is missing for this context")
   434  	}
   435  	redirectURI, ok := oidc["redirect_uri"].(string)
   436  	if !ok {
   437  		return nil, errors.New("The redirect_uri is missing for this context")
   438  	}
   439  	authorizeURL, ok := oidc["authorize_url"].(string)
   440  	if !ok {
   441  		return nil, errors.New("The authorize_url is missing for this context")
   442  	}
   443  	tokenURL, ok := oidc["token_url"].(string)
   444  	if !ok {
   445  		return nil, errors.New("The token_url is missing for this context")
   446  	}
   447  	userInfoURL, ok := oidc["userinfo_url"].(string)
   448  	if !ok {
   449  		return nil, errors.New("The userinfo_url is missing for this context")
   450  	}
   451  	userInfoField, ok := oidc["userinfo_instance_field"].(string)
   452  	if !ok && !allowCustomInstance {
   453  		return nil, errors.New("The userinfo_instance_field is missing for this context")
   454  	}
   455  
   456  	config := &Config{
   457  		Provider:            GenericProvider,
   458  		AllowOAuthToken:     allowOAuthToken,
   459  		AllowCustomInstance: allowCustomInstance,
   460  		ClientID:            clientID,
   461  		ClientSecret:        clientSecret,
   462  		Scope:               scope,
   463  		RedirectURI:         redirectURI,
   464  		AuthorizeURL:        authorizeURL,
   465  		TokenURL:            tokenURL,
   466  		UserInfoURL:         userInfoURL,
   467  		UserInfoField:       userInfoField,
   468  		UserInfoPrefix:      userInfoPrefix,
   469  		UserInfoSuffix:      userInfoSuffix,
   470  		IDTokenKeyURL:       idTokenKeyURL,
   471  	}
   472  	return config, nil
   473  }
   474  
   475  func getFranceConnectConfig(context string) (*Config, error) {
   476  	oidc, ok := config.GetFranceConnect(context)
   477  	if !ok {
   478  		return nil, errors.New("No FranceConnect is configured for this context")
   479  	}
   480  
   481  	// Mandatory fields
   482  	clientID, ok := oidc["client_id"].(string)
   483  	if !ok {
   484  		return nil, errors.New("The client_id is missing for this context")
   485  	}
   486  	clientSecret, ok := oidc["client_secret"].(string)
   487  	if !ok {
   488  		return nil, errors.New("The client_secret is missing for this context")
   489  	}
   490  	scope, ok := oidc["scope"].(string)
   491  	if !ok {
   492  		return nil, errors.New("The scope is missing for this context")
   493  	}
   494  	redirectURI, ok := oidc["redirect_uri"].(string)
   495  	if !ok {
   496  		return nil, errors.New("The redirect_uri is missing for this context")
   497  	}
   498  	authorizeURL, ok := oidc["authorize_url"].(string)
   499  	if !ok {
   500  		authorizeURL = "https://app.franceconnect.gouv.fr/api/v1/authorize"
   501  	}
   502  	tokenURL, ok := oidc["token_url"].(string)
   503  	if !ok {
   504  		tokenURL = "https://app.franceconnect.gouv.fr/api/v1/token"
   505  	}
   506  	userInfoURL, ok := oidc["userinfo_url"].(string)
   507  	if !ok {
   508  		userInfoURL = "https://app.franceconnect.gouv.fr/api/v1/userinfo"
   509  	}
   510  
   511  	config := &Config{
   512  		Provider:            FranceConnectProvider,
   513  		AllowCustomInstance: true,
   514  		ClientID:            clientID,
   515  		ClientSecret:        clientSecret,
   516  		Scope:               scope,
   517  		RedirectURI:         redirectURI,
   518  		AuthorizeURL:        authorizeURL,
   519  		TokenURL:            tokenURL,
   520  		UserInfoURL:         userInfoURL,
   521  	}
   522  	return config, nil
   523  }
   524  
   525  func makeStartURL(domain, redirect, confirm string, conf *Config) (string, error) {
   526  	u, err := url.Parse(conf.AuthorizeURL)
   527  	if err != nil {
   528  		return "", err
   529  	}
   530  	state := newStateHolder(domain, redirect, confirm, conf.Provider)
   531  	if err = getStorage().Add(state); err != nil {
   532  		return "", err
   533  	}
   534  	vv := u.Query()
   535  	vv.Add("response_type", "code")
   536  	vv.Add("scope", conf.Scope)
   537  	vv.Add("client_id", conf.ClientID)
   538  	vv.Add("redirect_uri", conf.RedirectURI)
   539  	vv.Add("state", state.id)
   540  	vv.Add("nonce", state.Nonce)
   541  	if conf.Provider == FranceConnectProvider {
   542  		vv.Add("acr_values", "eidas1")
   543  	}
   544  	u.RawQuery = vv.Encode()
   545  	return u.String(), nil
   546  }
   547  
   548  var oidcClient = &http.Client{
   549  	Timeout: 15 * time.Second,
   550  }
   551  
   552  func getToken(conf *Config, code string) (string, error) {
   553  	data := url.Values{
   554  		"grant_type":   []string{"authorization_code"},
   555  		"code":         []string{code},
   556  		"redirect_uri": []string{conf.RedirectURI},
   557  	}
   558  
   559  	// FranceConnect expects the client_id+client_secret in the body, not in a
   560  	// Authentication header like normal OIDC.
   561  	if conf.Provider == FranceConnectProvider {
   562  		data.Add("client_id", conf.ClientID)
   563  		data.Add("client_secret", conf.ClientSecret)
   564  	}
   565  
   566  	body := strings.NewReader(data.Encode())
   567  	req, err := http.NewRequest("POST", conf.TokenURL, body)
   568  	if err != nil {
   569  		return "", err
   570  	}
   571  	req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationForm)
   572  	req.Header.Add(echo.HeaderAccept, echo.MIMEApplicationJSON)
   573  
   574  	if conf.Provider == GenericProvider {
   575  		auth := []byte(conf.ClientID + ":" + conf.ClientSecret)
   576  		req.Header.Add(echo.HeaderAuthorization, "Basic "+base64.StdEncoding.EncodeToString(auth))
   577  	}
   578  
   579  	res, err := oidcClient.Do(req)
   580  	if err != nil {
   581  		return "", err
   582  	}
   583  	defer res.Body.Close()
   584  	if res.StatusCode != 200 {
   585  		// Flush the body, so that the connecion can be reused by keep-alive
   586  		_, _ = io.Copy(io.Discard, res.Body)
   587  		logger.WithNamespace("oidc").
   588  			Infof("Invalid status code %d for %s", res.StatusCode, conf.TokenURL)
   589  		return "", fmt.Errorf("OIDC service responded with %d", res.StatusCode)
   590  	}
   591  	resBody, err := io.ReadAll(res.Body)
   592  	if err != nil {
   593  		return "", err
   594  	}
   595  
   596  	var out struct {
   597  		AccessToken string `json:"access_token"`
   598  	}
   599  	err = json.Unmarshal(resBody, &out)
   600  	if err != nil {
   601  		return "", err
   602  	}
   603  	return out.AccessToken, nil
   604  }
   605  
   606  func getDomainFromUserInfo(conf *Config, token string) (string, error) {
   607  	if conf.AllowCustomInstance {
   608  		return "", ErrInvalidConfiguration
   609  	}
   610  	params, err := getUserInfo(conf, token)
   611  	if err != nil {
   612  		return "", err
   613  	}
   614  	return extractDomain(conf, params)
   615  }
   616  
   617  func checkDomainFromUserInfo(conf *Config, inst *instance.Instance, token string) error {
   618  	params, err := getUserInfo(conf, token)
   619  	if err != nil {
   620  		return err
   621  	}
   622  
   623  	if conf.AllowCustomInstance {
   624  		sub, ok := params["sub"].(string)
   625  		expected := inst.OIDCID
   626  		if conf.Provider == FranceConnectProvider {
   627  			expected = inst.FranceConnectID
   628  		}
   629  		if !ok || sub == "" || sub != expected {
   630  			inst.Logger().WithNamespace("oidc").Errorf("Invalid sub: %s != %s", sub, expected)
   631  			if conf.Provider == FranceConnectProvider {
   632  				return ErrFranceConnectFailed
   633  			}
   634  			return ErrAuthenticationFailed
   635  		}
   636  		return nil
   637  	}
   638  
   639  	domain, err := extractDomain(conf, params)
   640  	if err != nil {
   641  		logger.WithNamespace("oidc").Warnf("Cannot extract domain: %s", err)
   642  		return err
   643  	}
   644  	if domain != inst.Domain {
   645  		logger.WithNamespace("oidc").Errorf("Invalid domains: %s != %s", domain, inst.Domain)
   646  		return ErrAuthenticationFailed
   647  	}
   648  	return nil
   649  }
   650  
   651  func getUserInfo(conf *Config, token string) (map[string]interface{}, error) {
   652  	req, err := http.NewRequest("GET", conf.UserInfoURL, nil)
   653  	if err != nil {
   654  		return nil, ErrInvalidConfiguration
   655  	}
   656  	req.Header.Add(echo.HeaderAuthorization, "Bearer "+token)
   657  	res, err := oidcClient.Do(req)
   658  	if err != nil {
   659  		logger.WithNamespace("oidc").Errorf("Error on getDomainFromUserInfo: %s", err)
   660  		return nil, ErrIdentityProvider
   661  	}
   662  	defer res.Body.Close()
   663  	if res.StatusCode != 200 {
   664  		// Flush the body, so that the connecion can be reused by keep-alive
   665  		_, _ = io.Copy(io.Discard, res.Body)
   666  		logger.WithNamespace("oidc").
   667  			Infof("Invalid status code %d for %s", res.StatusCode, conf.UserInfoURL)
   668  		return nil, fmt.Errorf("OIDC service responded with %d", res.StatusCode)
   669  	}
   670  
   671  	var params map[string]interface{}
   672  	err = json.NewDecoder(res.Body).Decode(&params)
   673  	if err != nil {
   674  		logger.WithNamespace("oidc").Errorf("Error on getDomainFromUserInfo: %s", err)
   675  		return nil, ErrIdentityProvider
   676  	}
   677  	return params, nil
   678  }
   679  
   680  func extractDomain(conf *Config, params map[string]interface{}) (string, error) {
   681  	domain, ok := params[conf.UserInfoField].(string)
   682  	if !ok {
   683  		return "", ErrAuthenticationFailed
   684  	}
   685  	domain = strings.ReplaceAll(domain, "-", "") // We don't want - in cozy instance
   686  	domain = strings.ToLower(domain)             // The domain is case insensitive
   687  	domain = conf.UserInfoPrefix + domain + conf.UserInfoSuffix
   688  	return domain, nil
   689  }
   690  
   691  func checkIDToken(conf *Config, inst *instance.Instance, idToken string) error {
   692  	keys, err := GetIDTokenKeys(conf.IDTokenKeyURL)
   693  	if err != nil {
   694  		return err
   695  	}
   696  
   697  	token, err := jwt.Parse(idToken, func(token *jwt.Token) (interface{}, error) {
   698  		return ChooseKeyForIDToken(keys, token)
   699  	})
   700  	if err != nil {
   701  		logger.WithNamespace("oidc").Errorf("Error on jwt.Parse: %s", err)
   702  		return ErrInvalidToken
   703  	}
   704  	if !token.Valid {
   705  		logger.WithNamespace("oidc").Errorf("%s: %#v", ErrInvalidToken, token)
   706  		return ErrInvalidToken
   707  	}
   708  
   709  	claims := token.Claims.(jwt.MapClaims)
   710  	if claims["sub"] == "" || claims["sub"] != inst.OIDCID {
   711  		inst.Logger().WithNamespace("oidc").Errorf("Invalid sub: %s != %s", claims["sub"], inst.OIDCID)
   712  		return ErrAuthenticationFailed
   713  	}
   714  
   715  	return nil
   716  }
   717  
   718  type jwKey struct {
   719  	Alg  string `json:"alg"`
   720  	Type string `json:"kty"`
   721  	ID   string `json:"kid"`
   722  	Use  string `json:"use"`
   723  	E    string `json:"e"`
   724  	N    string `json:"n"`
   725  }
   726  
   727  const cacheTTL = 24 * time.Hour
   728  
   729  var keysClient = &http.Client{
   730  	Timeout: 10 * time.Second,
   731  	Transport: &http.Transport{
   732  		DisableKeepAlives: true,
   733  	},
   734  }
   735  
   736  // GetIDTokenKeys returns the keys that can be used to verify that an OIDC
   737  // id_token is valid.
   738  func GetIDTokenKeys(keyURL string) ([]*jwKey, error) {
   739  	cache := config.GetConfig().CacheStorage
   740  	cacheKey := "oidc-jwk:" + keyURL
   741  
   742  	data, ok := cache.Get(cacheKey)
   743  	if !ok {
   744  		var err error
   745  		data, err = getKeysFromHTTP(keyURL)
   746  		if err != nil {
   747  			return nil, err
   748  		}
   749  	}
   750  
   751  	var keys struct {
   752  		Keys []*jwKey `json:"keys"`
   753  	}
   754  	if err := json.Unmarshal(data, &keys); err != nil {
   755  		return nil, err
   756  	}
   757  	if !ok {
   758  		cache.Set(cacheKey, data, cacheTTL)
   759  	}
   760  	return keys.Keys, nil
   761  }
   762  
   763  func getKeysFromHTTP(keyURL string) ([]byte, error) {
   764  	req, err := http.NewRequest(http.MethodGet, keyURL, nil)
   765  	if err != nil {
   766  		return nil, err
   767  	}
   768  	req.Header.Add("User-Agent", "cozy-stack "+build.Version+" ("+runtime.Version()+")")
   769  	res, err := keysClient.Do(req)
   770  	if err != nil {
   771  		return nil, err
   772  	}
   773  	defer res.Body.Close()
   774  	if res.StatusCode != http.StatusOK {
   775  		logger.WithNamespace("oidc").Warnf("getKeys cannot fetch jwk: %d", res.StatusCode)
   776  		return nil, errors.New("cannot fetch jwk")
   777  	}
   778  	return io.ReadAll(res.Body)
   779  }
   780  
   781  // ChooseKeyForIDToken can be used to check an id_token as a JWT.
   782  func ChooseKeyForIDToken(keys []*jwKey, token *jwt.Token) (interface{}, error) {
   783  	if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
   784  		return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
   785  	}
   786  
   787  	var key *jwKey
   788  	for _, k := range keys {
   789  		if k.Use != "sig" || k.Type != "RSA" {
   790  			continue
   791  		}
   792  		if k.ID == token.Header["kid"] {
   793  			return loadKey(k)
   794  		}
   795  		key = k
   796  	}
   797  	if key == nil {
   798  		return nil, errors.New("Key not found")
   799  	}
   800  	return loadKey(key)
   801  }
   802  
   803  func loadKey(raw *jwKey) (interface{}, error) {
   804  	var n, e big.Int
   805  	nn, err := base64.RawURLEncoding.DecodeString(raw.N)
   806  	if err != nil {
   807  		return nil, err
   808  	}
   809  	n.SetBytes(nn)
   810  	ee, err := base64.RawURLEncoding.DecodeString(raw.E)
   811  	if err != nil {
   812  		return nil, err
   813  	}
   814  	e.SetBytes(ee)
   815  
   816  	var key rsa.PublicKey
   817  	key.N = &n
   818  	key.E = int(e.Int64())
   819  	return &key, nil
   820  }
   821  
   822  func renderError(c echo.Context, inst *instance.Instance, code int, msg string) error {
   823  	if inst == nil {
   824  		inst = &instance.Instance{
   825  			Domain:      c.Request().Host,
   826  			ContextName: config.DefaultInstanceContext,
   827  			Locale:      consts.DefaultLocale,
   828  		}
   829  	}
   830  	return c.Render(code, "error.html", echo.Map{
   831  		"Domain":       inst.ContextualDomain(),
   832  		"ContextName":  inst.ContextName,
   833  		"Locale":       inst.Locale,
   834  		"Title":        inst.TemplateTitle(),
   835  		"Favicon":      middlewares.Favicon(inst),
   836  		"Illustration": "/images/generic-error.svg",
   837  		"Error":        msg,
   838  		"SupportEmail": inst.SupportEmailAddress(),
   839  	})
   840  }
   841  
   842  // Routes setup routing for OpenID Connect routes.
   843  // Careful, the normal middlewares NeedInstance and LoadSession are not applied
   844  // to this group in web/routing
   845  func Routes(router *echo.Group) {
   846  	router.GET("/start", Start, middlewares.NeedInstance, middlewares.CheckOnboardingNotFinished)
   847  	router.GET("/franceconnect", StartFranceConnect, middlewares.NeedInstance, middlewares.CheckOnboardingNotFinished)
   848  	router.GET("/redirect", Redirect)
   849  	router.GET("/login", Login, middlewares.NeedInstance)
   850  	router.POST("/twofactor", TwoFactor, middlewares.NeedInstance)
   851  	router.POST("/access_token", AccessToken, middlewares.NeedInstance)
   852  }
   853  
   854  // GetDelegatedCode is mostly a proxy for the userinfo request made by the
   855  // cloudery to the OIDC provider. It adds a delegated code in the response
   856  // associated to the sub.
   857  func GetDelegatedCode(c echo.Context) error {
   858  	contextName := c.Param("context")
   859  	provider := c.Param("provider")
   860  	var conf *Config
   861  	var err error
   862  	if provider == "franceconnect" {
   863  		conf, err = getFranceConnectConfig(contextName)
   864  	} else {
   865  		conf, err = getGenericConfig(contextName)
   866  	}
   867  	if err != nil {
   868  		return c.JSON(http.StatusBadRequest, echo.Map{
   869  			"error": err.Error(),
   870  		})
   871  	}
   872  
   873  	var reqBody struct {
   874  		AccessToken string `json:"access_token"`
   875  	}
   876  	if err := c.Bind(&reqBody); err != nil {
   877  		return err
   878  	}
   879  
   880  	params, err := getUserInfo(conf, reqBody.AccessToken)
   881  	if err != nil {
   882  		return err
   883  	}
   884  
   885  	var s string
   886  	if conf.AllowCustomInstance {
   887  		sub, ok := params["sub"].(string)
   888  		if !ok {
   889  			logger.WithNamespace("oidc").Errorf("Missing sub")
   890  			return ErrAuthenticationFailed
   891  		}
   892  		s = sub
   893  	} else {
   894  		domain, err := extractDomain(conf, params)
   895  		if err != nil {
   896  			logger.WithNamespace("oidc").Warnf("Cannot extract domain: %s", err)
   897  			return err
   898  		}
   899  		s = domain
   900  	}
   901  
   902  	logger.WithNamespace("oidc").Infof("GetDelegatedCode for %s", s)
   903  	params["delegated_code"] = getStorage().CreateCode(s)
   904  	return c.JSON(http.StatusOK, params)
   905  }
   906  
   907  // AdminRoutes setup the routing for OpenID Connect on the admin port. It is
   908  // mostly used by the cloudery.
   909  func AdminRoutes(router *echo.Group) {
   910  	router.POST("/:context/:provider/code", GetDelegatedCode)
   911  }
   912  
   913  // LoginDomainHandler is the handler for the requests on the login domain. It
   914  // shows a page with a login button (that can start the OIDC dance).
   915  func LoginDomainHandler(c echo.Context, contextName string) error {
   916  	r := c.Request()
   917  	if strings.HasPrefix(r.URL.Path, "/assets/") {
   918  		rndr, err := statik.NewRenderer()
   919  		if err != nil {
   920  			return err
   921  		}
   922  		rndr.ServeHTTP(c.Response(), r)
   923  		return nil
   924  	}
   925  
   926  	if r.Method != http.MethodPost {
   927  		i := &instance.Instance{Locale: "fr", ContextName: contextName}
   928  		title := i.Translate("Login Welcome")
   929  		return c.Render(http.StatusOK, "oidc_login.html", echo.Map{
   930  			"Domain":      i.ContextualDomain(),
   931  			"ContextName": i.ContextName,
   932  			"Locale":      i.Locale,
   933  			"Title":       title,
   934  			"Favicon":     middlewares.Favicon(i),
   935  		})
   936  	}
   937  
   938  	conf, err := getGenericConfig(contextName)
   939  	if err != nil {
   940  		return renderError(c, nil, http.StatusNotFound, "Sorry, the context was not found.")
   941  	}
   942  	u, err := makeStartURL(r.Host, "", "", conf)
   943  	if err != nil {
   944  		return renderError(c, nil, http.StatusNotFound, "Sorry, the server is not configured for OpenID Connect.")
   945  	}
   946  	return c.Redirect(http.StatusSeeOther, u)
   947  }
   948  
   949  // FindLoginDomain returns the context name for which the login domain matches
   950  // the host.
   951  func FindLoginDomain(host string) (string, bool) {
   952  	for ctx, auth := range config.GetConfig().Authentication {
   953  		delegated, ok := auth.(map[string]interface{})
   954  		if !ok {
   955  			continue
   956  		}
   957  		oidc, ok := delegated["oidc"].(map[string]interface{})
   958  		if !ok {
   959  			continue
   960  		}
   961  		domain, ok := oidc["login_domain"].(string)
   962  		if ok && domain == host {
   963  			return ctx, true
   964  		}
   965  	}
   966  	return "", false
   967  }