Просмотр исходного кода

feat(oauth2): admin role mapping

Aaron 23 часов назад
Родитель
Сommit
05b24c91a9
5 измененных файлов с 87 добавлено и 2 удалено
  1. 27 0
      internal/config/options.go
  2. 25 1
      internal/oauth2/oidc.go
  3. 1 0
      internal/oauth2/profile.go
  4. 15 1
      internal/ui/oauth2_callback.go
  5. 19 0
      miniflux.1

+ 27 - 0
internal/config/options.go

@@ -437,6 +437,16 @@ func NewConfigOptions() *configOptions {
 				valueType:         secretFileType,
 				valueType:         secretFileType,
 				targetKey:         "METRICS_USERNAME",
 				targetKey:         "METRICS_USERNAME",
 			},
 			},
+			"OAUTH2_ADDITIONAL_SCOPES": {
+				parsedStringList: []string{},
+				rawValue:         "",
+				valueType:        stringListType,
+			},
+			"OAUTH2_ADMIN_GROUP": {
+				parsedStringValue: "",
+				rawValue:          "",
+				valueType:         stringType,
+			},
 			"OAUTH2_CLIENT_ID": {
 			"OAUTH2_CLIENT_ID": {
 				parsedStringValue: "",
 				parsedStringValue: "",
 				rawValue:          "",
 				rawValue:          "",
@@ -461,6 +471,11 @@ func NewConfigOptions() *configOptions {
 				valueType:         secretFileType,
 				valueType:         secretFileType,
 				targetKey:         "OAUTH2_CLIENT_SECRET",
 				targetKey:         "OAUTH2_CLIENT_SECRET",
 			},
 			},
+			"OAUTH2_GROUP_CLAIM": {
+				parsedStringValue: "",
+				rawValue:          "",
+				valueType:         stringType,
+			},
 			"OAUTH2_OIDC_DISCOVERY_ENDPOINT": {
 			"OAUTH2_OIDC_DISCOVERY_ENDPOINT": {
 				parsedStringValue: "",
 				parsedStringValue: "",
 				rawValue:          "",
 				rawValue:          "",
@@ -911,6 +926,18 @@ func (c *configOptions) MetricsUsername() string {
 	return c.options["METRICS_USERNAME"].parsedStringValue
 	return c.options["METRICS_USERNAME"].parsedStringValue
 }
 }
 
 
+func (c *configOptions) Oauth2GroupClaim() string {
+	return c.options["OAUTH2_GROUP_CLAIM"].parsedStringValue
+}
+
+func (c *configOptions) Oauth2AdditionalScopes() []string {
+	return c.options["OAUTH2_ADDITIONAL_SCOPES"].parsedStringList
+}
+
+func (c *configOptions) Oauth2AdminGroup() string {
+	return c.options["OAUTH2_ADMIN_GROUP"].parsedStringValue
+}
+
 func (c *configOptions) OAuth2ClientID() string {
 func (c *configOptions) OAuth2ClientID() string {
 	return c.options["OAUTH2_CLIENT_ID"].parsedStringValue
 	return c.options["OAUTH2_CLIENT_ID"].parsedStringValue
 }
 }

+ 25 - 1
internal/oauth2/oidc.go

@@ -7,7 +7,9 @@ import (
 	"context"
 	"context"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
+	"slices"
 
 
+	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 
 
 	"github.com/coreos/go-oidc/v3/oidc"
 	"github.com/coreos/go-oidc/v3/oidc"
@@ -56,7 +58,7 @@ func (o *oidcProvider) Config() *oauth2.Config {
 		RedirectURL:  o.redirectURL,
 		RedirectURL:  o.redirectURL,
 		ClientID:     o.clientID,
 		ClientID:     o.clientID,
 		ClientSecret: o.clientSecret,
 		ClientSecret: o.clientSecret,
-		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
+		Scopes:       slices.Concat([]string{oidc.ScopeOpenID, "profile", "email"}, config.Opts.Oauth2AdditionalScopes()),
 		Endpoint:     o.provider.Endpoint(),
 		Endpoint:     o.provider.Endpoint(),
 	}
 	}
 }
 }
@@ -107,6 +109,28 @@ func (o *oidcProvider) Profile(ctx context.Context, code, codeVerifier string) (
 		}
 		}
 	}
 	}
 
 
