Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions internal/bootstrap/data/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
3 changes: 3 additions & 0 deletions internal/cache/keyed_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions internal/conf/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
233 changes: 161 additions & 72 deletions server/handles/ssologin.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package handles

import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
Expand All @@ -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"
Expand All @@ -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 {
Expand All @@ -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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="robots" content="noindex">
</head>
<body>
<script>
if (window.opener) {
window.opener.postMessage(%s, %s);
}
window.close();
</script>
</body>
</html>`, 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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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(`<!DOCTYPE html>
<head></head>
<body>
<script>
window.opener.postMessage({"sso_id": "%s"}, "*")
window.close()
</script>
</body>`, 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
Expand All @@ -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(`<!DOCTYPE html>
<head></head>
<body>
<script>
window.opener.postMessage({"token":"%s"}, "*")
window.close()
</script>
</body>`, 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
}
}
Expand Down Expand Up @@ -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(`<!DOCTYPE html>
<head></head>
<body>
<script>
window.opener.postMessage({"sso_id": "%s"}, "*")
window.close()
</script>
</body>`, 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()
Expand All @@ -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(`<!DOCTYPE html>
<head></head>
<body>
<script>
window.opener.postMessage({"token":"%s"}, "*")
window.close()
</script>
</body>`, token)
c.Data(200, "text/html; charset=utf-8", []byte(html))
if err := respondSSOCallback(c, "token", token); err != nil {
common.ErrorResp(c, err, 500)
}
}