diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go index 4526aa5234..ad3c7e3362 100644 --- a/internal/bootstrap/data/setting.go +++ b/internal/bootstrap/data/setting.go @@ -202,10 +202,10 @@ func InitialSettings() []model.SettingItem { {Key: conf.SSOClientId, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, {Key: conf.SSOClientSecret, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, {Key: conf.SSOOIDCUsernameKey, Value: "name", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, - {Key: conf.SSOOrganizationName, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, - {Key: conf.SSOApplicationName, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, + {Key: conf.SSOOIDCSubjectKey, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE, Help: `OIDC claim used as the SSO ID (recommended: sub); leave empty to use oidc username key for compatibility`}, + {Key: conf.SSOOIDCPKCEEnabled, Value: "false", Type: conf.TypeBool, Group: model.SSO, Flag: model.PRIVATE, Help: `Use an S256 PKCE challenge for OIDC authorization code flows; the provider application must support PKCE`}, + {Key: conf.SSOOIDCNonceEnabled, Value: "false", Type: conf.TypeBool, Group: model.SSO, Flag: model.PRIVATE, Help: `Send a nonce in OIDC authorization requests and verify it in ID tokens`}, {Key: conf.SSOEndpointName, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, - {Key: conf.SSOJwtPublicKey, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, {Key: conf.SSOExtraScopes, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, {Key: conf.SSOAutoRegister, Value: "false", Type: conf.TypeBool, Group: model.SSO, Flag: model.PRIVATE}, {Key: conf.SSODefaultDir, Value: "/", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE}, diff --git a/internal/cache/keyed_cache.go b/internal/cache/keyed_cache.go index 07bf8cd9f9..2f35929b76 100644 --- a/internal/cache/keyed_cache.go +++ b/internal/cache/keyed_cache.go @@ -75,6 +75,9 @@ func (c *KeyedCache[T]) Pop(key string) (T, bool) { defer c.mu.Unlock() if entry, exists := c.entries[key]; exists { delete(c.entries, key) + if entry.Expired() { + return *new(T), false + } return entry.data, true } return *new(T), false diff --git a/internal/conf/const.go b/internal/conf/const.go index b99d8849cb..33a8b00b1e 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -105,10 +105,10 @@ const ( SSOLoginEnabled = "sso_login_enabled" SSOLoginPlatform = "sso_login_platform" SSOOIDCUsernameKey = "sso_oidc_username_key" - SSOOrganizationName = "sso_organization_name" - SSOApplicationName = "sso_application_name" + SSOOIDCSubjectKey = "sso_oidc_subject_key" + SSOOIDCPKCEEnabled = "sso_oidc_pkce_enabled" + SSOOIDCNonceEnabled = "sso_oidc_nonce_enabled" SSOEndpointName = "sso_endpoint_name" - SSOJwtPublicKey = "sso_jwt_public_key" SSOExtraScopes = "sso_extra_scopes" SSOAutoRegister = "sso_auto_register" SSODefaultDir = "sso_default_dir" diff --git a/server/handles/ssologin.go b/server/handles/ssologin.go index 4baabf6c16..08f893384e 100644 --- a/server/handles/ssologin.go +++ b/server/handles/ssologin.go @@ -1,7 +1,7 @@ package handles import ( - "encoding/base64" + "encoding/json" "errors" "fmt" "net/http" @@ -10,8 +10,7 @@ import ( "strings" "time" - "github.com/OpenListTeam/go-cache" - + internalcache "github.com/OpenListTeam/OpenList/v4/internal/cache" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/db" "github.com/OpenListTeam/OpenList/v4/internal/model" @@ -29,21 +28,31 @@ import ( const stateLength = 16 const stateExpire = time.Minute * 5 -var stateCache = cache.NewMemCache[string](cache.WithShards[string](stateLength)) +type ssoState struct { + Method string + ClientIP string + PKCEVerifier string + Nonce string +} + +var stateCache = internalcache.NewKeyedCache[ssoState](stateExpire) -func _keyState(clientID, state string) string { +func keyState(clientID, state string) string { return fmt.Sprintf("%s_%s", clientID, state) } -func generateState(clientID, ip string) string { +func generateState(clientID string, value ssoState) string { state := random.String(stateLength) - stateCache.Set(_keyState(clientID, state), ip, cache.WithEx[string](stateExpire)) + stateCache.Set(keyState(clientID, state), value) return state } -func verifyState(clientID, ip, state string) bool { - value, ok := stateCache.Get(_keyState(clientID, state)) - return ok && value == ip +func consumeState(clientID, state, method, clientIP string) (ssoState, bool) { + value, ok := stateCache.Pop(keyState(clientID, state)) + if !ok || value.Method != method || value.ClientIP != clientIP { + return ssoState{}, false + } + return value, true } func ssoRedirectUri(c *gin.Context, useCompatibility bool, method string) string { @@ -54,6 +63,64 @@ func ssoRedirectUri(c *gin.Context, useCompatibility bool, method string) string } } +func ssoCallbackOrigin(c *gin.Context) (string, error) { + baseURL, err := url.Parse(common.GetApiUrl(c)) + if err != nil { + return "", fmt.Errorf("invalid API URL: %w", err) + } + if baseURL.Host == "" || (baseURL.Scheme != "http" && baseURL.Scheme != "https") { + return "", errors.New("invalid API URL origin") + } + return (&url.URL{Scheme: baseURL.Scheme, Host: baseURL.Host}).String(), nil +} + +func respondSSOCallback(c *gin.Context, key, value string) error { + targetOrigin, err := ssoCallbackOrigin(c) + if err != nil { + return err + } + message, err := json.Marshal(map[string]string{key: value}) + if err != nil { + return fmt.Errorf("marshal SSO callback message: %w", err) + } + targetOriginJSON, err := json.Marshal(targetOrigin) + if err != nil { + return fmt.Errorf("marshal SSO callback origin: %w", err) + } + + html := fmt.Sprintf(` + + + + + + + + + `, message, targetOriginJSON) + c.Header("Cache-Control", "no-store") + c.Header("Referrer-Policy", "no-referrer") + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html)) + return nil +} + +func redirectSSOCallback(c *gin.Context, targetPath, key, value string) error { + targetURL, err := url.Parse(common.GetApiUrl(c) + targetPath) + if err != nil { + return fmt.Errorf("invalid SSO callback redirect URL: %w", err) + } + query := targetURL.Query() + query.Set(key, value) + targetURL.RawQuery = query.Encode() + c.Redirect(http.StatusFound, targetURL.String()) + return nil +} + func SSOLoginRedirect(c *gin.Context) { method := c.Query("method") useCompatibility := setting.GetBool(conf.SSOCompatibilityMode) @@ -101,8 +168,18 @@ func SSOLoginRedirect(c *gin.Context) { common.ErrorStrResp(c, err.Error(), 400) return } - state := generateState(clientId, c.ClientIP()) - c.Redirect(http.StatusFound, oauth2Config.AuthCodeURL(state)) + loginState := ssoState{Method: method, ClientIP: c.ClientIP()} + authOptions := make([]oauth2.AuthCodeOption, 0, 2) + if setting.GetBool(conf.SSOOIDCPKCEEnabled) { + loginState.PKCEVerifier = oauth2.GenerateVerifier() + authOptions = append(authOptions, oauth2.S256ChallengeOption(loginState.PKCEVerifier)) + } + if setting.GetBool(conf.SSOOIDCNonceEnabled) { + loginState.Nonce = random.String(32) + authOptions = append(authOptions, oidc.Nonce(loginState.Nonce)) + } + state := generateState(clientId, loginState) + c.Redirect(http.StatusFound, oauth2Config.AuthCodeURL(state, authOptions...)) return default: common.ErrorStrResp(c, "invalid platform", 400) @@ -171,18 +248,6 @@ func autoRegister(username, userID string, err error) (*model.User, error) { return user, nil } -func parseJWT(p string) ([]byte, error) { - parts := strings.Split(p, ".") - if len(parts) < 2 { - return nil, fmt.Errorf("oidc: malformed jwt, expected 3 parts got %d", len(parts)) - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, fmt.Errorf("oidc: malformed jwt payload: %v", err) - } - return payload, nil -} - func OIDCLoginCallback(c *gin.Context) { useCompatibility := setting.GetBool(conf.SSOCompatibilityMode) method := c.Query("method") @@ -201,12 +266,17 @@ func OIDCLoginCallback(c *gin.Context) { common.ErrorResp(c, err, 400) return } - if !verifyState(clientId, c.ClientIP(), c.Query("state")) { + loginState, ok := consumeState(clientId, c.Query("state"), method, c.ClientIP()) + if !ok { common.ErrorStrResp(c, "incorrect or expired state parameter", 400) return } - oauth2Token, err := oauth2Config.Exchange(c, c.Query("code")) + exchangeOptions := make([]oauth2.AuthCodeOption, 0, 1) + if loginState.PKCEVerifier != "" { + exchangeOptions = append(exchangeOptions, oauth2.VerifierOption(loginState.PKCEVerifier)) + } + oauth2Token, err := oauth2Config.Exchange(c, c.Query("code"), exchangeOptions...) if err != nil { common.ErrorResp(c, err, 400) return @@ -219,41 +289,72 @@ func OIDCLoginCallback(c *gin.Context) { verifier := provider.Verifier(&oidc.Config{ ClientID: clientId, }) - _, err = verifier.Verify(c, rawIDToken) + idToken, err := verifier.Verify(c, rawIDToken) if err != nil { common.ErrorResp(c, err, 400) return } - payload, err := parseJWT(rawIDToken) - if err != nil { + if loginState.Nonce != "" && idToken.Nonce != loginState.Nonce { + common.ErrorStrResp(c, "OIDC ID token nonce does not match authorization request", 400) + return + } + var idTokenClaims json.RawMessage + if err = idToken.Claims(&idTokenClaims); err != nil { common.ErrorResp(c, err, 400) return } - userID := utils.Json.Get(payload, setting.GetStr(conf.SSOOIDCUsernameKey, "name")).ToString() + + usernameKey := setting.GetStr(conf.SSOOIDCUsernameKey, "name") + subjectKey := setting.GetStr(conf.SSOOIDCSubjectKey) + if subjectKey == "" { + // Preserve the identity mapping used before a separate subject key was available. + subjectKey = usernameKey + } + userID := utils.Json.Get(idTokenClaims, subjectKey).ToString() + username := utils.Json.Get(idTokenClaims, usernameKey).ToString() + if userID == "" || username == "" { + userInfo, userInfoErr := provider.UserInfo(c, oauth2.StaticTokenSource(oauth2Token)) + if userInfoErr == nil { + if userInfo.Subject != idToken.Subject { + common.ErrorStrResp(c, "OIDC userinfo subject does not match ID token subject", 400) + return + } + var userInfoClaims json.RawMessage + if err = userInfo.Claims(&userInfoClaims); err != nil { + common.ErrorResp(c, err, 400) + return + } + if userID == "" { + userID = utils.Json.Get(userInfoClaims, subjectKey).ToString() + } + if username == "" { + username = utils.Json.Get(userInfoClaims, usernameKey).ToString() + } + } else if userID == "" { + common.ErrorResp(c, fmt.Errorf("failed to get OIDC userinfo: %w", userInfoErr), 400) + return + } + } if userID == "" { - common.ErrorStrResp(c, "cannot get username from OIDC provider", 400) + common.ErrorStrResp(c, "cannot get subject from OIDC provider", 400) return } if method == "get_sso_id" { if useCompatibility { - c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+userID) + if err := redirectSSOCallback(c, "/@manage", "sso_id", userID); err != nil { + common.ErrorResp(c, err, 500) + } return } - html := fmt.Sprintf(` - - - - `, userID) - c.Data(200, "text/html; charset=utf-8", []byte(html)) + if err := respondSSOCallback(c, "sso_id", userID); err != nil { + common.ErrorResp(c, err, 500) + } return } if method == "sso_get_token" { user, err := db.GetUserBySSOID(userID) if err != nil { - user, err = autoRegister(userID, userID, err) + user, err = autoRegister(username, userID, err) if err != nil { common.ErrorResp(c, err, 400) return @@ -265,18 +366,14 @@ func OIDCLoginCallback(c *gin.Context) { return } if useCompatibility { - c.Redirect(302, common.GetApiUrl(c)+"/@login?token="+token) + if err := redirectSSOCallback(c, "/@login", "token", token); err != nil { + common.ErrorResp(c, err, 500) + } return } - html := fmt.Sprintf(` - - - - `, token) - c.Data(200, "text/html; charset=utf-8", []byte(html)) + if err := respondSSOCallback(c, "token", token); err != nil { + common.ErrorResp(c, err, 500) + } return } } @@ -403,18 +500,14 @@ func SSOLoginCallback(c *gin.Context) { } if argument == "get_sso_id" { if usecompatibility { - c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+userID) + if err := redirectSSOCallback(c, "/@manage", "sso_id", userID); err != nil { + common.ErrorResp(c, err, 500) + } return } - html := fmt.Sprintf(` - - - - `, userID) - c.Data(200, "text/html; charset=utf-8", []byte(html)) + if err := respondSSOCallback(c, "sso_id", userID); err != nil { + common.ErrorResp(c, err, 500) + } return } username := utils.Json.Get(resp.Body(), usernameField).ToString() @@ -432,16 +525,12 @@ func SSOLoginCallback(c *gin.Context) { return } if usecompatibility { - c.Redirect(302, common.GetApiUrl(c)+"/@login?token="+token) + if err := redirectSSOCallback(c, "/@login", "token", token); err != nil { + common.ErrorResp(c, err, 500) + } return } - html := fmt.Sprintf(` - - - - `, token) - c.Data(200, "text/html; charset=utf-8", []byte(html)) + if err := respondSSOCallback(c, "token", token); err != nil { + common.ErrorResp(c, err, 500) + } }