+	if groupClaim := config.Opts.Oauth2GroupClaim(); groupClaim != "" {
+		var rawUserClaims map[string]any
+		if err := userInfo.Claims(&rawUserClaims); err != nil {
+			return nil, fmt.Errorf(`oidc: failed to parse user claims: %w`, err)
+		}
+
+		rawValue := rawUserClaims[groupClaim]
+		switch value := rawValue.(type) {
+		case string:
+			profile.Groups = []string{value}
+		case []any:
+			profile.Groups = make([]string, len(value))
+			for i, rawEntry := range value {
+				if entry, ok := rawEntry.(string); ok {
+					profile.Groups[i] = entry
+				} else {
+					return nil, fmt.Errorf(`oidc: failed to parse user claims: %q must only contain strings`, groupClaim)
+				}
+			}
+		}
+	}
+
 	if profile.Username == "" {
 	if profile.Username == "" {
 		return nil, ErrEmptyUsername
 		return nil, ErrEmptyUsername
 	}
 	}

+ 1 - 0
internal/oauth2/profile.go

@@ -12,6 +12,7 @@ type UserProfile struct {
 	Key      string
 	Key      string
 	ID       string
 	ID       string
 	Username string
 	Username string
+	Groups   []string
 }
 }
 
 
 // String returns a formatted string representation of the user profile.
 // String returns a formatted string representation of the user profile.

+ 15 - 1
internal/ui/oauth2_callback.go

@@ -8,6 +8,7 @@ import (
 	"errors"
 	"errors"
 	"log/slog"
 	"log/slog"
 	"net/http"
 	"net/http"
+	"slices"
 
 
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
@@ -116,6 +117,11 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
+	groupClaim := config.Opts.Oauth2GroupClaim()
+	adminGroup := config.Opts.Oauth2AdminGroup()
+	adminRoleMappingEnabled := provider == "oidc" && groupClaim != "" && adminGroup != ""
+	isAdmin := adminRoleMappingEnabled && slices.Contains(profile.Groups, adminGroup)
+
 	if user == nil {
 	if user == nil {
 		if !config.Opts.IsOAuth2UserCreationAllowed() {
 		if !config.Opts.IsOAuth2UserCreationAllowed() {
 			response.HTMLForbidden(w, r)
 			response.HTMLForbidden(w, r)
@@ -127,7 +133,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 			return
 			return
 		}
 		}
 
 
-		userCreationRequest := &model.UserCreationRequest{Username: profile.Username}
+		userCreationRequest := &model.UserCreationRequest{Username: profile.Username, IsAdmin: isAdmin}
 		authProvider.PopulateUserCreationWithProfileID(userCreationRequest, profile)
 		authProvider.PopulateUserCreationWithProfileID(userCreationRequest, profile)
 
 
 		user, err = h.store.CreateUser(userCreationRequest)
 		user, err = h.store.CreateUser(userCreationRequest)
@@ -145,6 +151,14 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 		slog.String("username", user.Username),
 		slog.String("username", user.Username),
 	)
 	)
 
 
+	if adminRoleMappingEnabled && user.IsAdmin != isAdmin {
+		user.IsAdmin = isAdmin
+		if err := h.store.UpdateUser(user); err != nil {
+			response.HTMLServerError(w, r, err)
+			return
+		}
+	}
+
 	h.store.SetLastLogin(user.ID)
 	h.store.SetLastLogin(user.ID)
 	if err := authenticateWebSession(w, r, h.store, user); err != nil {
 	if err := authenticateWebSession(w, r, h.store, user); err != nil {
 		response.HTMLServerError(w, r, err)
 		response.HTMLServerError(w, r, err)

+ 19 - 0
miniflux.1

@@ -504,6 +504,18 @@ endpoint HTTP authentication\&.
 .br
 .br
 Default is empty\&.
 Default is empty\&.
 .TP
 .TP
+.B OAUTH2_ADDITIONAL_SCOPES
+Additional OAuth2 scopes to request\&.
+.br
+Default is empty\&.
+.TP
+.B OAUTH2_ADMIN_GROUP
+Group for administrators\&.
+.br
+When set, the admin role is mapped based on the specified group\&.
+.br
+Default is empty\&.
+.TP
 .B OAUTH2_CLIENT_ID
 .B OAUTH2_CLIENT_ID
 OAuth2 client ID\&.
 OAuth2 client ID\&.
 .br
 .br
@@ -526,6 +538,13 @@ Path to a secret key exposed as a file, it should contain the
 .br
 .br
 Default is empty\&.
 Default is empty\&.
 .TP
 .TP
+.B OAUTH2_GROUP_CLAIM
+Claim that contains user groups\&.
+.br
+Used for obtaining user groups, which can be used for mapping the admin role\&.
+.br
+Default is empty\&.
+.TP
 .B OAUTH2_OIDC_DISCOVERY_ENDPOINT
 .B OAUTH2_OIDC_DISCOVERY_ENDPOINT
 OpenID Connect discovery endpoint\&.
 OpenID Connect discovery endpoint\&.
 .br
 .br