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(¶ms) 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 }