mirror of
https://github.com/argoproj/argo-cd
synced 2026-04-21 17:07:16 +00:00
Merge pull request from GHSA-q9hr-j4rf-8fjc
* fix: verify audience claim Co-Authored-By: Vladimir Pouzanov <farcaller@gmail.com> Signed-off-by: CI <350466+crenshaw-dev@users.noreply.github.com> * fix lint Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com> * fix handling of expired token error Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com> * go mod tidy Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com> * handle single aud claim marshaled as a string Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com> Signed-off-by: CI <350466+crenshaw-dev@users.noreply.github.com> Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com> Co-authored-by: Vladimir Pouzanov <farcaller@gmail.com>
This commit is contained in:
parent
95da518e7d
commit
b38bc0040b
14 changed files with 774 additions and 63 deletions
|
|
@ -206,7 +206,7 @@ var validatorsByGroup = map[string]settingValidator{
|
|||
}
|
||||
ssoProvider = "Dex"
|
||||
} else if general.OIDCConfigRAW != "" {
|
||||
if _, err := settings.UnmarshalOIDCConfig(general.OIDCConfigRAW); err != nil {
|
||||
if err := settings.ValidateOIDCConfig(general.OIDCConfigRAW); err != nil {
|
||||
return "", fmt.Errorf("invalid oidc.config: %v", err)
|
||||
}
|
||||
ssoProvider = "OIDC"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
|
@ -316,3 +317,8 @@ const (
|
|||
SecurityMedium = 2 // Could indicate malicious events, but has a high likelihood of being user/system error (i.e. access denied)
|
||||
SecurityLow = 1 // Unexceptional entries (i.e. successful access logs)
|
||||
)
|
||||
|
||||
// Common error messages
|
||||
const TokenVerificationError = "failed to verify the token"
|
||||
|
||||
var TokenVerificationErr = errors.New(TokenVerificationError)
|
||||
|
|
|
|||
|
|
@ -301,6 +301,18 @@ data:
|
|||
issuer: https://dev-123456.oktapreview.com
|
||||
clientID: aaaabbbbccccddddeee
|
||||
clientSecret: $oidc.okta.clientSecret
|
||||
|
||||
# Optional list of allowed aud claims. If omitted or empty, defaults to the clientID value above. If you specify a
|
||||
# list and want the clientD to be allowed, you must explicitly include it in the list.
|
||||
# Token verification will pass if any of the token's audiences matches any of the audiences in this list.
|
||||
allowedAudiences:
|
||||
- aaaabbbbccccddddeee
|
||||
- qqqqwwwweeeerrrrttt
|
||||
|
||||
# Optional. If false, tokens without an audience will always fail validation. If true, tokens without an audience
|
||||
# will always pass validation.
|
||||
# Defaults to true for Argo CD < 2.6.0. Defaults to false for Argo CD >= 2.6.0.
|
||||
skipAudienceCheckWhenTokenHasNoAudience: true
|
||||
|
||||
# Optional set of OIDC scopes to request. If omitted, defaults to: ["openid", "profile", "email", "groups"]
|
||||
requestedScopes: ["openid", "profile", "email", "groups"]
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -99,7 +99,7 @@ require (
|
|||
require (
|
||||
github.com/gfleury/go-bitbucket-v1 v0.0.0-20220301131131-8e7ed04b843e
|
||||
github.com/stretchr/objx v0.5.0 // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.6.0
|
||||
k8s.io/apiserver v0.24.2
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import (
|
|||
appstatecache "github.com/argoproj/argo-cd/v2/util/cache/appstate"
|
||||
"github.com/argoproj/argo-cd/v2/util/rbac"
|
||||
settings_util "github.com/argoproj/argo-cd/v2/util/settings"
|
||||
testutil "github.com/argoproj/argo-cd/v2/util/test"
|
||||
)
|
||||
|
||||
func fakeServer() (*ArgoCDServer, func()) {
|
||||
|
|
@ -522,7 +523,7 @@ func dexMockHandler(t *testing.T, url string) func(http.ResponseWriter, *http.Re
|
|||
}
|
||||
}
|
||||
|
||||
func getTestServer(t *testing.T, anonymousEnabled bool, withFakeSSO bool) (argocd *ArgoCDServer, dexURL string) {
|
||||
func getTestServer(t *testing.T, anonymousEnabled bool, withFakeSSO bool, useDexForSSO bool) (argocd *ArgoCDServer, oidcURL string) {
|
||||
cm := test.NewFakeConfigMap()
|
||||
if anonymousEnabled {
|
||||
cm.Data["users.anonymous.enabled"] = "true"
|
||||
|
|
@ -533,9 +534,14 @@ func getTestServer(t *testing.T, anonymousEnabled bool, withFakeSSO bool) (argoc
|
|||
ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
dexMockHandler(t, ts.URL)(w, r)
|
||||
})
|
||||
oidcServer := ts
|
||||
if !useDexForSSO {
|
||||
oidcServer = testutil.GetOIDCTestServer(t)
|
||||
}
|
||||
if withFakeSSO {
|
||||
cm.Data["url"] = ts.URL
|
||||
cm.Data["dex.config"] = `
|
||||
if useDexForSSO {
|
||||
cm.Data["dex.config"] = `
|
||||
connectors:
|
||||
# OIDC
|
||||
- type: OIDC
|
||||
|
|
@ -545,6 +551,19 @@ connectors:
|
|||
issuer: https://auth.example.gom
|
||||
clientID: test-client
|
||||
clientSecret: $dex.oidc.clientSecret`
|
||||
} else {
|
||||
oidcConfig := settings_util.OIDCConfig{
|
||||
Name: "Okta",
|
||||
Issuer: oidcServer.URL,
|
||||
ClientID: "argo-cd",
|
||||
ClientSecret: "$oidc.okta.clientSecret",
|
||||
}
|
||||
oidcConfigString, err := yaml.Marshal(oidcConfig)
|
||||
require.NoError(t, err)
|
||||
cm.Data["oidc.config"] = string(oidcConfigString)
|
||||
// Avoid bothering with certs for local tests.
|
||||
cm.Data["oidc.tls.insecure.skip.verify"] = "true"
|
||||
}
|
||||
}
|
||||
secret := test.NewFakeSecret()
|
||||
kubeclientset := fake.NewSimpleClientset(cm, secret)
|
||||
|
|
@ -556,27 +575,32 @@ connectors:
|
|||
AppClientset: appClientSet,
|
||||
RepoClientset: mockRepoClient,
|
||||
}
|
||||
if withFakeSSO {
|
||||
if withFakeSSO && useDexForSSO {
|
||||
argoCDOpts.DexServerAddr = ts.URL
|
||||
}
|
||||
argocd = NewServer(context.Background(), argoCDOpts)
|
||||
return argocd, ts.URL
|
||||
return argocd, oidcServer.URL
|
||||
}
|
||||
|
||||
func TestAuthenticate_3rd_party_JWTs(t *testing.T) {
|
||||
// Marshaling single strings to strings is typical, so we test for this relatively common behavior.
|
||||
jwt.MarshalSingleStringAsArray = false
|
||||
|
||||
type testData struct {
|
||||
test string
|
||||
anonymousEnabled bool
|
||||
claims jwt.RegisteredClaims
|
||||
expectedErrorContains string
|
||||
expectedClaims interface{}
|
||||
useDex bool
|
||||
}
|
||||
var tests = []testData{
|
||||
// Dex
|
||||
{
|
||||
test: "anonymous disabled, no audience",
|
||||
anonymousEnabled: false,
|
||||
claims: jwt.RegisteredClaims{},
|
||||
expectedErrorContains: "no audience found in the token",
|
||||
claims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
|
||||
expectedErrorContains: common.TokenVerificationError,
|
||||
expectedClaims: nil,
|
||||
},
|
||||
{
|
||||
|
|
@ -589,31 +613,95 @@ func TestAuthenticate_3rd_party_JWTs(t *testing.T) {
|
|||
{
|
||||
test: "anonymous disabled, unexpired token, admin claim",
|
||||
anonymousEnabled: false,
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
|
||||
expectedErrorContains: "id token signed with unsupported algorithm",
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
|
||||
expectedErrorContains: common.TokenVerificationError,
|
||||
expectedClaims: nil,
|
||||
},
|
||||
{
|
||||
test: "anonymous enabled, unexpired token, admin claim",
|
||||
anonymousEnabled: true,
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
|
||||
expectedErrorContains: "",
|
||||
expectedClaims: "",
|
||||
},
|
||||
{
|
||||
test: "anonymous disabled, expired token, admin claim",
|
||||
anonymousEnabled: false,
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
|
||||
expectedErrorContains: "token is expired",
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
|
||||
expectedErrorContains: common.TokenVerificationError,
|
||||
expectedClaims: jwt.RegisteredClaims{Issuer: "sso"},
|
||||
},
|
||||
{
|
||||
test: "anonymous enabled, expired token, admin claim",
|
||||
anonymousEnabled: true,
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
|
||||
expectedErrorContains: "",
|
||||
expectedClaims: "",
|
||||
},
|
||||
{
|
||||
test: "anonymous disabled, unexpired token, admin claim, incorrect audience",
|
||||
anonymousEnabled: false,
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"incorrect-audience"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
|
||||
expectedErrorContains: common.TokenVerificationError,
|
||||
expectedClaims: nil,
|
||||
},
|
||||
// External OIDC (not bundled Dex)
|
||||
{
|
||||
test: "external OIDC: anonymous disabled, no audience",
|
||||
anonymousEnabled: false,
|
||||
claims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
|
||||
useDex: true,
|
||||
expectedErrorContains: common.TokenVerificationError,
|
||||
expectedClaims: nil,
|
||||
},
|
||||
{
|
||||
test: "external OIDC: anonymous enabled, no audience",
|
||||
anonymousEnabled: true,
|
||||
claims: jwt.RegisteredClaims{},
|
||||
useDex: true,
|
||||
expectedErrorContains: "",
|
||||
expectedClaims: "",
|
||||
},
|
||||
{
|
||||
test: "external OIDC: anonymous disabled, unexpired token, admin claim",
|
||||
anonymousEnabled: false,
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
|
||||
useDex: true,
|
||||
expectedErrorContains: common.TokenVerificationError,
|
||||
expectedClaims: nil,
|
||||
},
|
||||
{
|
||||
test: "external OIDC: anonymous enabled, unexpired token, admin claim",
|
||||
anonymousEnabled: true,
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
|
||||
useDex: true,
|
||||
expectedErrorContains: "",
|
||||
expectedClaims: "",
|
||||
},
|
||||
{
|
||||
test: "external OIDC: anonymous disabled, expired token, admin claim",
|
||||
anonymousEnabled: false,
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
|
||||
useDex: true,
|
||||
expectedErrorContains: common.TokenVerificationError,
|
||||
expectedClaims: jwt.RegisteredClaims{Issuer: "sso"},
|
||||
},
|
||||
{
|
||||
test: "external OIDC: anonymous enabled, expired token, admin claim",
|
||||
anonymousEnabled: true,
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
|
||||
useDex: true,
|
||||
expectedErrorContains: "",
|
||||
expectedClaims: "",
|
||||
},
|
||||
{
|
||||
test: "external OIDC: anonymous disabled, unexpired token, admin claim, incorrect audience",
|
||||
anonymousEnabled: false,
|
||||
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"incorrect-audience"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
|
||||
useDex: true,
|
||||
expectedErrorContains: common.TokenVerificationError,
|
||||
expectedClaims: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testData := range tests {
|
||||
|
|
@ -625,8 +713,13 @@ func TestAuthenticate_3rd_party_JWTs(t *testing.T) {
|
|||
// Must be declared here to avoid race.
|
||||
ctx := context.Background() //nolint:ineffassign,staticcheck
|
||||
|
||||
argocd, dexURL := getTestServer(t, testDataCopy.anonymousEnabled, true)
|
||||
testDataCopy.claims.Issuer = fmt.Sprintf("%s/api/dex", dexURL)
|
||||
argocd, oidcURL := getTestServer(t, testDataCopy.anonymousEnabled, true, testDataCopy.useDex)
|
||||
|
||||
if testDataCopy.useDex {
|
||||
testDataCopy.claims.Issuer = fmt.Sprintf("%s/api/dex", oidcURL)
|
||||
} else {
|
||||
testDataCopy.claims.Issuer = oidcURL
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, testDataCopy.claims)
|
||||
tokenString, err := token.SignedString([]byte("key"))
|
||||
require.NoError(t, err)
|
||||
|
|
@ -676,7 +769,7 @@ func TestAuthenticate_no_request_metadata(t *testing.T) {
|
|||
t.Run(testDataCopy.test, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
argocd, _ := getTestServer(t, testDataCopy.anonymousEnabled, true)
|
||||
argocd, _ := getTestServer(t, testDataCopy.anonymousEnabled, true, true)
|
||||
ctx := context.Background()
|
||||
|
||||
ctx, err := argocd.Authenticate(ctx)
|
||||
|
|
@ -722,7 +815,7 @@ func TestAuthenticate_no_SSO(t *testing.T) {
|
|||
// Must be declared here to avoid race.
|
||||
ctx := context.Background() //nolint:ineffassign,staticcheck
|
||||
|
||||
argocd, dexURL := getTestServer(t, testDataCopy.anonymousEnabled, false)
|
||||
argocd, dexURL := getTestServer(t, testDataCopy.anonymousEnabled, false, true)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{Issuer: fmt.Sprintf("%s/api/dex", dexURL)})
|
||||
tokenString, err := token.SignedString([]byte("key"))
|
||||
require.NoError(t, err)
|
||||
|
|
@ -795,7 +888,7 @@ func TestAuthenticate_bad_request_metadata(t *testing.T) {
|
|||
test: "anonymous disabled, bad auth header",
|
||||
anonymousEnabled: false,
|
||||
metadata: metadata.MD{"authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiJ9.TGGTTHuuGpEU8WgobXxkrBtW3NiR3dgw5LR-1DEW3BQ"}},
|
||||
expectedErrorMessage: "no audience found in the token",
|
||||
expectedErrorMessage: common.TokenVerificationError,
|
||||
expectedClaims: nil,
|
||||
},
|
||||
{
|
||||
|
|
@ -809,7 +902,7 @@ func TestAuthenticate_bad_request_metadata(t *testing.T) {
|
|||
test: "anonymous disabled, bad auth cookie",
|
||||
anonymousEnabled: false,
|
||||
metadata: metadata.MD{"grpcgateway-cookie": []string{"argocd.token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiJ9.TGGTTHuuGpEU8WgobXxkrBtW3NiR3dgw5LR-1DEW3BQ"}},
|
||||
expectedErrorMessage: "no audience found in the token",
|
||||
expectedErrorMessage: common.TokenVerificationError,
|
||||
expectedClaims: nil,
|
||||
},
|
||||
{
|
||||
|
|
@ -830,7 +923,7 @@ func TestAuthenticate_bad_request_metadata(t *testing.T) {
|
|||
// Must be declared here to avoid race.
|
||||
ctx := context.Background() //nolint:ineffassign,staticcheck
|
||||
|
||||
argocd, _ := getTestServer(t, testDataCopy.anonymousEnabled, true)
|
||||
argocd, _ := getTestServer(t, testDataCopy.anonymousEnabled, true, true)
|
||||
ctx = metadata.NewIncomingContext(context.Background(), testDataCopy.metadata)
|
||||
|
||||
ctx, err := argocd.Authenticate(ctx)
|
||||
|
|
|
|||
|
|
@ -353,9 +353,12 @@ func (a *ClientApp) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, "no id_token in token response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
idToken, err := a.provider.Verify(a.clientID, idTokenRAW)
|
||||
|
||||
idToken, err := a.provider.Verify(idTokenRAW, a.settings)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusInternalServerError)
|
||||
log.Warnf("Failed to verify token: %s", err)
|
||||
http.Error(w, common.TokenVerificationError, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
path := "/"
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ func (p *fakeProvider) ParseConfig() (*OIDCConfiguration, error) {
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *fakeProvider) Verify(_, _ string) (*gooidc.IDToken, error) {
|
||||
func (p *fakeProvider) Verify(_ string, _ *settings.ArgoCDSettings) (*gooidc.IDToken, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package oidc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
|
@ -9,6 +10,9 @@ import (
|
|||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/argoproj/argo-cd/v2/util/security"
|
||||
"github.com/argoproj/argo-cd/v2/util/settings"
|
||||
)
|
||||
|
||||
// Provider is a wrapper around go-oidc provider to also provide the following features:
|
||||
|
|
@ -23,7 +27,7 @@ type Provider interface {
|
|||
|
||||
ParseConfig() (*OIDCConfiguration, error)
|
||||
|
||||
Verify(clientID, tokenString string) (*gooidc.IDToken, error)
|
||||
Verify(tokenString string, argoSettings *settings.ArgoCDSettings) (*gooidc.IDToken, error)
|
||||
}
|
||||
|
||||
type providerImpl struct {
|
||||
|
|
@ -69,13 +73,61 @@ func (p *providerImpl) newGoOIDCProvider() (*gooidc.Provider, error) {
|
|||
return prov, nil
|
||||
}
|
||||
|
||||
func (p *providerImpl) Verify(clientID, tokenString string) (*gooidc.IDToken, error) {
|
||||
func (p *providerImpl) Verify(tokenString string, argoSettings *settings.ArgoCDSettings) (*gooidc.IDToken, error) {
|
||||
// According to the JWT spec, the aud claim is optional. The spec also says (emphasis mine):
|
||||
//
|
||||
// If the principal processing the claim does not identify itself with a value in the "aud" claim _when this
|
||||
// claim is present_, then the JWT MUST be rejected.
|
||||
//
|
||||
// - https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3
|
||||
//
|
||||
// If the claim is not present, we can skip the audience claim check (called the "ClientID check" in go-oidc's
|
||||
// terminology).
|
||||
//
|
||||
// The OIDC spec says that the aud claim is required (https://openid.net/specs/openid-connect-core-1_0.html#IDToken).
|
||||
// But we cannot assume that all OIDC providers will follow the spec. For Argo CD <2.6.0, we will default to
|
||||
// allowing the aud claim to be optional. In Argo CD >=2.6.0, we will default to requiring the aud claim to be
|
||||
// present and give users the skipAudienceCheckWhenTokenHasNoAudience setting to revert the behavior if necessary.
|
||||
//
|
||||
// At this point, we have not verified that the token has not been altered. All code paths below MUST VERIFY
|
||||
// THE TOKEN SIGNATURE to confirm that an attacker did not maliciously remove the "aud" claim.
|
||||
unverifiedHasAudClaim, err := security.UnverifiedHasAudClaim(tokenString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to determine whether the token has an aud claim: %w", err)
|
||||
}
|
||||
|
||||
var idToken *gooidc.IDToken
|
||||
if !unverifiedHasAudClaim {
|
||||
idToken, err = p.verify("", tokenString, argoSettings.SkipAudienceCheckWhenTokenHasNoAudience())
|
||||
} else {
|
||||
allowedAudiences := argoSettings.OAuth2AllowedAudiences()
|
||||
if len(allowedAudiences) == 0 {
|
||||
return nil, errors.New("token has an audience claim, but no allowed audiences are configured")
|
||||
}
|
||||
// Token must be verified for at least one allowed audience
|
||||
for _, aud := range allowedAudiences {
|
||||
idToken, err = p.verify(aud, tokenString, false)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify token: %w", err)
|
||||
}
|
||||
|
||||
return idToken, nil
|
||||
}
|
||||
|
||||
func (p *providerImpl) verify(clientID, tokenString string, skipClientIDCheck bool) (*gooidc.IDToken, error) {
|
||||
ctx := context.Background()
|
||||
prov, err := p.provider()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifier := prov.Verifier(&gooidc.Config{ClientID: clientID})
|
||||
config := &gooidc.Config{ClientID: clientID, SkipClientIDCheck: skipClientIDCheck}
|
||||
verifier := prov.Verifier(config)
|
||||
idToken, err := verifier.Verify(ctx, tokenString)
|
||||
if err != nil {
|
||||
// HACK: if we failed token verification, it's possible the reason was because dex
|
||||
|
|
@ -93,7 +145,7 @@ func (p *providerImpl) Verify(clientID, tokenString string) (*gooidc.IDToken, er
|
|||
// return original error if we fail to re-initialize OIDC
|
||||
return nil, err
|
||||
}
|
||||
verifier = newProvider.Verifier(&gooidc.Config{ClientID: clientID})
|
||||
verifier = newProvider.Verifier(config)
|
||||
idToken, err = verifier.Verify(ctx, tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
80
util/security/jwt.go
Normal file
80
util/security/jwt.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseJWT parses a jwt and returns it as json bytes.
|
||||
//
|
||||
// This function DOES NOT VERIFY THE TOKEN. You still have to verify the token to confirm that the token holder has not
|
||||
// altered the claims.
|
||||
//
|
||||
// This code is copied almost verbatim from go-oidc (https://github.com/coreos/go-oidc).
|
||||
func parseJWT(p string) ([]byte, error) {
|
||||
parts := strings.Split(p, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("malformed jwt, expected 3 parts got %d", len(parts))
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed jwt payload: %v", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
type audience []string
|
||||
|
||||
// UnmarshalJSON allows us to unmarshal either a single audience or a list of audiences.
|
||||
// Taken from: https://github.com/coreos/go-oidc/blob/a8ceb9a2043fca2e43518633920db746808b1138/oidc/oidc.go#L475
|
||||
func (a *audience) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if json.Unmarshal(b, &s) == nil {
|
||||
*a = audience{s}
|
||||
return nil
|
||||
}
|
||||
var auds []string
|
||||
if err := json.Unmarshal(b, &auds); err != nil {
|
||||
return err
|
||||
}
|
||||
*a = auds
|
||||
return nil
|
||||
}
|
||||
|
||||
// jwtWithOnlyAudClaim represents a jwt where only the "aud" claim is present. This struct allows us to unmarshal a jwt
|
||||
// and be confident that the only information retrieved from that jwt is the "aud" claim.
|
||||
type jwtWithOnlyAudClaim struct {
|
||||
Aud audience `json:"aud"`
|
||||
}
|
||||
|
||||
// getUnverifiedAudClaim gets the "aud" claim from a jwt.
|
||||
//
|
||||
// This function DOES NOT VERIFY THE TOKEN. You still have to verify the token to confirm that the token holder has not
|
||||
// altered the "aud" claim.
|
||||
//
|
||||
// This code is copied almost verbatim from go-oidc (https://github.com/coreos/go-oidc).
|
||||
func getUnverifiedAudClaim(rawIDToken string) ([]string, error) {
|
||||
payload, err := parseJWT(rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed jwt: %v", err)
|
||||
}
|
||||
var token jwtWithOnlyAudClaim
|
||||
if err = json.Unmarshal(payload, &token); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal claims: %v", err)
|
||||
}
|
||||
return token.Aud, nil
|
||||
}
|
||||
|
||||
// UnverifiedHasAudClaim returns whether the "aud" claim is present in the given JWT.
|
||||
//
|
||||
// This function DOES NOT VERIFY THE TOKEN. You still have to verify the token to confirm that the token holder has not
|
||||
// altered the "aud" claim.
|
||||
func UnverifiedHasAudClaim(rawIDToken string) (bool, error) {
|
||||
aud, err := getUnverifiedAudClaim(rawIDToken)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to determine whether token had an audience claim: %w", err)
|
||||
}
|
||||
return aud != nil, nil
|
||||
}
|
||||
56
util/security/jwt_test.go
Normal file
56
util/security/jwt_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
utiltest "github.com/argoproj/argo-cd/v2/util/test"
|
||||
)
|
||||
|
||||
func Test_UnverifiedHasAudClaim(t *testing.T) {
|
||||
tokenForAud := func(t *testing.T, aud jwt.ClaimStrings) string {
|
||||
claims := jwt.RegisteredClaims{Audience: aud, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
|
||||
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
tokenString, err := token.SignedString(key)
|
||||
require.NoError(t, err)
|
||||
return tokenString
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
aud jwt.ClaimStrings
|
||||
expectedHasAud bool
|
||||
}{
|
||||
{
|
||||
name: "no audience",
|
||||
aud: jwt.ClaimStrings{},
|
||||
expectedHasAud: false,
|
||||
},
|
||||
{
|
||||
name: "one empty audience",
|
||||
aud: jwt.ClaimStrings{""},
|
||||
expectedHasAud: true,
|
||||
},
|
||||
{
|
||||
name: "one non-empty audience",
|
||||
aud: jwt.ClaimStrings{"test"},
|
||||
expectedHasAud: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCaseCopy := testCase
|
||||
t.Run(testCaseCopy.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
out, err := UnverifiedHasAudClaim(tokenForAud(t, testCaseCopy.aud))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCaseCopy.expectedHasAud, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -524,32 +524,29 @@ func (mgr *SessionManager) VerifyToken(tokenString string) (jwt.Claims, string,
|
|||
return nil, "", err
|
||||
}
|
||||
|
||||
// Token must be verified for at least one audience
|
||||
// TODO(jannfis): Is this the right way? Shouldn't we know our audience and only validate for the correct one?
|
||||
var idToken *oidc.IDToken
|
||||
for _, aud := range claims.Audience {
|
||||
idToken, err = prov.Verify(aud, tokenString)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
argoSettings, err := mgr.settingsMgr.GetSettings()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("cannot access settings while verifying the token: %w", err)
|
||||
}
|
||||
if argoSettings == nil {
|
||||
return nil, "", fmt.Errorf("settings are not available while verifying the token")
|
||||
}
|
||||
|
||||
idToken, err := prov.Verify(tokenString, argoSettings)
|
||||
|
||||
// The token verification has failed. If the token has expired, we will
|
||||
// return a dummy claims only containing a value for the issuer, so the
|
||||
// UI can handle expired tokens appropriately.
|
||||
if err != nil {
|
||||
log.Warnf("Failed to verify token: %s", err)
|
||||
tokenExpiredError := &oidc.TokenExpiredError{}
|
||||
if errors.As(err, &tokenExpiredError) {
|
||||
claims = jwt.RegisteredClaims{
|
||||
Issuer: "sso",
|
||||
}
|
||||
return claims, "", err
|
||||
return claims, "", common.TokenVerificationErr
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if idToken == nil {
|
||||
return nil, "", fmt.Errorf("no audience found in the token")
|
||||
return nil, "", common.TokenVerificationErr
|
||||
}
|
||||
|
||||
var claims jwt.MapClaims
|
||||
|
|
|
|||
|
|
@ -722,9 +722,7 @@ rootCA: |
|
|||
|
||||
_, _, err = mgr.VerifyToken(tokenString)
|
||||
require.Error(t, err)
|
||||
if !strings.Contains(err.Error(), "certificate signed by unknown authority") && !strings.Contains(err.Error(), "certificate is not trusted") {
|
||||
t.Fatal("did not receive expected certificate verification failure error")
|
||||
}
|
||||
assert.ErrorIs(t, err, common.TokenVerificationErr)
|
||||
})
|
||||
|
||||
t.Run("OIDC provider is external, TLS is configured", func(t *testing.T) {
|
||||
|
|
@ -759,9 +757,7 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
|||
|
||||
_, _, err = mgr.VerifyToken(tokenString)
|
||||
require.Error(t, err)
|
||||
if !strings.Contains(err.Error(), "certificate signed by unknown authority") && !strings.Contains(err.Error(), "certificate is not trusted") {
|
||||
t.Fatal("did not receive expected certificate verification failure error")
|
||||
}
|
||||
assert.ErrorIs(t, err, common.TokenVerificationErr)
|
||||
})
|
||||
|
||||
t.Run("OIDC provider is Dex, TLS is configured", func(t *testing.T) {
|
||||
|
|
@ -796,9 +792,7 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
|||
|
||||
_, _, err = mgr.VerifyToken(tokenString)
|
||||
require.Error(t, err)
|
||||
if !strings.Contains(err.Error(), "certificate signed by unknown authority") && !strings.Contains(err.Error(), "certificate is not trusted") {
|
||||
t.Fatal("did not receive expected certificate verification failure error")
|
||||
}
|
||||
assert.ErrorIs(t, err, common.TokenVerificationErr)
|
||||
})
|
||||
|
||||
t.Run("OIDC provider is external, TLS is configured, OIDCTLSInsecureSkipVerify is true", func(t *testing.T) {
|
||||
|
|
@ -866,4 +860,295 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
|||
assert.NotContains(t, err.Error(), "certificate is not trusted")
|
||||
assert.NotContains(t, err.Error(), "certificate signed by unknown authority")
|
||||
})
|
||||
|
||||
t.Run("OIDC provider is external, audience is not specified", func(t *testing.T) {
|
||||
config := map[string]string{
|
||||
"url": "",
|
||||
"oidc.config": fmt.Sprintf(`
|
||||
name: Test
|
||||
issuer: %s
|
||||
clientID: xxx
|
||||
clientSecret: yyy
|
||||
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
||||
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
|
||||
}
|
||||
|
||||
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
|
||||
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
|
||||
secretConfig := map[string][]byte{
|
||||
"tls.crt": utiltest.Cert,
|
||||
"tls.key": utiltest.PrivateKey,
|
||||
}
|
||||
|
||||
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
|
||||
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
|
||||
mgr.verificationDelayNoiseEnabled = false
|
||||
|
||||
claims := jwt.RegisteredClaims{Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
|
||||
claims.Issuer = oidcTestServer.URL
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
|
||||
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
tokenString, err := token.SignedString(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = mgr.VerifyToken(tokenString)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("OIDC provider is external, audience is not specified but is required", func(t *testing.T) {
|
||||
config := map[string]string{
|
||||
"url": "",
|
||||
"oidc.config": fmt.Sprintf(`
|
||||
name: Test
|
||||
issuer: %s
|
||||
clientID: xxx
|
||||
clientSecret: yyy
|
||||
requestedScopes: ["oidc"]
|
||||
skipAudienceCheckWhenTokenHasNoAudience: false`, oidcTestServer.URL),
|
||||
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
|
||||
}
|
||||
|
||||
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
|
||||
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
|
||||
secretConfig := map[string][]byte{
|
||||
"tls.crt": utiltest.Cert,
|
||||
"tls.key": utiltest.PrivateKey,
|
||||
}
|
||||
|
||||
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
|
||||
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
|
||||
mgr.verificationDelayNoiseEnabled = false
|
||||
|
||||
claims := jwt.RegisteredClaims{Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
|
||||
claims.Issuer = oidcTestServer.URL
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
|
||||
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
tokenString, err := token.SignedString(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = mgr.VerifyToken(tokenString)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, common.TokenVerificationErr)
|
||||
})
|
||||
|
||||
t.Run("OIDC provider is external, audience is client ID, no allowed list specified", func(t *testing.T) {
|
||||
config := map[string]string{
|
||||
"url": "",
|
||||
"oidc.config": fmt.Sprintf(`
|
||||
name: Test
|
||||
issuer: %s
|
||||
clientID: xxx
|
||||
clientSecret: yyy
|
||||
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
||||
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
|
||||
}
|
||||
|
||||
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
|
||||
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
|
||||
secretConfig := map[string][]byte{
|
||||
"tls.crt": utiltest.Cert,
|
||||
"tls.key": utiltest.PrivateKey,
|
||||
}
|
||||
|
||||
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
|
||||
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
|
||||
mgr.verificationDelayNoiseEnabled = false
|
||||
|
||||
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"xxx"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
|
||||
claims.Issuer = oidcTestServer.URL
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
|
||||
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
tokenString, err := token.SignedString(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = mgr.VerifyToken(tokenString)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("OIDC provider is external, audience is in allowed list", func(t *testing.T) {
|
||||
config := map[string]string{
|
||||
"url": "",
|
||||
"oidc.config": fmt.Sprintf(`
|
||||
name: Test
|
||||
issuer: %s
|
||||
clientID: xxx
|
||||
clientSecret: yyy
|
||||
requestedScopes: ["oidc"]
|
||||
allowedAudiences:
|
||||
- something`, oidcTestServer.URL),
|
||||
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
|
||||
}
|
||||
|
||||
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
|
||||
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
|
||||
secretConfig := map[string][]byte{
|
||||
"tls.crt": utiltest.Cert,
|
||||
"tls.key": utiltest.PrivateKey,
|
||||
}
|
||||
|
||||
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
|
||||
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
|
||||
mgr.verificationDelayNoiseEnabled = false
|
||||
|
||||
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"something"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
|
||||
claims.Issuer = oidcTestServer.URL
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
|
||||
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
tokenString, err := token.SignedString(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = mgr.VerifyToken(tokenString)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("OIDC provider is external, audience is not in allowed list", func(t *testing.T) {
|
||||
config := map[string]string{
|
||||
"url": "",
|
||||
"oidc.config": fmt.Sprintf(`
|
||||
name: Test
|
||||
issuer: %s
|
||||
clientID: xxx
|
||||
clientSecret: yyy
|
||||
requestedScopes: ["oidc"]
|
||||
allowedAudiences:
|
||||
- something-else`, oidcTestServer.URL),
|
||||
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
|
||||
}
|
||||
|
||||
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
|
||||
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
|
||||
secretConfig := map[string][]byte{
|
||||
"tls.crt": utiltest.Cert,
|
||||
"tls.key": utiltest.PrivateKey,
|
||||
}
|
||||
|
||||
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
|
||||
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
|
||||
mgr.verificationDelayNoiseEnabled = false
|
||||
|
||||
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"something"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
|
||||
claims.Issuer = oidcTestServer.URL
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
|
||||
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
tokenString, err := token.SignedString(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = mgr.VerifyToken(tokenString)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, common.TokenVerificationErr)
|
||||
})
|
||||
|
||||
t.Run("OIDC provider is external, audience is not client ID, and there is no allow list", func(t *testing.T) {
|
||||
config := map[string]string{
|
||||
"url": "",
|
||||
"oidc.config": fmt.Sprintf(`
|
||||
name: Test
|
||||
issuer: %s
|
||||
clientID: xxx
|
||||
clientSecret: yyy
|
||||
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
||||
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
|
||||
}
|
||||
|
||||
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
|
||||
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
|
||||
secretConfig := map[string][]byte{
|
||||
"tls.crt": utiltest.Cert,
|
||||
"tls.key": utiltest.PrivateKey,
|
||||
}
|
||||
|
||||
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
|
||||
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
|
||||
mgr.verificationDelayNoiseEnabled = false
|
||||
|
||||
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"something"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
|
||||
claims.Issuer = oidcTestServer.URL
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
|
||||
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
tokenString, err := token.SignedString(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = mgr.VerifyToken(tokenString)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, common.TokenVerificationErr)
|
||||
})
|
||||
|
||||
t.Run("OIDC provider is external, audience is specified, but allow list is empty", func(t *testing.T) {
|
||||
config := map[string]string{
|
||||
"url": "",
|
||||
"oidc.config": fmt.Sprintf(`
|
||||
name: Test
|
||||
issuer: %s
|
||||
clientID: xxx
|
||||
clientSecret: yyy
|
||||
requestedScopes: ["oidc"]
|
||||
allowedAudiences: []`, oidcTestServer.URL),
|
||||
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
|
||||
}
|
||||
|
||||
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
|
||||
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
|
||||
secretConfig := map[string][]byte{
|
||||
"tls.crt": utiltest.Cert,
|
||||
"tls.key": utiltest.PrivateKey,
|
||||
}
|
||||
|
||||
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
|
||||
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
|
||||
mgr.verificationDelayNoiseEnabled = false
|
||||
|
||||
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"something"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
|
||||
claims.Issuer = oidcTestServer.URL
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
|
||||
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
tokenString, err := token.SignedString(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = mgr.VerifyToken(tokenString)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, common.TokenVerificationErr)
|
||||
})
|
||||
|
||||
t.Run("OIDC provider is external, audience is not specified, token is signed with the wrong key", func(t *testing.T) {
|
||||
config := map[string]string{
|
||||
"url": "",
|
||||
"oidc.config": fmt.Sprintf(`
|
||||
name: Test
|
||||
issuer: %s
|
||||
clientID: xxx
|
||||
clientSecret: yyy
|
||||
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
||||
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
|
||||
}
|
||||
|
||||
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
|
||||
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
|
||||
secretConfig := map[string][]byte{
|
||||
"tls.crt": utiltest.Cert,
|
||||
"tls.key": utiltest.PrivateKey,
|
||||
}
|
||||
|
||||
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
|
||||
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
|
||||
mgr.verificationDelayNoiseEnabled = false
|
||||
|
||||
claims := jwt.RegisteredClaims{Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
|
||||
claims.Issuer = oidcTestServer.URL
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
|
||||
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey2)
|
||||
require.NoError(t, err)
|
||||
tokenString, err := token.SignedString(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = mgr.VerifyToken(tokenString)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, common.TokenVerificationErr)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,33 @@ type Help struct {
|
|||
BinaryURLs map[string]string `json:"binaryUrl,omitempty"`
|
||||
}
|
||||
|
||||
// oidcConfig is the same as the public OIDCConfig, except the public one excludes the AllowedAudiences and the
|
||||
// SkipAudienceCheckWhenTokenHasNoAudience fields.
|
||||
// AllowedAudiences should be accessed via ArgoCDSettings.OAuth2AllowedAudiences.
|
||||
// SkipAudienceCheckWhenTokenHasNoAudience should be accessed via ArgoCDSettings.SkipAudienceCheckWhenTokenHasNoAudience.
|
||||
type oidcConfig struct {
|
||||
OIDCConfig
|
||||
AllowedAudiences []string `json:"allowedAudiences,omitempty"`
|
||||
SkipAudienceCheckWhenTokenHasNoAudience *bool `json:"skipAudienceCheckWhenTokenHasNoAudience,omitempty"`
|
||||
}
|
||||
|
||||
func (o *oidcConfig) toExported() *OIDCConfig {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return &OIDCConfig{
|
||||
Name: o.Name,
|
||||
Issuer: o.Issuer,
|
||||
ClientID: o.ClientID,
|
||||
ClientSecret: o.ClientSecret,
|
||||
CLIClientID: o.CLIClientID,
|
||||
RequestedScopes: o.RequestedScopes,
|
||||
RequestedIDTokenClaims: o.RequestedIDTokenClaims,
|
||||
LogoutURL: o.LogoutURL,
|
||||
RootCA: o.RootCA,
|
||||
}
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
|
|
@ -1641,24 +1668,37 @@ func UnmarshalDexConfig(config string) (map[string]interface{}, error) {
|
|||
return dexCfg, err
|
||||
}
|
||||
|
||||
func (a *ArgoCDSettings) OIDCConfig() *OIDCConfig {
|
||||
func (a *ArgoCDSettings) oidcConfig() *oidcConfig {
|
||||
if a.OIDCConfigRAW == "" {
|
||||
return nil
|
||||
}
|
||||
oidcConfig, err := UnmarshalOIDCConfig(a.OIDCConfigRAW)
|
||||
config, err := unmarshalOIDCConfig(a.OIDCConfigRAW)
|
||||
if err != nil {
|
||||
log.Warnf("invalid oidc config: %v", err)
|
||||
return nil
|
||||
}
|
||||
oidcConfig.ClientSecret = ReplaceStringSecret(oidcConfig.ClientSecret, a.Secrets)
|
||||
oidcConfig.ClientID = ReplaceStringSecret(oidcConfig.ClientID, a.Secrets)
|
||||
return &oidcConfig
|
||||
config.ClientSecret = ReplaceStringSecret(config.ClientSecret, a.Secrets)
|
||||
config.ClientID = ReplaceStringSecret(config.ClientID, a.Secrets)
|
||||
return &config
|
||||
}
|
||||
|
||||
func UnmarshalOIDCConfig(config string) (OIDCConfig, error) {
|
||||
var oidcConfig OIDCConfig
|
||||
err := yaml.Unmarshal([]byte(config), &oidcConfig)
|
||||
return oidcConfig, err
|
||||
func (a *ArgoCDSettings) OIDCConfig() *OIDCConfig {
|
||||
config := a.oidcConfig()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return config.toExported()
|
||||
}
|
||||
|
||||
func unmarshalOIDCConfig(configStr string) (oidcConfig, error) {
|
||||
var config oidcConfig
|
||||
err := yaml.Unmarshal([]byte(configStr), &config)
|
||||
return config, err
|
||||
}
|
||||
|
||||
func ValidateOIDCConfig(configStr string) error {
|
||||
_, err := unmarshalOIDCConfig(configStr)
|
||||
return err
|
||||
}
|
||||
|
||||
// TLSConfig returns a tls.Config with the configured certificates
|
||||
|
|
@ -1697,6 +1737,33 @@ func (a *ArgoCDSettings) OAuth2ClientID() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// OAuth2AllowedAudiences returns a list of audiences that are allowed for the OAuth2 client. If the user has not
|
||||
// explicitly configured the list of audiences (or has configured an empty list), then the OAuth2 client ID is returned
|
||||
// as the only allowed audience. When using the bundled Dex, that client ID is always "argo-cd".
|
||||
func (a *ArgoCDSettings) OAuth2AllowedAudiences() []string {
|
||||
if config := a.oidcConfig(); config != nil {
|
||||
if len(config.AllowedAudiences) == 0 {
|
||||
return []string{config.ClientID}
|
||||
}
|
||||
return config.AllowedAudiences
|
||||
}
|
||||
if a.DexConfig != "" {
|
||||
return []string{common.ArgoCDClientAppID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ArgoCDSettings) SkipAudienceCheckWhenTokenHasNoAudience() bool {
|
||||
if config := a.oidcConfig(); config != nil {
|
||||
if config.SkipAudienceCheckWhenTokenHasNoAudience != nil {
|
||||
return *config.SkipAudienceCheckWhenTokenHasNoAudience
|
||||
}
|
||||
return true
|
||||
}
|
||||
// When using the bundled Dex, the audience check is required. Dex will always send JWTs with an audience.
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *ArgoCDSettings) OAuth2ClientSecret() string {
|
||||
if oidcConfig := a.OIDCConfig(); oidcConfig != nil {
|
||||
return oidcConfig.ClientSecret
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
)
|
||||
|
||||
// Cert is a certificate for tests. It was generated like this:
|
||||
// opts := tls.CertOptions{Hosts: []string{"localhost"}, Organization: "Acme"}
|
||||
// certBytes, privKey, err := tls.generatePEM(opts)
|
||||
//
|
||||
// opts := tls.CertOptions{Hosts: []string{"localhost"}, Organization: "Acme"}
|
||||
// certBytes, privKey, err := tls.generatePEM(opts)
|
||||
var Cert = []byte(`-----BEGIN CERTIFICATE-----
|
||||
MIIC8zCCAdugAwIBAgIQCSoocl6e/FR4mQy1wX6NbjANBgkqhkiG9w0BAQsFADAP
|
||||
MQ0wCwYDVQQKEwRBY21lMB4XDTIyMDYyMjE3Mjk1MloXDTIzMDYyMjE3Mjk1Mlow
|
||||
|
|
@ -61,6 +65,48 @@ SDpz4h+Bov5qTKkzcxuu1QWtA4M0K8Iy6IYLwb83DZEm1OsAf4i0pODz21PY/I+O
|
|||
VHzjB10oYgaInHZgMUdyb6F571UdiYSB6a/IlZ3ngj5touy3VIM=
|
||||
-----END RSA PRIVATE KEY-----`)
|
||||
|
||||
// PrivateKey2 is a second RSA key used only for tests. You can use it to see if signing a JWT with a different key
|
||||
// fails validation (as it should).
|
||||
var PrivateKey2 = []byte(`-----BEGIN RSA PRIVATE KEY-----
|
||||
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
|
||||
-----END RSA PRIVATE KEY-----`)
|
||||
|
||||
func dexMockHandler(t *testing.T, url string) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -123,6 +169,20 @@ func oidcMockHandler(t *testing.T, url string) func(http.ResponseWriter, *http.R
|
|||
"claims_supported": ["sub", "aud", "exp"]
|
||||
}`, url))
|
||||
require.NoError(t, err)
|
||||
case "/keys":
|
||||
pubKey, err := jwt.ParseRSAPublicKeyFromPEM(Cert)
|
||||
require.NoError(t, err)
|
||||
jwks := jose.JSONWebKeySet{
|
||||
Keys: []jose.JSONWebKey{
|
||||
{
|
||||
Key: pubKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(jwks)
|
||||
require.NoError(t, err)
|
||||
_, err = io.WriteString(w, string(out))
|
||||
require.NoError(t, err)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
|
|
@ -137,4 +197,4 @@ func GetOIDCTestServer(t *testing.T) *httptest.Server {
|
|||
oidcMockHandler(t, ts.URL)(w, r)
|
||||
})
|
||||
return ts
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue