Files
deploy/util/session/sessionmanager_test.go
41536738c8 refactor(cd): hard-fork - rename CRD API group argoproj.io -> deploy.hanzo.ai (#1)
* feat(api): hard-fork CRD API group argoproj.io -> deploy.hanzo.ai

Hanzo CD hard-fork, phase 1.1. Single source of truth is
pkg/apis/application/register.go's Group constant (Application/AppProject/
ApplicationSet all derive their FullName from it); every other Go call-site
that hardcoded the literal group string is updated to match: the 3 generated
informer GVRs, the app-of-apps nested-Application detector in types.go, the
resource-tree Application-kind cache populator, the 3 NotFound error
constructors in server/application, and the applicationset-controller's
leader-election Lease name/ID (kept in lockstep across Go source + RBAC
resourceNames, or leader election would get RBAC-denied on startup).

CRD YAMLs (application/appproject/applicationset) and every RBAC apiGroups
rule that actually governs our 3 CRDs are repointed to deploy.hanzo.ai, across
both manifests/base/* (namespaced Roles) and manifests/cluster-rbac/* (cluster
scope). Two adjacent things that share the "argoproj.io" string but are NOT
ours are deliberately left untouched: gitops-engine's health-check dispatch
for Argo Workflows' `Workflow` kind, and argocd-server-clusterrole's second
apiGroups block granting read on Argo Workflows' `workflows` resource — both
belong to a separate, unrelated Argoproj-family project we are not forking.

Also untouched, per explicit scope: annotation/label keys (argocd.argoproj.io/*)
and finalizers (*.argoproj.io) that track already-applied live resources, and
argocd-cm.yaml's resource.customizations.ignoreResourceUpdates keys (a narrow
app-of-apps diffing nicety, not build- or RBAC-relevant).

CGO_ENABLED=0 go build ./... verified green.

* ci: wire canonical hanzo.yml + cicd.yml (Hanzo CD image build)

Phase 1.3. Follows the same one-way pattern every hanzo/lux/zoo repo uses:
hanzo.yml declares the image (root Dockerfile -> ghcr.io/hanzoai/deploy) and a
fast go-build sanity test (the multi-stage Dockerfile build is the slow, real
gate); cicd.yml is the ~10-line trigger shim importing hanzoai/ci's reusable
build workflow on the arc pool, secrets from KMS only.

No `deploy:` block yet, matching the hanzoai/cms build-only-first-cutover
pattern: this repo's own live hanzo-cd namespace runs the whole Hanzo fleet's
GitOps, so the prod image/CRD swap is a deliberate separate action (see the
[HELD] universe PR), never an automatic rollout on merge. Every PR/push still
builds and pushes an immutable ghcr.io/hanzoai/deploy:sha-<7>-amd64 image.

* feat(ui): rebrand Argo CD -> Hanzo CD (title, favicon, logo, sidebar)

Phase 1.2. User-facing "Argo CD"/"ArgoCD" text replaced with "Hanzo CD"
across the login page, help modal, settings screens, version-info panel,
sync-options warning, fullscreen-logs title, app.tsx page titles, and
index.html's <title>/noscript text (dropped the now-wrong upstream CLI
doc link rather than invent a hanzo.ai URL that doesn't exist).

Logo/favicon assets replaced with the canonical Hanzo mark (never
hand-drawn, copied from ~/work/hanzo/logo/dist): the two real usages of
Argo's logo collapse onto one Hanzo glyph (mono + white variants) at the
same 5 call sites (login icon + footer, login.scss $logoPath/
$logoNegativePath, sidebar text-logo), preserving the existing CSS
invert() filters so both the light login panel and dark sidebar still
render correctly. Old argologo.svg/argo_o.svg/argo.png deleted (no
longer referenced). favicon-{16x16,32x32}.png added at
ui/src/assets/favicon/ (index.html's links to that path were previously
dead - the directory didn't exist in this checkout). package.json name
argo-cd-ui -> hanzo-cd-ui.

Left alone, deliberately: the two Kubernetes resource-kind icon sets
under ui/src/assets/images/resources/ (unrelated to branding), and
upstream doc/project links (argoproj.io, argo-cd.readthedocs.io) since
we have no equivalent hanzo.ai docs to point to yet - except the login
footer logo's outbound link, which now points to hanzo.ai instead of
argoproj.io (a Hanzo-branded mark linking to the upstream project it
was forked from read as an obvious leftover).

* feat(module): rename Go module github.com/argoproj/argo-cd/v3 -> github.com/hanzoai/deploy

Phase 2. No version suffix on the new path (org policy: never publish our
own Go modules above v1.x.x / with a v2+ suffix) — this fork starts a fresh
v0/v1 module identity rather than carrying the v3 the upstream fork point
had reached.

Mechanical, symmetric rewrite: 704 .go files (import statements only; two
disjoint substitutions applied specific-first so the nested gitops-engine
module path never double-matches), plus go.mod (module line, the
gitops-engine require+replace pair, version reset to v0.0.0-... since a
suffix-less path can't carry a v3 semver), gitops-engine/go.mod, Makefile
(ldflags PACKAGE + 2 TEST_MODULE filters), .golangci.yaml, .goreleaser.yaml,
.mockery.yaml (+ gitops-engine's), Procfile, the codegen/proto generation
scripts (hack/generate-proto.sh, update-codegen.sh, update-openapi.sh), and
20 .proto files' go_package options. Also one OpenTelemetry tracer name
string in gitops-engine/pkg/sync/sync_context.go (a cosmestic label, not an
import, but worth keeping consistent).

CGO_ENABLED=0 go build ./... verified green for both the root module and
the nested gitops-engine module; go vet clean on both; go mod tidy run
(network available) to reconcile go.sum, dependency set unchanged.

Left alone, deliberately: doc-only mentions (docs/snyk/*.html scan reports,
gitops-engine/README.md), a comment-only reference in
.github/workflows/release.yaml, and one Renovate bot config line — none are
build- or codegen-load-bearing. Also left alone: comments citing upstream
GitHub issue URLs (github.com/argoproj/argo-cd/issues/NNNNN) and a handful
of test fixtures using "https://github.com/argoproj/argo-cd.git" as a
representative example repo URL — both are accurate historical/test-data
references, not stale self-references.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 18:27:58 -07:00

1612 lines
56 KiB
Go

package session
import (
"context"
"encoding/json"
"encoding/pem"
stderrors "errors"
"fmt"
"io"
"maps"
"math"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes/fake"
"github.com/hanzoai/deploy/common"
appv1 "github.com/hanzoai/deploy/pkg/apis/application/v1alpha1"
apps "github.com/hanzoai/deploy/pkg/client/clientset/versioned/fake"
"github.com/hanzoai/deploy/pkg/client/listers/application/v1alpha1"
"github.com/hanzoai/deploy/test"
"github.com/hanzoai/deploy/util"
"github.com/hanzoai/deploy/util/cache"
"github.com/hanzoai/deploy/util/crypto"
"github.com/hanzoai/deploy/util/dex"
jwtutil "github.com/hanzoai/deploy/util/jwt"
"github.com/hanzoai/deploy/util/oidc"
"github.com/hanzoai/deploy/util/password"
"github.com/hanzoai/deploy/util/settings"
utiltest "github.com/hanzoai/deploy/util/test"
)
func getProjLister(objects ...runtime.Object) v1alpha1.AppProjectNamespaceLister {
return test.NewFakeProjListerFromInterface(apps.NewSimpleClientset(objects...).ArgoprojV1alpha1().AppProjects("argocd"))
}
func getKubeClient(t *testing.T, pass string, enabled bool, capabilities ...settings.AccountCapability) *fake.Clientset {
t.Helper()
const defaultSecretKey = "Hello, world!"
bcrypt, err := password.HashPassword(pass)
require.NoError(t, err)
if len(capabilities) == 0 {
capabilities = []settings.AccountCapability{settings.AccountCapabilityLogin, settings.AccountCapabilityApiKey}
}
var capabilitiesStr []string
for i := range capabilities {
capabilitiesStr = append(capabilitiesStr, string(capabilities[i]))
}
return fake.NewClientset(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "argocd-cm",
Namespace: "argocd",
Labels: map[string]string{
"app.kubernetes.io/part-of": "argocd",
},
},
Data: map[string]string{
"admin": strings.Join(capabilitiesStr, ","),
"admin.enabled": strconv.FormatBool(enabled),
},
}, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "argocd-secret",
Namespace: "argocd",
},
Data: map[string][]byte{
"admin.password": []byte(bcrypt),
"server.secretkey": []byte(defaultSecretKey),
},
})
}
func newSessionManager(settingsMgr *settings.SettingsManager, projectLister v1alpha1.AppProjectNamespaceLister, storage UserStateStorage) *SessionManager {
mgr := NewSessionManager(settingsMgr, projectLister, "", nil, storage)
mgr.verificationDelayNoiseEnabled = false
return mgr
}
func TestSessionManager_AdminToken(t *testing.T) {
redisClient, closer := test.NewInMemoryRedis()
defer closer()
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "pass", true), "argocd")
mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(redisClient))
token, err := mgr.Create("admin:login", 0, "123")
require.NoError(t, err, "Could not create token")
claims, newToken, err := mgr.Parse(token)
require.NoError(t, err)
assert.Empty(t, newToken)
mapClaims := *(claims.(*jwt.MapClaims))
subject := mapClaims["sub"].(string)
if subject != "admin" {
t.Errorf("Token claim subject %q does not match expected subject %q.", subject, "admin")
}
}
func TestSessionManager_AdminToken_ExpiringSoon(t *testing.T) {
redisClient, closer := test.NewInMemoryRedis()
defer closer()
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "pass", true), "argocd")
mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(redisClient))
token, err := mgr.Create("admin:login", int64(autoRegenerateTokenDuration.Seconds()-1), "123")
require.NoError(t, err)
// verify new token is generated is login token is expiring soon
_, newToken, err := mgr.Parse(token)
require.NoError(t, err)
assert.NotEmpty(t, newToken)
// verify that new token is valid and for the same user
claims, _, err := mgr.Parse(newToken)
require.NoError(t, err)
mapClaims := *(claims.(*jwt.MapClaims))
subject := mapClaims["sub"].(string)
assert.Equal(t, "admin", subject)
}
func TestSessionManager_AdminToken_Revoked(t *testing.T) {
redisClient, closer := test.NewInMemoryRedis()
defer closer()
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "pass", true), "argocd")
storage := NewUserStateStorage(redisClient)
mgr := newSessionManager(settingsMgr, getProjLister(), storage)
token, err := mgr.Create("admin:login", int64(autoRegenerateTokenDuration.Seconds()*2), "123")
require.NoError(t, err)
err = storage.RevokeToken(t.Context(), "123", autoRegenerateTokenDuration*2)
require.NoError(t, err)
_, _, err = mgr.Parse(token)
assert.Equal(t, "token is revoked, please re-login", err.Error())
}
func TestSessionManager_AdminToken_EmptyJti(t *testing.T) {
redisClient, closer := test.NewInMemoryRedis()
defer closer()
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "pass", true), "argocd")
mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(redisClient))
// Create a token with an empty jti
token, err := mgr.Create("admin:login", int64(autoRegenerateTokenDuration.Seconds()*2), "")
require.NoError(t, err)
_, _, err = mgr.Parse(token)
require.Error(t, err)
assert.Equal(t, "token does not have a unique identifier (jti claim) and cannot be validated", err.Error())
}
func TestSessionManager_AdminToken_NoExpirationTime(t *testing.T) {
redisClient, closer := test.NewInMemoryRedis()
defer closer()
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "pass", true), "argocd")
mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(redisClient))
token, err := mgr.Create("admin:login", 0, "123")
if err != nil {
t.Errorf("Could not create token: %v", err)
}
claims, newToken, err := mgr.Parse(token)
require.NoError(t, err)
assert.Empty(t, newToken)
mapClaims, err := jwtutil.MapClaims(claims)
require.NoError(t, err)
sub, err := mapClaims.GetSubject()
require.NoError(t, err)
require.Equal(t, "admin", sub)
}
func TestSessionManager_AdminToken_Expired(t *testing.T) {
redisClient, closer := test.NewInMemoryRedis()
defer closer()
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "pass", true), "argocd")
mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(redisClient))
token, err := mgr.Create("admin:login", 2, "123")
if err != nil {
t.Errorf("Could not create token: %v", err)
}
time.Sleep(3 * time.Second)
_, _, err = mgr.Parse(token)
require.Error(t, err)
assert.ErrorContains(t, err, "token is expired")
}
func TestSessionManager_AdminToken_Deactivated(t *testing.T) {
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "pass", false), "argocd")
mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil))
token, err := mgr.Create("admin:login", int64(autoRegenerateTokenDuration.Seconds()*2), "abc")
require.NoError(t, err, "Could not create token")
_, _, err = mgr.Parse(token)
assert.ErrorContains(t, err, "account admin is disabled")
}
func TestSessionManager_AdminToken_LoginCapabilityDisabled(t *testing.T) {
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "pass", true, settings.AccountCapabilityLogin), "argocd")
mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil))
token, err := mgr.Create("admin", int64(autoRegenerateTokenDuration.Seconds()*2), "abc")
require.NoError(t, err, "Could not create token")
_, _, err = mgr.Parse(token)
assert.ErrorContains(t, err, "account admin does not have 'apiKey' capability")
}
func TestSessionManager_ProjectToken(t *testing.T) {
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "pass", true), "argocd")
t.Run("Valid Token", func(t *testing.T) {
proj := appv1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
Namespace: "argocd",
},
Spec: appv1.AppProjectSpec{Roles: []appv1.ProjectRole{{Name: "test"}}},
Status: appv1.AppProjectStatus{JWTTokensByRole: map[string]appv1.JWTTokens{
"test": {
Items: []appv1.JWTToken{{ID: "abc", IssuedAt: time.Now().Unix(), ExpiresAt: 0}},
},
}},
}
mgr := newSessionManager(settingsMgr, getProjLister(&proj), NewUserStateStorage(nil))
jwtToken, err := mgr.Create("proj:default:test", 100, "abc")
require.NoError(t, err)
claims, _, err := mgr.Parse(jwtToken)
require.NoError(t, err)
mapClaims, err := jwtutil.MapClaims(claims)
require.NoError(t, err)
require.Equal(t, "proj:default:test", mapClaims["sub"])
})
t.Run("Token Revoked", func(t *testing.T) {
proj := appv1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
Namespace: "argocd",
},
Spec: appv1.AppProjectSpec{Roles: []appv1.ProjectRole{{Name: "test"}}},
}
mgr := newSessionManager(settingsMgr, getProjLister(&proj), NewUserStateStorage(nil))
jwtToken, err := mgr.Create("proj:default:test", 10, "")
require.NoError(t, err)
_, _, err = mgr.Parse(jwtToken)
assert.ErrorContains(t, err, "does not exist in project 'default'")
})
}
type tokenVerifierMock struct {
claims jwt.Claims
err error
}
func (tm *tokenVerifierMock) VerifyToken(_ context.Context, _ string) (jwt.Claims, string, error) {
if tm.claims == nil {
return nil, "", tm.err
}
return tm.claims, "", tm.err
}
func TestSessionManager_WithAuthMiddleware(t *testing.T) {
handlerFunc := func() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
t.Helper()
w.WriteHeader(http.StatusOK)
contextClaims := r.Context().Value("claims")
if contextClaims != nil {
var gotClaims jwt.MapClaims
var ok bool
if gotClaims, ok = contextClaims.(jwt.MapClaims); !ok {
if tmpClaims, ok := contextClaims.(*jwt.MapClaims); ok && tmpClaims != nil {
gotClaims = *tmpClaims
}
}
jsonClaims, err := json.Marshal(gotClaims)
require.NoError(t, err, "error marshalling claims set by AuthMiddleware")
w.Header().Set("Content-Type", "application/json")
_, err = w.Write(jsonClaims)
require.NoError(t, err, "error writing response: %s", err)
} else {
w.Header().Set("Content-Type", "application/text")
_, err := w.Write([]byte("Ok"))
require.NoError(t, err, "error writing response: %s", err)
}
}
}
type testCase struct {
name string
authDisabled bool
ssoEnabled bool
cookieHeader bool
verifiedClaims *jwt.MapClaims
verifyTokenErr error
userInfoCacheClaims *jwt.MapClaims
expectedStatusCode int
expectedResponseBody *string
}
cases := []testCase{
{
name: "will authenticate successfully",
authDisabled: false,
ssoEnabled: false,
cookieHeader: true,
verifiedClaims: &jwt.MapClaims{},
verifyTokenErr: nil,
userInfoCacheClaims: nil,
expectedStatusCode: http.StatusOK,
expectedResponseBody: new("{}"),
},
{
name: "will be noop if auth is disabled",
authDisabled: true,
ssoEnabled: false,
cookieHeader: false,
verifiedClaims: nil,
verifyTokenErr: nil,
userInfoCacheClaims: nil,
expectedStatusCode: http.StatusOK,
expectedResponseBody: new("Ok"),
},
{
name: "will return 400 if no cookie header",
authDisabled: false,
ssoEnabled: false,
cookieHeader: false,
verifiedClaims: &jwt.MapClaims{},
verifyTokenErr: nil,
userInfoCacheClaims: nil,
expectedStatusCode: http.StatusBadRequest,
expectedResponseBody: nil,
},
{
name: "will return 401 verify token fails",
authDisabled: false,
ssoEnabled: false,
cookieHeader: true,
verifiedClaims: &jwt.MapClaims{},
verifyTokenErr: stderrors.New("token error"),
userInfoCacheClaims: nil,
expectedStatusCode: http.StatusUnauthorized,
expectedResponseBody: nil,
},
{
name: "will return 200 if claims are nil",
authDisabled: false,
ssoEnabled: false,
cookieHeader: true,
verifiedClaims: nil,
verifyTokenErr: nil,
userInfoCacheClaims: nil,
expectedStatusCode: http.StatusOK,
expectedResponseBody: new("null"),
},
{
name: "will return 401 if sso is enabled but userinfo response not working",
authDisabled: false,
ssoEnabled: true,
cookieHeader: true,
verifiedClaims: nil,
verifyTokenErr: nil,
userInfoCacheClaims: nil, // indicates that the userinfo response will not work since cache is empty and userinfo endpoint not rechable
expectedStatusCode: http.StatusUnauthorized,
expectedResponseBody: new("Invalid session"),
},
{
name: "will return 200 if sso is enabled and userinfo response from cache is valid",
authDisabled: false,
ssoEnabled: true,
cookieHeader: true,
verifiedClaims: &jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
verifyTokenErr: nil,
userInfoCacheClaims: &jwt.MapClaims{"sub": "randomUser", "groups": []string{"superusers"}, "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
expectedStatusCode: http.StatusOK,
expectedResponseBody: new("\"groups\":[\"superusers\"]"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// given
mux := http.NewServeMux()
mux.HandleFunc("/", handlerFunc())
tm := &tokenVerifierMock{
claims: tc.verifiedClaims,
err: tc.verifyTokenErr,
}
clientApp := &oidc.ClientApp{} // all testcases need at least the empty struct for the function to work
if tc.ssoEnabled {
userInfoCache := cache.NewInMemoryCache(24 * time.Hour)
signature, err := util.MakeSignature(32)
require.NoError(t, err, "failed creating signature for settings object")
cdSettings := &settings.ArgoCDSettings{
ServerSignature: signature,
OIDCConfigRAW: `
issuer: http://localhost:63231
enableUserInfoGroups: true
userInfoPath: /`,
}
clientApp, err = oidc.NewClientApp(cdSettings, "", nil, "/argo-cd", userInfoCache)
require.NoError(t, err, "failed creating clientapp")
// prepopulate the cache with claims to return for a userinfo call
encryptionKey, err := cdSettings.GetServerEncryptionKey()
require.NoError(t, err, "failed obtaining encryption key from settings")
// set fake accessToken for GetUserInfo to not return early (can be the same for all cases)
encAccessToken, err := crypto.Encrypt([]byte("123456"), encryptionKey)
require.NoError(t, err, "failed encrypting dummy access token")
err = userInfoCache.Set(&cache.Item{
Key: oidc.FormatAccessTokenCacheKey("randomUser"),
Object: encAccessToken,
})
require.NoError(t, err, "failed setting item to in-memory cache")
// set cacheClaims to in-memory cache to let GetUserInfo return early with this information
if tc.userInfoCacheClaims != nil {
cacheClaims, err := json.Marshal(tc.userInfoCacheClaims)
require.NoError(t, err)
encCacheClaims, err := crypto.Encrypt([]byte(cacheClaims), encryptionKey)
require.NoError(t, err, "failed encrypting cache Claims")
err = userInfoCache.Set(&cache.Item{
Key: oidc.FormatUserInfoResponseCacheKey("randomUser"),
Object: encCacheClaims,
})
require.NoError(t, err, "failed setting item to in-memory cache")
}
}
ts := httptest.NewServer(WithAuthMiddleware(tc.authDisabled, tc.ssoEnabled, clientApp, tm, mux))
defer ts.Close()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, ts.URL, http.NoBody)
require.NoErrorf(t, err, "error creating request: %s", err)
if tc.cookieHeader {
req.Header.Add("Cookie", "argocd.token=123456")
}
// when
resp, err := http.DefaultClient.Do(req)
// then
require.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, tc.expectedStatusCode, resp.StatusCode)
if tc.expectedResponseBody != nil {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
actual := strings.TrimSuffix(string(body), "\n")
assert.Contains(t, actual, *tc.expectedResponseBody)
}
})
}
}
var (
loggedOutContext = context.Background()
//nolint:staticcheck
loggedInContext = context.WithValue(context.Background(), "claims", &jwt.MapClaims{"iss": "qux", "sub": "foo", "email": "bar", "groups": []string{"baz"}})
//nolint:staticcheck
loggedInContextFederated = context.WithValue(context.Background(), "claims", &jwt.MapClaims{"iss": "qux", "sub": "not-foo", "email": "bar", "groups": []string{"baz"}, "federated_claims": map[string]any{"user_id": "foo"}})
)
func TestIss(t *testing.T) {
assert.Empty(t, Iss(loggedOutContext))
assert.Equal(t, "qux", Iss(loggedInContext))
assert.Equal(t, "qux", Iss(loggedInContextFederated))
}
func TestLoggedIn(t *testing.T) {
assert.False(t, LoggedIn(loggedOutContext))
assert.True(t, LoggedIn(loggedInContext))
assert.True(t, LoggedIn(loggedInContextFederated))
}
func TestUsername(t *testing.T) {
assert.Empty(t, Username(loggedOutContext))
assert.Equal(t, "bar", Username(loggedInContext))
assert.Equal(t, "bar", Username(loggedInContextFederated))
}
func TestGetUserIdentifier(t *testing.T) {
assert.Empty(t, GetUserIdentifier(loggedOutContext))
assert.Equal(t, "foo", GetUserIdentifier(loggedInContext))
assert.Equal(t, "foo", GetUserIdentifier(loggedInContextFederated))
}
func TestGroups(t *testing.T) {
assert.Empty(t, Groups(loggedOutContext, []string{"groups"}))
assert.Equal(t, []string{"baz"}, Groups(loggedInContext, []string{"groups"}))
}
func TestVerifyUsernamePassword(t *testing.T) {
const password = "password"
for _, tc := range []struct {
name string
disabled bool
userName string
password string
expected error
}{
{
name: "Success if userName and password is correct",
disabled: false,
userName: common.ArgoCDAdminUsername,
password: password,
expected: nil,
},
{
name: "Return error if password is empty",
disabled: false,
userName: common.ArgoCDAdminUsername,
password: "",
expected: status.Errorf(codes.Unauthenticated, blankPasswordError),
},
{
name: "Return error if password is not correct",
disabled: false,
userName: common.ArgoCDAdminUsername,
password: "foo",
expected: status.Errorf(codes.Unauthenticated, invalidLoginError),
},
{
name: "Return error if disableAdmin is true",
disabled: true,
userName: common.ArgoCDAdminUsername,
password: password,
expected: status.Errorf(codes.Unauthenticated, accountDisabled, "admin"),
},
} {
t.Run(tc.name, func(t *testing.T) {
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, password, !tc.disabled), "argocd")
mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil))
err := mgr.VerifyUsernamePassword(tc.userName, tc.password)
if tc.expected == nil {
require.NoError(t, err)
} else {
assert.EqualError(t, err, tc.expected.Error())
}
})
}
}
func TestCacheValueGetters(t *testing.T) {
t.Run("Default values", func(t *testing.T) {
mlf := getMaxLoginFailures()
assert.Equal(t, defaultMaxLoginFailures, mlf)
mcs := getMaximumCacheSize()
assert.Equal(t, defaultMaxCacheSize, mcs)
})
t.Run("Valid environment overrides", func(t *testing.T) {
t.Setenv(envLoginMaxFailCount, "5")
t.Setenv(envLoginMaxCacheSize, "5")
mlf := getMaxLoginFailures()
assert.Equal(t, 5, mlf)
mcs := getMaximumCacheSize()
assert.Equal(t, 5, mcs)
})
t.Run("Invalid environment overrides", func(t *testing.T) {
t.Setenv(envLoginMaxFailCount, "invalid")
t.Setenv(envLoginMaxCacheSize, "invalid")
mlf := getMaxLoginFailures()
assert.Equal(t, defaultMaxLoginFailures, mlf)
mcs := getMaximumCacheSize()
assert.Equal(t, defaultMaxCacheSize, mcs)
})
t.Run("Less than allowed in environment overrides", func(t *testing.T) {
t.Setenv(envLoginMaxFailCount, "-1")
t.Setenv(envLoginMaxCacheSize, "-1")
mlf := getMaxLoginFailures()
assert.Equal(t, defaultMaxLoginFailures, mlf)
mcs := getMaximumCacheSize()
assert.Equal(t, defaultMaxCacheSize, mcs)
})
t.Run("Greater than allowed in environment overrides", func(t *testing.T) {
t.Setenv(envLoginMaxFailCount, strconv.Itoa(math.MaxInt32+1))
t.Setenv(envLoginMaxCacheSize, strconv.Itoa(math.MaxInt32+1))
mlf := getMaxLoginFailures()
assert.Equal(t, defaultMaxLoginFailures, mlf)
mcs := getMaximumCacheSize()
assert.Equal(t, defaultMaxCacheSize, mcs)
})
}
func TestLoginRateLimiter(t *testing.T) {
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "password", true), "argocd")
storage := NewUserStateStorage(nil)
mgr := newSessionManager(settingsMgr, getProjLister(), storage)
t.Run("Test login delay valid user", func(t *testing.T) {
for i := 0; i < getMaxLoginFailures(); i++ {
err := mgr.VerifyUsernamePassword("admin", "wrong")
require.Error(t, err)
}
// The 11th time should fail even if password is right
{
err := mgr.VerifyUsernamePassword("admin", "password")
require.Error(t, err)
}
storage.attempts = map[string]LoginAttempts{}
// Failed counter should have been reset, should validate immediately
{
err := mgr.VerifyUsernamePassword("admin", "password")
require.NoError(t, err)
}
})
t.Run("Test login delay invalid user", func(t *testing.T) {
for i := 0; i < getMaxLoginFailures(); i++ {
err := mgr.VerifyUsernamePassword("invalid", "wrong")
require.Error(t, err)
}
err := mgr.VerifyUsernamePassword("invalid", "wrong")
require.Error(t, err)
})
}
func TestMaxUsernameLength(t *testing.T) {
var username strings.Builder
for range maxUsernameLength + 1 {
username.WriteString("a")
}
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "password", true), "argocd")
mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil))
err := mgr.VerifyUsernamePassword(username.String(), "password")
assert.ErrorContains(t, err, fmt.Sprintf(usernameTooLongError, maxUsernameLength))
}
func TestMaxCacheSize(t *testing.T) {
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "password", true), "argocd")
mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil))
invalidUsers := []string{"invalid1", "invalid2", "invalid3", "invalid4", "invalid5", "invalid6", "invalid7"}
// Temporarily decrease max cache size
t.Setenv(envLoginMaxCacheSize, "5")
for _, user := range invalidUsers {
err := mgr.VerifyUsernamePassword(user, "password")
require.Error(t, err)
}
assert.Len(t, mgr.GetLoginFailures(), 5)
}
func TestFailedAttemptsExpiry(t *testing.T) {
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "password", true), "argocd")
mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil))
invalidUsers := []string{"invalid1", "invalid2", "invalid3", "invalid4", "invalid5", "invalid6", "invalid7"}
t.Setenv(envLoginFailureWindowSeconds, "1")
for _, user := range invalidUsers {
err := mgr.VerifyUsernamePassword(user, "password")
require.Error(t, err)
}
time.Sleep(2 * time.Second)
err := mgr.VerifyUsernamePassword("invalid8", "password")
require.Error(t, err)
assert.Len(t, mgr.GetLoginFailures(), 1)
}
func getKubeClientWithConfig(config map[string]string, secretConfig map[string][]byte) *fake.Clientset {
mergedSecretConfig := map[string][]byte{
"server.secretkey": []byte("Hello, world!"),
}
maps.Copy(mergedSecretConfig, secretConfig)
return fake.NewClientset(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "argocd-cm",
Namespace: "argocd",
Labels: map[string]string{
"app.kubernetes.io/part-of": "argocd",
},
},
Data: config,
}, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "argocd-secret",
Namespace: "argocd",
},
Data: mergedSecretConfig,
})
}
func TestSessionManager_VerifyToken(t *testing.T) {
oidcTestServer := utiltest.GetOIDCTestServer(t, nil)
t.Cleanup(oidcTestServer.Close)
dexTestServer := utiltest.GetDexTestServer(t)
t.Cleanup(dexTestServer.Close)
t.Run("RS512 is supported", func(t *testing.T) {
dexConfig := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]`, oidcTestServer.URL),
}
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClientWithConfig(dexConfig, nil), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
// Use test server's client to avoid TLS issues.
mgr.client = oidcTestServer.Client()
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, 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(t.Context(), tokenString)
assert.NotContains(t, err.Error(), "oidc: id token signed with unsupported algorithm")
})
t.Run("oidcConfig.rootCA is respected", func(t *testing.T) {
cert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: oidcTestServer.TLS.Certificates[0].Certificate[0]})
dexConfig := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]
rootCA: |
%s
`, oidcTestServer.URL, strings.ReplaceAll(string(cert), "\n", "\n ")),
}
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClientWithConfig(dexConfig, nil), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = oidcTestServer.URL
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
_, _, err = mgr.VerifyToken(t.Context(), tokenString)
// If the root CA is being respected, we won't get this error. The error message is environment-dependent, so
// we check for either of the error messages associated with a failed cert check.
assert.NotContains(t, err.Error(), "certificate is not trusted")
assert.NotContains(t, err.Error(), "certificate signed by unknown authority")
})
t.Run("OIDC provider is Dex, TLS is configured", func(t *testing.T) {
dexConfig := map[string]string{
"url": dexTestServer.URL,
"dex.config": `connectors:
- type: github
name: GitHub
config:
clientID: aabbccddeeff00112233
clientSecret: aabbccddeeff00112233`,
}
// 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(t.Context(), getKubeClientWithConfig(dexConfig, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), dexTestServer.URL, nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = dexTestServer.URL + "/api/dex"
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(t.Context(), tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrTokenVerification)
})
t.Run("OIDC provider is external, TLS is configured", func(t *testing.T) {
dexConfig := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]`, oidcTestServer.URL),
}
// 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(t.Context(), getKubeClientWithConfig(dexConfig, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, 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(t.Context(), tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrTokenVerification)
})
t.Run("OIDC provider is Dex, TLS is configured", func(t *testing.T) {
dexConfig := map[string]string{
"url": dexTestServer.URL,
"dex.config": `connectors:
- type: github
name: GitHub
config:
clientID: aabbccddeeff00112233
clientSecret: aabbccddeeff00112233`,
}
// 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(t.Context(), getKubeClientWithConfig(dexConfig, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), dexTestServer.URL, nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = dexTestServer.URL + "/api/dex"
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(t.Context(), tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrTokenVerification)
})
t.Run("OIDC provider is external, TLS is configured, OIDCTLSInsecureSkipVerify is true", func(t *testing.T) {
dexConfig := 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 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(t.Context(), getKubeClientWithConfig(dexConfig, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, 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(t.Context(), tokenString)
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, TLS is not configured, OIDCTLSInsecureSkipVerify is true", func(t *testing.T) {
dexConfig := 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",
}
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClientWithConfig(dexConfig, nil), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, 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(t.Context(), tokenString)
// This is the error thrown when the test server's certificate _is_ being verified.
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(t.Context(), 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(t.Context(), tokenString)
require.Error(t, err)
})
t.Run("OIDC provider is external, audience is not specified, absent audience is allowed", func(t *testing.T) {
config := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]
skipAudienceCheckWhenTokenHasNoAudience: true`, 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(t.Context(), 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(t.Context(), 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(t.Context(), 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(t.Context(), tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrTokenVerification)
})
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(t.Context(), 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(t.Context(), 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(t.Context(), 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(t.Context(), tokenString)
require.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(t.Context(), 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(t.Context(), tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrTokenVerification)
})
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(t.Context(), 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(t.Context(), tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrTokenVerification)
})
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(t.Context(), 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(t.Context(), tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrTokenVerification)
})
// Make sure the logic works to allow any of the allowed audiences, not just the first one.
t.Run("OIDC provider is external, audience is specified, actual audience isn't the first allowed audience", func(t *testing.T) {
config := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]
allowedAudiences: ["aud-a", "aud-b"]`, 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(t.Context(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"aud-b"}, 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(t.Context(), tokenString)
require.NoError(t, err)
})
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(t.Context(), 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(t.Context(), tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrTokenVerification)
})
t.Run("OIDC provider is external, token is revoked", func(t *testing.T) {
tokenID := "123"
redisClient, closer := test.NewInMemoryRedis()
defer closer()
storage := NewUserStateStorage(redisClient)
err := storage.RevokeToken(t.Context(), tokenID, autoRegenerateTokenDuration*2)
require.NoError(t, err)
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(t.Context(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, storage)
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
claims.ID = tokenID
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(t.Context(), tokenString)
require.Error(t, err)
assert.Equal(t, "token is revoked, please re-login", err.Error())
})
t.Run("OIDC provider is Dex, token is revoked", func(t *testing.T) {
tokenID := "123"
redisClient, closer := test.NewInMemoryRedis()
defer closer()
storage := NewUserStateStorage(redisClient)
err := storage.RevokeToken(t.Context(), tokenID, autoRegenerateTokenDuration*2)
require.NoError(t, err)
config := map[string]string{
"url": dexTestServer.URL,
"dex.config": `connectors:
- type: github
name: GitHub
config:
clientID: aabbccddeeff00112233
clientSecret: aabbccddeeff00112233`,
}
// 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(t.Context(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, storage)
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"argo-cd-cli"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = dexTestServer.URL + "/api/dex"
claims.ID = tokenID
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(t.Context(), tokenString)
require.Error(t, err)
assert.Equal(t, "token is revoked, please re-login", err.Error())
})
}
func TestSessionManager_RevokeToken(t *testing.T) {
redisClient, closer := test.NewInMemoryRedis()
defer closer()
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClient(t, "pass", true), "argocd")
storage := NewUserStateStorage(redisClient)
mgr := newSessionManager(settingsMgr, getProjLister(), storage)
t.Run("Revoke and verify", func(t *testing.T) {
tokenID := "revoke-test-id"
assert.False(t, storage.IsTokenRevoked(tokenID), "token should not be revoked initially")
err := mgr.RevokeToken(t.Context(), tokenID, time.Hour)
require.NoError(t, err)
assert.True(t, storage.IsTokenRevoked(tokenID), "token should be revoked after RevokeToken")
})
t.Run("Revoked token rejected by Parse", func(t *testing.T) {
tokenID := "parse-revoke-id"
token, err := mgr.Create("admin:login", int64(autoRegenerateTokenDuration.Seconds()*2), tokenID)
require.NoError(t, err)
// Token should parse fine before revocation
_, _, err = mgr.Parse(token)
require.NoError(t, err)
// Revoke and verify Parse rejects it
err = mgr.RevokeToken(t.Context(), tokenID, autoRegenerateTokenDuration*2)
require.NoError(t, err)
_, _, err = mgr.Parse(token)
require.Error(t, err)
assert.Equal(t, "token is revoked, please re-login", err.Error())
})
}
func TestSessionManager_VerifyToken_DexTokenRevokedByAccessTokenHash(t *testing.T) {
dexTestServer := utiltest.GetDexTestServer(t)
t.Cleanup(dexTestServer.Close)
accessTokenHash := "dex-access-token-hash-xyz"
redisClient, closer := test.NewInMemoryRedis()
defer closer()
storage := NewUserStateStorage(redisClient)
// Revoke using the access token hash (the fallback ID for Dex tokens without jti)
err := storage.RevokeToken(t.Context(), accessTokenHash, autoRegenerateTokenDuration*2)
require.NoError(t, err)
config := map[string]string{
"url": dexTestServer.URL,
"dex.config": `connectors:
- type: github
name: GitHub
config:
clientID: aabbccddeeff00112233
clientSecret: aabbccddeeff00112233`,
}
secretConfig := map[string][]byte{
"tls.crt": utiltest.Cert,
"tls.key": utiltest.PrivateKey,
}
settingsMgr := settings.NewSettingsManager(t.Context(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, storage)
mgr.verificationDelayNoiseEnabled = false
// Create a Dex token WITHOUT jti — the at_hash field in the OIDC ID token should be used as fallback
claims := jwt.MapClaims{
"aud": []string{"argo-cd-cli"},
"sub": "admin",
"exp": jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
"iss": dexTestServer.URL + "/api/dex",
"at_hash": accessTokenHash,
// Deliberately no "jti" claim
}
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(t.Context(), tokenString)
require.Error(t, err)
assert.Equal(t, "token is revoked, please re-login", err.Error())
}
func Test_PickFailureAttemptWhenOverflowed(t *testing.T) {
t.Run("Not pick admin user from the queue", func(t *testing.T) {
failures := map[string]LoginAttempts{
"admin": {
FailCount: 1,
},
"test2": {
FailCount: 1,
},
}
// inside pickRandomNonAdminLoginFailure, it uses random, so we need to test it multiple times
for range 1000 {
user := pickRandomNonAdminLoginFailure(failures, "test")
assert.Equal(t, "test2", *user)
}
})
t.Run("Not pick admin user and current user from the queue", func(t *testing.T) {
failures := map[string]LoginAttempts{
"test": {
FailCount: 1,
},
"admin": {
FailCount: 1,
},
"test2": {
FailCount: 1,
},
}
// inside pickRandomNonAdminLoginFailure, it uses random, so we need to test it multiple times
for range 1000 {
user := pickRandomNonAdminLoginFailure(failures, "test")
assert.Equal(t, "test2", *user)
}
})
}
func TestTokenUniqueID(t *testing.T) {
testCases := []struct {
name string
claims jwt.MapClaims
want string
}{
{
name: "jti present",
claims: jwt.MapClaims{"jti": "abc123"},
want: "abc123",
},
{
name: "uti fallback when jti absent (Entra ID)",
claims: jwt.MapClaims{"uti": "xyz789"},
want: "xyz789",
},
{
name: "jti preferred over uti",
claims: jwt.MapClaims{"jti": "abc123", "uti": "xyz789"},
want: "abc123",
},
{
name: "empty jti falls back to uti",
claims: jwt.MapClaims{"jti": "", "uti": "xyz789"},
want: "xyz789",
},
{
name: "neither present",
claims: jwt.MapClaims{"sub": "user"},
want: "",
},
{
name: "non-string jti ignored, uti used",
claims: jwt.MapClaims{"jti": 123, "uti": "xyz789"},
want: "xyz789",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, tokenUniqueID(tc.claims))
})
}
}