Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Override sub with federated_claims.user_id when dex is used #20683

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
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
15 changes: 14 additions & 1 deletion cmd/argocd/commands/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"golang.org/x/oauth2"

"github.com/argoproj/argo-cd/v2/cmd/argocd/commands/headless"
"github.com/argoproj/argo-cd/v2/cmd/argocd/commands/utils"
argocdclient "github.com/argoproj/argo-cd/v2/pkg/apiclient"
sessionpkg "github.com/argoproj/argo-cd/v2/pkg/apiclient/session"
settingspkg "github.com/argoproj/argo-cd/v2/pkg/apiclient/settings"
Expand Down Expand Up @@ -196,9 +197,21 @@ func userDisplayName(claims jwt.MapClaims) string {
if name := jwtutil.StringField(claims, "name"); name != "" {
return name
}
return jwtutil.StringField(claims, "sub")
argoClaims := &utils.ArgoClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: claims["sub"].(string),
},
}
if fedClaims, ok := claims["federated_claims"].(map[string]any); ok {
argoClaims.FederatedClaims = &utils.FederatedClaims{
ConnectorID: fedClaims["connector_id"].(string),
UserID: fedClaims["user_id"].(string),
}
}
return utils.GetUserIdentifier(argoClaims)
}

// oauth2Login opens a browser, runs a temporary HTTP server to delegate OAuth2 login flow and
// oauth2Login opens a browser, runs a temporary HTTP server to delegate OAuth2 login flow and
// returns the JWT token and a refresh token (if supported)
func oauth2Login(
Expand Down
15 changes: 15 additions & 0 deletions cmd/argocd/commands/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,18 @@ func Test_ssoAuthFlow_ssoLaunchBrowser_false(t *testing.T) {

assert.Contains(t, out, "To authenticate, copy-and-paste the following URL into your preferred browser: http://test-sso-browser-flow.com")
}

func Test_userDisplayName_federatedClaims(t *testing.T) {
claims := jwt.MapClaims{
"iss": "qux",
"sub": "foo",
"groups": []string{"baz"},
"federated_claims": map[string]any{
"connector_id": "dex",
"user_id": "ldap-123",
},
}
actualName := userDisplayName(claims)
expectedName := "ldap-123"
assert.Equal(t, expectedName, actualName)
}
18 changes: 17 additions & 1 deletion cmd/argocd/commands/project_role.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,22 @@ func tokenTimeToString(t int64) string {
return tokenTimeToString
}

func mapClaimsToArgoClaims(claims jwtgo.MapClaims) *utils.ArgoClaims {
sub := jwt.StringField(claims, "sub")
argoClaims := &utils.ArgoClaims{
RegisteredClaims: jwtgo.RegisteredClaims{
Subject: sub,
},
}
if fedClaims, ok := claims["federated_claims"].(map[string]any); ok {
argoClaims.FederatedClaims = &utils.FederatedClaims{
ConnectorID: fmt.Sprint(fedClaims["connector_id"]),
UserID: fmt.Sprint(fedClaims["user_id"]),
}
}
return argoClaims
}

// NewProjectRoleCreateTokenCommand returns a new instance of an `argocd proj role create-token` command
func NewProjectRoleCreateTokenCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command {
var (
Expand Down Expand Up @@ -332,7 +348,7 @@ Create token succeeded for proj:test-project:test-role.
issuedAt, _ := jwt.IssuedAt(claims)
expiresAt := int64(jwt.Float64Field(claims, "exp"))
id := jwt.StringField(claims, "jti")
subject := jwt.StringField(claims, "sub")
subject := utils.GetUserIdentifier(mapClaimsToArgoClaims(claims))

if !outputTokenOnly {
fmt.Printf("Create token succeeded for %s.\n", subject)
Expand Down
48 changes: 48 additions & 0 deletions cmd/argocd/commands/utils/claims.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package utils

import (
"fmt"

"github.com/golang-jwt/jwt/v5"
)

// ArgoClaims defines the claims structure based on Dex's documented claims
type ArgoClaims struct {
jwt.RegisteredClaims
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
Name string `json:"name,omitempty"`
Groups []string `json:"groups,omitempty"`
// As per Dex docs, federated_claims has a specific structure
FederatedClaims *FederatedClaims `json:"federated_claims,omitempty"`
}

// FederatedClaims represents the structure documented by Dex
type FederatedClaims struct {
ConnectorID string `json:"connector_id"`
UserID string `json:"user_id"`
}

// GetFederatedClaims extracts federated claims from jwt.MapClaims
func GetFederatedClaims(claims jwt.MapClaims) *FederatedClaims {
if federated, ok := claims["federated_claims"].(map[string]any); ok {
return &FederatedClaims{
ConnectorID: fmt.Sprint(federated["connector_id"]),
UserID: fmt.Sprint(federated["user_id"]),
}
}
return nil
}

// GetUserIdentifier returns a consistent user identifier, checking federated_claims.user_id when Dex is in use
func GetUserIdentifier(claims *ArgoClaims) string {
// Check federated claims first
if claims.FederatedClaims != nil && claims.FederatedClaims.UserID != "" {
return claims.FederatedClaims.UserID
}
// Fallback to sub
if claims.Subject != "" {
return claims.Subject
}
return ""
}
62 changes: 62 additions & 0 deletions cmd/argocd/commands/utils/claims_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package utils

import (
"testing"

"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
)

func TestGetUserIdentifier(t *testing.T) {
tests := []struct {
name string
claims *ArgoClaims
want string
}{
{
name: "when both dex and sub defined - prefer dex user_id",
claims: &ArgoClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "ignored:login",
},
FederatedClaims: &FederatedClaims{
UserID: "dex-user",
},
},
want: "dex-user",
},
{
name: "when both dex and sub defined but dex user_id empty - fallback to sub",
claims: &ArgoClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "test:apiKey",
},
FederatedClaims: &FederatedClaims{
UserID: "",
},
},
want: "test:apiKey",
},
{
name: "when only sub is defined (no dex) - use sub",
claims: &ArgoClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "admin:login",
},
},
want: "admin:login",
},
{
name: "when neither dex nor sub defined - return empty",
claims: &ArgoClaims{},
want: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetUserIdentifier(tt.claims)
assert.Equal(t, tt.want, got)
})
}
}
4 changes: 2 additions & 2 deletions server/account/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func NewServer(sessionMgr *session.SessionManager, settingsMgr *settings.Setting
// UpdatePassword updates the password of the currently authenticated account or the account specified in the request.
func (s *Server) UpdatePassword(ctx context.Context, q *account.UpdatePasswordRequest) (*account.UpdatePasswordResponse, error) {
issuer := session.Iss(ctx)
username := session.Sub(ctx)
username := session.GetUserIdentifier(ctx)
updatedUsername := username

if q.Name != "" {
Expand Down Expand Up @@ -169,7 +169,7 @@ func toApiAccount(name string, a settings.Account) *account.Account {

func (s *Server) ensureHasAccountPermission(ctx context.Context, action string, account string) error {
// account has always has access to itself
if session.Sub(ctx) == account && session.Iss(ctx) == session.SessionManagerClaimsIssuer {
if session.GetUserIdentifier(ctx) == account && session.Iss(ctx) == session.SessionManagerClaimsIssuer {
return nil
}
if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceAccounts, action, account); err != nil {
Expand Down
48 changes: 36 additions & 12 deletions server/account/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,30 +82,54 @@ func getAdminAccount(mgr *settings.SettingsManager) (*settings.Account, error) {
}

func adminContext(ctx context.Context) context.Context {
// nolint:staticcheck
return context.WithValue(ctx, "claims", &jwt.RegisteredClaims{Subject: "admin", Issuer: sessionutil.SessionManagerClaimsIssuer})
claims := jwt.MapClaims{
"sub": "admin",
"iss": sessionutil.SessionManagerClaimsIssuer,
"groups": []string{"role:admin"},
"federated_claims": map[string]any{
"user_id": "admin",
},
}
ctx = context.WithValue(ctx, sessionutil.ClaimsKey(), claims)
//nolint:staticcheck
ctx = context.WithValue(ctx, "claims", claims)
return ctx
}

func ssoAdminContext(ctx context.Context, iat time.Time) context.Context {
// nolint:staticcheck
return context.WithValue(ctx, "claims", &jwt.RegisteredClaims{
Subject: "admin",
Issuer: "https://myargocdhost.com/api/dex",
IssuedAt: jwt.NewNumericDate(iat),
})
claims := jwt.MapClaims{
"sub": "admin",
"iss": "https://myargocdhost.com/api/dex",
"iat": jwt.NewNumericDate(iat),
"groups": []string{"role:admin"}, // Add admin group
"federated_claims": map[string]any{
"user_id": "admin",
},
}
// Set both context values
ctx = context.WithValue(ctx, sessionutil.ClaimsKey(), claims)
//nolint:staticcheck
ctx = context.WithValue(ctx, "claims", claims)

return ctx
}

func projTokenContext(ctx context.Context) context.Context {
claims := jwt.MapClaims{
"sub": "proj:demo:deployer",
"iss": sessionutil.SessionManagerClaimsIssuer,
"groups": []string{"proj:demo:deployer"},
}
ctx = context.WithValue(ctx, sessionutil.ClaimsKey(), claims)
// nolint:staticcheck
return context.WithValue(ctx, "claims", &jwt.RegisteredClaims{
Subject: "proj:demo:deployer",
Issuer: sessionutil.SessionManagerClaimsIssuer,
})
ctx = context.WithValue(ctx, "claims", claims)
return ctx
}

func TestUpdatePassword(t *testing.T) {
accountServer, sessionServer := newTestAccountServer(context.Background())
ctx := adminContext(context.Background())

var err error

// ensure password is not allowed to be updated if given bad password
Expand Down
9 changes: 8 additions & 1 deletion server/rbacpolicy/rbacpolicy.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/golang-jwt/jwt/v5"
log "github.com/sirupsen/logrus"

"github.com/argoproj/argo-cd/v2/cmd/argocd/commands/utils"
"github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
applister "github.com/argoproj/argo-cd/v2/pkg/client/listers/application/v1alpha1"
jwtutil "github.com/argoproj/argo-cd/v2/util/jwt"
Expand Down Expand Up @@ -115,8 +116,14 @@ func (p *RBACPolicyEnforcer) EnforceClaims(claims jwt.Claims, rvals ...any) bool
if err != nil {
return false
}
argoClaims := &utils.ArgoClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: jwtutil.StringField(mapClaims, "sub"),
},
FederatedClaims: utils.GetFederatedClaims(mapClaims),
}

subject := jwtutil.StringField(mapClaims, "sub")
subject := utils.GetUserIdentifier(argoClaims)
// Check if the request is for an application resource. We have special enforcement which takes
// into consideration the project's token and group bindings
var runtimePolicy string
Expand Down
18 changes: 17 additions & 1 deletion server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import (
"k8s.io/client-go/tools/cache"
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/argoproj/argo-cd/v2/cmd/argocd/commands/utils"
"github.com/argoproj/argo-cd/v2/common"
"github.com/argoproj/argo-cd/v2/pkg/apiclient"
accountpkg "github.com/argoproj/argo-cd/v2/pkg/apiclient/account"
Expand Down Expand Up @@ -1532,6 +1533,15 @@ func (server *ArgoCDServer) getClaims(ctx context.Context) (jwt.Claims, string,
groupClaims = *tmpClaims
}
}

// Convert to ArgoClaims for user identifier comparison
argoClaims := &utils.ArgoClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: jwtutil.StringField(groupClaims, "sub"),
},
FederatedClaims: utils.GetFederatedClaims(groupClaims),
}

iss := jwtutil.StringField(groupClaims, "iss")
if iss != util_session.SessionManagerClaimsIssuer && server.settings.UserInfoGroupsEnabled() && server.settings.UserInfoPath() != "" {
userInfo, unauthorized, err := server.ssoClientApp.GetUserInfo(groupClaims, server.settings.IssuerURL(), server.settings.UserInfoPath())
Expand All @@ -1543,7 +1553,13 @@ func (server *ArgoCDServer) getClaims(ctx context.Context) (jwt.Claims, string,
log.Errorf("error fetching user info endpoint: %v", err)
return claims, "", status.Errorf(codes.Internal, "invalid userinfo response")
}
if groupClaims["sub"] != userInfo["sub"] {
userInfoClaims := &utils.ArgoClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: jwtutil.StringField(userInfo, "sub"),
},
FederatedClaims: utils.GetFederatedClaims(userInfo),
}
if utils.GetUserIdentifier(argoClaims) != utils.GetUserIdentifier(userInfoClaims) {
return claims, "", status.Error(codes.Unknown, "subject of claims from user info endpoint didn't match subject of idToken, see https://openid.net/specs/openid-connect-core-1_0.html#UserInfo")
}
groupClaims["groups"] = userInfo["groups"]
Expand Down
Loading
Loading