Merge branch 'master' into emit-metric-labels

This commit is contained in:
Shetaya 2026-03-10 16:24:23 +02:00 committed by GitHub
commit 4d7e51af35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1844 additions and 48 deletions

View file

@ -111,7 +111,7 @@ jobs:
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
# renovate: datasource=go packageName=github.com/golangci/golangci-lint/v2 versioning=regex:^v(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)?$
version: v2.11.2
version: v2.11.3
args: --verbose
test-go:

View file

@ -73,7 +73,7 @@ jobs:
cache: false
- name: Install cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0

View file

@ -1,15 +1,23 @@
package commands
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"os"
"time"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/argoproj/argo-cd/v3/cmd/argocd/commands/utils"
"github.com/argoproj/argo-cd/v3/common"
argocdclient "github.com/argoproj/argo-cd/v3/pkg/apiclient"
"github.com/argoproj/argo-cd/v3/util/errors"
"github.com/argoproj/argo-cd/v3/util/cli"
errutil "github.com/argoproj/argo-cd/v3/util/errors"
grpc_util "github.com/argoproj/argo-cd/v3/util/grpc"
"github.com/argoproj/argo-cd/v3/util/localconfig"
)
@ -34,7 +42,7 @@ argocd logout cd.argoproj.io
context := args[0]
localCfg, err := localconfig.ReadLocalConfig(globalClientOpts.ConfigPath)
errors.CheckError(err)
errutil.CheckError(err)
if localCfg == nil {
log.Fatalf("Nothing to logout from")
}
@ -43,6 +51,43 @@ argocd logout cd.argoproj.io
canLogout := promptUtil.Confirm(fmt.Sprintf("Are you sure you want to log out from '%s'?", context))
if canLogout {
if tlsTestResult, err := grpc_util.TestTLS(context, common.BearerTokenTimeout); err != nil {
log.Warnf("failed to check the TLS config settings for the server : %v.", err)
globalClientOpts.PlainText = true
} else {
if !tlsTestResult.TLS {
if !globalClientOpts.PlainText {
if !cli.AskToProceed("WARNING: server is not configured with TLS. Proceed (y/n)? ") {
os.Exit(1)
}
globalClientOpts.PlainText = true
}
} else if tlsTestResult.InsecureErr != nil {
if !globalClientOpts.Insecure {
if !cli.AskToProceed(fmt.Sprintf("WARNING: server certificate had error: %s. Proceed insecurely (y/n)? ", tlsTestResult.InsecureErr)) {
os.Exit(1)
}
globalClientOpts.Insecure = true
}
}
}
scheme := "https"
if globalClientOpts.PlainText {
scheme = "http"
}
if res, err := revokeServerToken(scheme, context, localCfg.GetToken(context), globalClientOpts.Insecure); err != nil {
log.Warnf("failed to invalidate token on server: %v.", err)
} else {
_ = res.Body.Close()
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusSeeOther {
log.Warnf("server returned unexpected status code %d during logout", res.StatusCode)
} else {
log.Infof("token successfully invalidated on server")
}
}
// Remove token from local config
ok := localCfg.RemoveToken(context)
if !ok {
log.Fatalf("Context %s does not exist", context)
@ -53,7 +98,7 @@ argocd logout cd.argoproj.io
log.Fatalf("Error in logging out: %s", err)
}
err = localconfig.WriteLocalConfig(*localCfg, globalClientOpts.ConfigPath)
errors.CheckError(err)
errutil.CheckError(err)
fmt.Printf("Logged out from '%s'\n", context)
} else {
@ -63,3 +108,31 @@ argocd logout cd.argoproj.io
}
return command
}
// revokeServerToken makes a call to the server logout endpoint to revoke the token server side
func revokeServerToken(scheme, hostName, token string, insecure bool) (res *http.Response, err error) {
if token == "" {
return nil, errors.New("error getting token from local context file")
}
logoutURL := fmt.Sprintf("%s://%s%s", scheme, hostName, common.LogoutEndpoint)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, logoutURL, http.NoBody)
if err != nil {
return nil, err
}
cookie := &http.Cookie{
Name: common.AuthCookieName,
Value: token,
}
req.AddCookie(cookie)
client := &http.Client{Timeout: common.TokenRevocationClientTimeout}
if insecure {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
client.Transport = tr
}
return client.Do(req)
}

View file

@ -1,12 +1,29 @@
package commands
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"fmt"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
grpccreds "google.golang.org/grpc/credentials"
"github.com/argoproj/argo-cd/v3/common"
argocdclient "github.com/argoproj/argo-cd/v3/pkg/apiclient"
"github.com/argoproj/argo-cd/v3/util/localconfig"
)
@ -36,3 +53,218 @@ func TestLogout(t *testing.T) {
assert.Contains(t, localConfig.Contexts, localconfig.ContextRef{Name: "argocd2.example.com:443", Server: "argocd2.example.com:443", User: "argocd2.example.com:443"})
assert.Contains(t, localConfig.Contexts, localconfig.ContextRef{Name: "localhost:8080", Server: "localhost:8080", User: "localhost:8080"})
}
func TestRevokeServerToken_EmptyToken(t *testing.T) {
res, err := revokeServerToken("http", "localhost:8080", "", false)
require.EqualError(t, err, "error getting token from local context file")
assert.Nil(t, res)
}
func TestRevokeServerToken_SuccessfulRequest(t *testing.T) {
var receivedCookie string
var receivedMethod string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedMethod = r.Method
cookie, err := r.Cookie(common.AuthCookieName)
if err == nil {
receivedCookie = cookie.Value
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Strip the "http://" prefix to get the hostName
hostName := server.Listener.Addr().String()
res, err := revokeServerToken("http", hostName, "test-token", false)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Equal(t, http.MethodPost, receivedMethod)
assert.Equal(t, "test-token", receivedCookie)
}
func TestRevokeServerToken_ServerReturnsBadRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer server.Close()
hostName := server.Listener.Addr().String()
res, err := revokeServerToken("http", hostName, "test-token", false)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
}
func TestRevokeServerToken_InvalidURL(t *testing.T) {
// A hostname containing a control character produces an invalid URL,
// causing http.NewRequestWithContext to fail.
res, err := revokeServerToken("http", "invalid\x00host", "test-token", false)
require.Error(t, err)
assert.Nil(t, res)
assert.Contains(t, err.Error(), "invalid control character in URL")
}
func TestRevokeServerToken_InsecureTLS(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
hostName := server.Listener.Addr().String()
// Without insecure, the self-signed cert should cause a failure
res, err := revokeServerToken("https", hostName, "test-token", false)
require.Error(t, err)
assert.Nil(t, res)
// With insecure=true, should succeed despite self-signed cert
res, err = revokeServerToken("https", hostName, "test-token", true)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
}
// createTestLocalConfig creates a temporary local config file with a single context
// pointing to the given address and returns the config file path.
func createTestLocalConfig(t *testing.T, addr string) string {
t.Helper()
configFile := filepath.Join(t.TempDir(), "config")
cfg := localconfig.LocalConfig{
CurrentContext: addr,
Contexts: []localconfig.ContextRef{{Name: addr, Server: addr, User: addr}},
Servers: []localconfig.Server{{Server: addr}},
Users: []localconfig.User{{Name: addr, AuthToken: "test-token"}},
}
err := localconfig.WriteLocalConfig(cfg, configFile)
require.NoError(t, err)
return configFile
}
// generateSelfSignedCert creates a self-signed TLS certificate for 127.0.0.1.
func generateSelfSignedCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
require.NoError(t, err)
return tls.Certificate{
Certificate: [][]byte{certDER},
PrivateKey: key,
}
}
// TestLogout_TLSCheckFails_AutoSetsPlainText verifies that when grpc_util.TestTLS
// returns an error (e.g., nothing listening), the logout command automatically
// switches to plain text and still completes the logout successfully.
func TestLogout_TLSCheckFails_AutoSetsPlainText(t *testing.T) {
// Listen on a random port, then close it immediately so nothing is listening.
// This causes grpc_util.TestTLS to fail, triggering the PlainText auto-set path.
lc := net.ListenConfig{}
lis, err := lc.Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err, "Unable to start server")
addr := lis.Addr().String()
lis.Close()
configFile := createTestLocalConfig(t, addr)
// Verify token exists before logout
localCfg, err := localconfig.ReadLocalConfig(configFile)
require.NoError(t, err)
assert.Equal(t, "test-token", localCfg.GetToken(addr))
command := NewLogoutCommand(&argocdclient.ClientOptions{ConfigPath: configFile})
command.Run(nil, []string{addr})
// Verify token was removed despite TLS check failure
localCfg, err = localconfig.ReadLocalConfig(configFile)
require.NoError(t, err)
assert.Empty(t, localCfg.GetToken(addr))
}
// TestLogout_InsecureTLS_SetsInsecureFlag verifies that when the server has an
// insecure (self-signed) TLS certificate, the logout command detects it via
// grpc_util.TestTLS (which sets InsecureErr), prompts the user, and sets the
// Insecure flag to true before proceeding with logout.
func TestLogout_InsecureTLS_SetsInsecureFlag(t *testing.T) {
// Start a gRPC server with TLS credentials using a self-signed certificate.
// grpc_util.TestTLS will:
// 1. Connect with InsecureSkipVerify=true → succeeds (TLS=true)
// 2. Connect with InsecureSkipVerify=false → fails (InsecureErr set)
cert := generateSelfSignedCert(t)
lc := net.ListenConfig{}
lis, err := lc.Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err, "Unable to start server")
serverCreds := grpccreds.NewServerTLSFromCert(&cert)
grpcServer := grpc.NewServer(grpc.Creds(serverCreds))
go func() {
err := grpcServer.Serve(lis)
require.NoError(t, err, "Unable to start the grpc server")
}()
defer grpcServer.Stop()
addr := lis.Addr().String()
// Mock os.Stdin to provide "y\n" for cli.AskToProceed (insecure cert warning).
oldStdin := os.Stdin
r, w, err := os.Pipe()
require.NoError(t, err)
_, err = w.WriteString("y\n")
require.NoError(t, err)
w.Close()
os.Stdin = r
defer func() { os.Stdin = oldStdin }()
configFile := createTestLocalConfig(t, addr)
// Verify token exists before logout
localCfg, err := localconfig.ReadLocalConfig(configFile)
require.NoError(t, err)
assert.Equal(t, "test-token", localCfg.GetToken(addr))
command := NewLogoutCommand(&argocdclient.ClientOptions{ConfigPath: configFile})
command.Run(nil, []string{addr})
// Verify token was removed after accepting the insecure cert warning
localCfg, err = localconfig.ReadLocalConfig(configFile)
require.NoError(t, err)
assert.Empty(t, localCfg.GetToken(addr))
}
// TestLogout_ConfigFileNotFound verifies that when the local config file does not
// exist, the logout command reports "Nothing to logout from" via log.Fatalf.
func TestLogout_ConfigFileNotFound(t *testing.T) {
// Point to a config path that does not exist.
configFile := filepath.Join(t.TempDir(), "nonexistent", "config")
// Override logrus ExitFunc so log.Fatalf panics instead of calling os.Exit,
// allowing us to verify the fatal message in the test.
origExitFunc := logrus.StandardLogger().ExitFunc
logrus.StandardLogger().ExitFunc = func(code int) {
panic(fmt.Sprintf("os.Exit(%d)", code))
}
defer func() { logrus.StandardLogger().ExitFunc = origExitFunc }()
// Capture log output to verify the error message.
var buf bytes.Buffer
origOutput := logrus.StandardLogger().Out
logrus.SetOutput(&buf)
defer logrus.SetOutput(origOutput)
assert.Panics(t, func() {
command := NewLogoutCommand(&argocdclient.ClientOptions{ConfigPath: configFile})
command.Run(nil, []string{"some-context"})
})
assert.Contains(t, buf.String(), "Nothing to logout from")
}

View file

@ -372,6 +372,12 @@ const (
BearerTokenTimeout = 30 * time.Second
)
// TokenRevocationTimeout is the maximum time allowed for a server-side token revocation call during logout.
const TokenRevocationTimeout = 10 * time.Second
// TokenRevocationClientTimeout is the maximum time the CLI waits for the server to complete token revocation.
const TokenRevocationClientTimeout = 15 * time.Second
const (
DefaultGitRetryMaxDuration time.Duration = time.Second * 5 // 5s
DefaultGitRetryDuration time.Duration = time.Millisecond * 250 // 0.25s

View file

@ -429,6 +429,41 @@ You are not required to specify a logoutRedirectURL as this is automatically gen
> [!NOTE]
> The post logout redirect URI may need to be whitelisted against your OIDC provider's client settings for ArgoCD.
### Token Revocation and Session Management
Argo CD implements server-side token revocation to enhance security when users log out. This is particularly important for SSO configurations using Dex or other OIDC providers.
#### How Token Revocation Works
When a user logs out (either via the UI or CLI using `argocd logout`), Argo CD:
1. **Invalidates the token on the server**: The token is added to a revocation list stored in Redis
2. **Removes the token locally**: The token is removed from the local configuration
3. **Redirects to OIDC provider** (if configured): The user is redirected to the OIDC provider's logout URL to terminate the SSO session
Revoked tokens cannot be used for API calls, even if they haven't expired yet. This prevents:
- Unauthorized access after logout
- Token reuse if a token is compromised
- Security gaps with Dex SSO where tokens are not automatically invalidated
#### Graceful Degradation
The `argocd logout` command will gracefully handle scenarios where the server is unreachable:
```bash
$ argocd logout my-argocd-server
WARN[0000] Failed to invalidate token on server: connection refused. Proceeding with local logout.
Logged out from 'my-argocd-server'
```
This allows users to logout locally even if the server is down, though the token will not be revoked server-side until it expires naturally.
#### Security Best Practices
1.**Use short-lived tokens**: Configure reasonable token expiration times in the OIDC provider to limit the window of exposure
2.**Enable logout URLs**: Configure `logoutURL` in `oidc.config` for your OIDC provider to ensure SSO sessions are also terminated
3.**Monitor token usage**: Use Argo CD's audit logging to track token creation and revocation events
### Configuring a custom root CA certificate for communicating with the OIDC provider
If your OIDC provider is setup with a certificate which is not signed by one of the well known certificate authorities

View file

@ -241,3 +241,33 @@ data:
Context 'my-argo-cd-url' updated
You may get an warning if you are not using a correctly signed certs. Refer to [Why Am I Getting x509: certificate signed by unknown authority When Using The CLI?](https://argo-cd.readthedocs.io/en/stable/faq/#why-am-i-getting-x509-certificate-signed-by-unknown-authority-when-using-the-cli).
## Domain hint (optional)
For Microsoft identity platforms, you can set `domainHint` in `oidc.config` to provide a domain hint during sign-in.
When configured, Argo CD adds `domain_hint=<value>` to the authorization request sent to Microsoft.
This can reduce account discovery prompts in multi-tenant or federated environments.
- **Field:** `domainHint`
- **Type:** `string`
- **Required:** No
- **Default:** empty (parameter is not sent)
Example:
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
oidc.config: |
name: Microsoft
issuer: https://login.microsoftonline.com/<tenant-id>/v2.0
clientID: <client-id>
clientSecret: $oidc.microsoft.clientSecret
requestedScopes: ["openid", "profile", "email", "groups"]
domainHint: contoso.com
```

2
go.mod
View file

@ -71,6 +71,7 @@ require (
github.com/mattn/go-zglob v0.0.6
github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.1-0.20241014080628-3045bdf43455
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
github.com/olekukonko/tablewriter v1.1.3
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
@ -187,6 +188,7 @@ require (
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.24.3 // indirect
github.com/go-openapi/errors v0.22.7 // indirect

5
go.sum
View file

@ -311,6 +311,8 @@ github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@ -722,6 +724,8 @@ github.com/nlopes/slack v0.5.0/go.mod h1:jVI4BBK3lSktibKahxBF74txcK2vyvkza1z/+rR
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 h1:9bCMuD3TcnjeqjPT2gSlha4asp8NvgcFRYExCaikCxk=
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25/go.mod h1:eDjgYHYDJbPLBLsyZ6qRaugP0mX8vePOhZ5id1fdzJw=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
@ -1002,6 +1006,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=

View file

@ -2,6 +2,6 @@
set -eux -o pipefail
# renovate: datasource=go packageName=github.com/golangci/golangci-lint/v2
GOLANGCI_LINT_VERSION=2.11.2
GOLANGCI_LINT_VERSION=2.11.3
GO111MODULE=on go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v${GOLANGCI_LINT_VERSION}"

View file

@ -111,9 +111,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
issuer := jwtutil.StringField(mapClaims, "iss")
id := jwtutil.StringField(mapClaims, "jti")
// Workaround for Dex token, because does not have jti.
if id == "" {
id = jwtutil.StringField(mapClaims, "at_hash")
}
if exp, err := jwtutil.ExpirationTime(mapClaims); err == nil && id != "" {
if err := h.revokeToken(context.Background(), id, time.Until(exp)); err != nil {
log.Warnf("failed to invalidate token '%s': %v", id, err)
ttl := time.Until(exp)
if ttl <= 0 {
// Token already expired; no need to persist revocation
log.Infof("token '%s' already expired, skipping revocation", id)
} else {
revokeCtx, cancel := context.WithTimeout(r.Context(), common.TokenRevocationTimeout)
defer cancel()
if err := h.revokeToken(revokeCtx, id, ttl); err != nil {
log.Warnf("failed to invalidate token '%s': %v", id, err)
}
}
}

View file

@ -8,6 +8,7 @@ import (
"regexp"
"strconv"
"testing"
"time"
"github.com/argoproj/argo-cd/v3/common"
"github.com/argoproj/argo-cd/v3/test"
@ -15,7 +16,6 @@ import (
"github.com/argoproj/argo-cd/v3/util/settings"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -32,9 +32,11 @@ var (
baseLogoutURLwithRedirectURL = "http://localhost:4000/logout?post_logout_redirect_uri={{logoutRedirectURL}}"
baseLogoutURLwithTokenAndRedirectURL = "http://localhost:4000/logout?id_token_hint={{token}}&post_logout_redirect_uri={{logoutRedirectURL}}"
invalidToken = "sample-token"
dexToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ijc5YTFlNTgzOWM2ZTRjMTZlOTIwYzM1YTU0MmMwNmZhIn0.eyJzdWIiOiIwMHVqNnM1NDVyNU5peVNLcjVkNSIsIm5hbWUiOiJqZCByIiwiZW1haWwiOiJqYWlkZWVwMTdydWx6QGdtYWlsLmNvbSIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9kZXYtNTY5NTA5OC5va3RhLmNvbSIsImF1ZCI6IjBvYWowM2FmSEtqN3laWXJwNWQ1IiwiaWF0IjoxNjA1NTcyMzU5LCJleHAiOjE2MDU1NzU5NTksImFtciI6WyJwd2QiXSwiaWRwIjoiMDBvaWdoZmZ2SlFMNjNaOGg1ZDUiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJqYWlkZWVwMTdydWx6QGdtYWlsLmNvbSIsImF1dGhfdGltZSI6MTYwNTU3MjM1NywiYXRfaGFzaCI6ImplUTBGaXZqT2c0YjZNSldEMjE5bGcifQ.Xt_5G-4dNZef1egOYmvruszudlAvUXVQzqrI4YwkWJeZ0zZDk4lyhPUVuxVGjB3pCCUCUMloTL6xC7IVFNj53Eb7WNH_hxsFqemJ80HZYbUpo2G9fMjkPmFTaeFVMC4p3qxIaBAT9_uJbTRSyRGYLV-95KDpU-GNDFXlbFq-2bVvhppiYmKszyHbREZkB87Pi7K3Bk0NxAlDOJ7O5lhwjpwuOJ1WGCJptUetePm5MnpVT2ZCyjvntlzwHlIhMSKNlFZuFS_JMca5Ww0fQSBUlarQU9MMyZKBw-QuD5sJw3xjwQpxOG-T9mJz7F8VA5znLi_LJNutHVgcpt3T_TW_0NbgqsHe8Lw"
oidcToken = "eyJraWQiOiJYQi1MM3ZFdHhYWXJLcmRSQnVEV0NwdnZsSnk3SEJVb2d5N253M1U1Z1ZZIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiIwMHVqNnM1NDVyNU5peVNLcjVkNSIsIm5hbWUiOiJqZCByIiwiZW1haWwiOiJqYWlkZWVwMTdydWx6QGdtYWlsLmNvbSIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9kZXYtNTY5NTA5OC5va3RhLmNvbSIsImF1ZCI6IjBvYWowM2FmSEtqN3laWXJwNWQ1IiwiaWF0IjoxNjA1NTcyMzU5LCJleHAiOjE2MDU1NzU5NTksImp0aSI6IklELl9ORDJxVG5iREFtc3hIZUt2U2ZHeVBqTXRicXFEQXdkdlRQTDZCTnpfR3ciLCJhbXIiOlsicHdkIl0sImlkcCI6IjAwb2lnaGZmdkpRTDYzWjhoNWQ1IiwicHJlZmVycmVkX3VzZXJuYW1lIjoiamFpZGVlcDE3cnVsekBnbWFpbC5jb20iLCJhdXRoX3RpbWUiOjE2MDU1NzIzNTcsImF0X2hhc2giOiJqZVEwRml2ak9nNGI2TUpXRDIxOWxnIn0.GHkqwXgW-lrAhJdypW7SVjW0YdNLFQiRL8iwgT6DHJxP9Nb0OtkH2NKcBYAA5N6bTPLRQUHgYwWcgm5zSXmvqa7ciIgPF3tiQI8UmJA9VFRRDR-x9ExX15nskCbXfiQ67MriLslUrQUyzSCfUrSjXKwnDxbKGQncrtmRsh5asfCzJFb9excn311W9HKbT3KA0Ot7eOMnVS6V7SGfXxnKs6szcXIEMa_FhB4zDAVLr-dnxvSG_uuWcHrAkLTUVhHbdQQXF7hXIEfyr5lkMJN-drjdz-bn40GaYulEmUvO1bjcL9toCVQ3Ismypyr0b8phj4w3uRsLDZQxTxK7jAXlyQ"
nonOidcToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MDU1NzQyMTIsImlzcyI6ImFyZ29jZCIsIm5iZiI6MTYwNTU3NDIxMiwic3ViIjoiYWRtaW4ifQ.zDJ4piwWnwsHON-oPusHMXWINlnrRDTQykYogT7afeE"
expectedNonOIDCLogoutURL = "http://localhost:4000"
expectedDexLogoutURL = "http://localhost:4000"
expectedNonOIDCLogoutURLOnSecondHost = "http://argocd.my-corp.tld"
expectedOIDCLogoutURL = "https://dev-5695098.okta.com/oauth2/v1/logout?id_token_hint=" + oidcToken + "&post_logout_redirect_uri=" + baseURL
expectedOIDCLogoutURLWithRootPath = "https://dev-5695098.okta.com/oauth2/v1/logout?id_token_hint=" + oidcToken + "&post_logout_redirect_uri=" + baseURL + "/" + rootPath
@ -80,12 +82,46 @@ func TestConstructLogoutURL(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
constructedLogoutURL := constructLogoutURL(tt.logoutURL, tt.token, tt.logoutRedirectURL)
assert.Equal(t, tt.expectedLogoutURL, constructedLogoutURL)
require.Equal(t, tt.expectedLogoutURL, constructedLogoutURL)
})
}
}
func TestHandlerConstructLogoutURL(t *testing.T) {
kubeClientWithDexConfig := fake.NewClientset(
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: common.ArgoCDConfigMapName,
Namespace: "default",
Labels: map[string]string{
"app.kubernetes.io/part-of": "argocd",
},
},
Data: map[string]string{
"dex.config": "connectors: \n" +
"- type: dev \n" +
"name: Dev \n" +
"config: \n" +
"issuer: https://dev-5695098.okta.com \n" +
"clientID: aabbccddeeff00112233 \n" +
"clientSecret: aabbccddeeff00112233",
"url": "http://localhost:4000",
},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: common.ArgoCDSecretName,
Namespace: "default",
Labels: map[string]string{
"app.kubernetes.io/part-of": "argocd",
},
},
Data: map[string][]byte{
"admin.password": nil,
"server.secretkey": nil,
},
},
)
kubeClientWithOIDCConfig := fake.NewClientset(
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
@ -237,14 +273,24 @@ func TestHandlerConstructLogoutURL(t *testing.T) {
},
)
settingsManagerWithDexConfig := settings.NewSettingsManager(t.Context(), kubeClientWithDexConfig, "default")
settingsManagerWithOIDCConfig := settings.NewSettingsManager(t.Context(), kubeClientWithOIDCConfig, "default")
settingsManagerWithoutOIDCConfig := settings.NewSettingsManager(t.Context(), kubeClientWithoutOIDCConfig, "default")
settingsManagerWithOIDCConfigButNoLogoutURL := settings.NewSettingsManager(t.Context(), kubeClientWithOIDCConfigButNoLogoutURL, "default")
settingsManagerWithoutOIDCAndMultipleURLs := settings.NewSettingsManager(t.Context(), kubeClientWithoutOIDCAndMultipleURLs, "default")
settingsManagerWithOIDCConfigButNoURL := settings.NewSettingsManager(t.Context(), kubeClientWithOIDCConfigButNoURL, "default")
sessionManager := session.NewSessionManager(settingsManagerWithOIDCConfig, test.NewFakeProjLister(), "", nil, session.NewUserStateStorage(nil))
redisClient, closer := test.NewInMemoryRedis()
defer closer()
sessionManager := session.NewSessionManager(settingsManagerWithOIDCConfig, test.NewFakeProjLister(), "", nil, session.NewUserStateStorage(redisClient))
dexHandler := NewHandler(settingsManagerWithDexConfig, sessionManager, rootPath, baseHRef)
dexHandler.verifyToken = func(_ context.Context, tokenString string) (jwt.Claims, string, error) {
if !validJWTPattern.MatchString(tokenString) {
return nil, "", errors.New("invalid jwt")
}
return &jwt.RegisteredClaims{Issuer: "dev"}, "", nil
}
oidcHandler := NewHandler(settingsManagerWithOIDCConfig, sessionManager, rootPath, baseHRef)
oidcHandler.verifyToken = func(_ context.Context, tokenString string) (jwt.Claims, string, error) {
if !validJWTPattern.MatchString(tokenString) {
@ -281,6 +327,9 @@ func TestHandlerConstructLogoutURL(t *testing.T) {
}
return &jwt.RegisteredClaims{Issuer: "okta"}, "", nil
}
dexTokenHeader := make(map[string][]string)
dexTokenHeader["Cookie"] = []string{"argocd.token=" + dexToken}
oidcTokenHeader := make(map[string][]string)
oidcTokenHeader["Cookie"] = []string{"argocd.token=" + oidcToken}
nonOidcTokenHeader := make(map[string][]string)
@ -291,6 +340,9 @@ func TestHandlerConstructLogoutURL(t *testing.T) {
emptyHeader["Cookie"] = []string{"argocd.token="}
ctx := t.Context()
dexRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:4000/api/logout", http.NoBody)
require.NoError(t, err)
dexRequest.Header = dexTokenHeader
oidcRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:4000/api/logout", http.NoBody)
require.NoError(t, err)
oidcRequest.Header = oidcTokenHeader
@ -298,9 +350,9 @@ func TestHandlerConstructLogoutURL(t *testing.T) {
require.NoError(t, err)
nonoidcRequest.Header = nonOidcTokenHeader
nonoidcRequestOnSecondHost, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://argocd.my-corp.tld/api/logout", http.NoBody)
assert.NoError(t, err)
require.NoError(t, err)
nonoidcRequestOnSecondHost.Header = nonOidcTokenHeader
assert.NoError(t, err)
require.NoError(t, err)
requestWithInvalidToken, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:4000/api/logout", http.NoBody)
require.NoError(t, err)
requestWithInvalidToken.Header = invalidHeader
@ -320,6 +372,14 @@ func TestHandlerConstructLogoutURL(t *testing.T) {
expectedLogoutURL string
wantErr bool
}{
{
name: "Case: Dex logout request with valid token",
handler: dexHandler,
request: dexRequest,
responseRecorder: httptest.NewRecorder(),
expectedLogoutURL: expectedDexLogoutURL,
wantErr: false,
},
{
name: "Case: OIDC logout request with valid token",
handler: oidcHandler,
@ -405,9 +465,235 @@ func TestHandlerConstructLogoutURL(t *testing.T) {
if tt.wantErr {
t.Errorf("expected error but did not get one")
} else {
assert.Equal(t, tt.expectedLogoutURL, tt.responseRecorder.Result().Header["Location"][0])
require.Equal(t, tt.expectedLogoutURL, tt.responseRecorder.Result().Header["Location"][0])
}
}
})
}
}
func TestHandlerRevokeToken(t *testing.T) {
kubeClient := fake.NewClientset(
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: common.ArgoCDConfigMapName,
Namespace: "default",
Labels: map[string]string{
"app.kubernetes.io/part-of": "argocd",
},
},
Data: map[string]string{
"url": "http://localhost:4000",
},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: common.ArgoCDSecretName,
Namespace: "default",
Labels: map[string]string{
"app.kubernetes.io/part-of": "argocd",
},
},
Data: map[string][]byte{
"admin.password": nil,
"server.secretkey": nil,
},
},
)
settingsMgr := settings.NewSettingsManager(t.Context(), kubeClient, "default")
redisClient, closer := test.NewInMemoryRedis()
defer closer()
sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", nil, session.NewUserStateStorage(redisClient))
t.Run("Token with jti calls revokeToken with jti value", func(t *testing.T) {
var revokedID string
var revokeCalled bool
handler := NewHandler(settingsMgr, sessionMgr, "", baseHRef)
handler.verifyToken = func(_ context.Context, _ string) (jwt.Claims, string, error) {
return jwt.MapClaims{
"iss": session.SessionManagerClaimsIssuer,
"jti": "token-id-123",
"exp": float64(time.Now().Add(time.Hour).Unix()),
}, "", nil
}
handler.revokeToken = func(_ context.Context, id string, _ time.Duration) error {
revokeCalled = true
revokedID = id
return nil
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:4000/api/logout", http.NoBody)
require.NoError(t, err)
req.Header.Set("Cookie", "argocd.token="+nonOidcToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.True(t, revokeCalled, "revokeToken should have been called")
require.Equal(t, "token-id-123", revokedID)
require.Equal(t, http.StatusSeeOther, rec.Code)
})
t.Run("Dex token without jti uses at_hash as fallback", func(t *testing.T) {
var revokedID string
var revokeCalled bool
handler := NewHandler(settingsMgr, sessionMgr, "", baseHRef)
handler.verifyToken = func(_ context.Context, _ string) (jwt.Claims, string, error) {
return jwt.MapClaims{
"iss": "dex",
"at_hash": "dex-at-hash-456",
"exp": float64(time.Now().Add(time.Hour).Unix()),
}, "", nil
}
handler.revokeToken = func(_ context.Context, id string, _ time.Duration) error {
revokeCalled = true
revokedID = id
return nil
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:4000/api/logout", http.NoBody)
require.NoError(t, err)
req.Header.Set("Cookie", "argocd.token="+nonOidcToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.True(t, revokeCalled, "revokeToken should have been called with at_hash fallback")
require.Equal(t, "dex-at-hash-456", revokedID)
})
t.Run("Token with both jti and at_hash uses jti", func(t *testing.T) {
var revokedID string
handler := NewHandler(settingsMgr, sessionMgr, "", baseHRef)
handler.verifyToken = func(_ context.Context, _ string) (jwt.Claims, string, error) {
return jwt.MapClaims{
"iss": session.SessionManagerClaimsIssuer,
"jti": "primary-jti",
"at_hash": "secondary-at-hash",
"exp": float64(time.Now().Add(time.Hour).Unix()),
}, "", nil
}
handler.revokeToken = func(_ context.Context, id string, _ time.Duration) error {
revokedID = id
return nil
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:4000/api/logout", http.NoBody)
require.NoError(t, err)
req.Header.Set("Cookie", "argocd.token="+nonOidcToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, "primary-jti", revokedID, "should use jti when both jti and at_hash are present")
})
t.Run("Token with neither jti nor at_hash does not call revokeToken", func(t *testing.T) {
revokeCalled := false
handler := NewHandler(settingsMgr, sessionMgr, "", baseHRef)
handler.verifyToken = func(_ context.Context, _ string) (jwt.Claims, string, error) {
return jwt.MapClaims{
"iss": session.SessionManagerClaimsIssuer,
"exp": float64(time.Now().Add(time.Hour).Unix()),
}, "", nil
}
handler.revokeToken = func(_ context.Context, _ string, _ time.Duration) error {
revokeCalled = true
return nil
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:4000/api/logout", http.NoBody)
require.NoError(t, err)
req.Header.Set("Cookie", "argocd.token="+nonOidcToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.False(t, revokeCalled, "revokeToken should not be called when id is empty")
require.Equal(t, http.StatusSeeOther, rec.Code)
})
t.Run("Expired token skips revocation", func(t *testing.T) {
revokeCalled := false
handler := NewHandler(settingsMgr, sessionMgr, "", baseHRef)
handler.verifyToken = func(_ context.Context, _ string) (jwt.Claims, string, error) {
return jwt.MapClaims{
"iss": session.SessionManagerClaimsIssuer,
"jti": "expired-token-id",
"exp": float64(time.Now().Add(-time.Hour).Unix()), // already expired
}, "", nil
}
handler.revokeToken = func(_ context.Context, _ string, _ time.Duration) error {
revokeCalled = true
return nil
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:4000/api/logout", http.NoBody)
require.NoError(t, err)
req.Header.Set("Cookie", "argocd.token="+nonOidcToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.False(t, revokeCalled, "revokeToken should not be called for expired tokens")
require.Equal(t, http.StatusSeeOther, rec.Code)
})
t.Run("Revocation timeout does not block logout", func(t *testing.T) {
handler := NewHandler(settingsMgr, sessionMgr, "", baseHRef)
handler.verifyToken = func(_ context.Context, _ string) (jwt.Claims, string, error) {
return jwt.MapClaims{
"iss": session.SessionManagerClaimsIssuer,
"jti": "timeout-token-id",
"exp": float64(time.Now().Add(time.Hour).Unix()),
}, "", nil
}
handler.revokeToken = func(ctx context.Context, _ string, _ time.Duration) error {
// Simulate a slow backend that exceeds the context timeout
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Second):
return nil
}
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:4000/api/logout", http.NoBody)
require.NoError(t, err)
req.Header.Set("Cookie", "argocd.token="+nonOidcToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusSeeOther, rec.Code, "should redirect even when revocation times out")
})
t.Run("revokeToken error does not prevent redirect", func(t *testing.T) {
handler := NewHandler(settingsMgr, sessionMgr, "", baseHRef)
handler.verifyToken = func(_ context.Context, _ string) (jwt.Claims, string, error) {
return jwt.MapClaims{
"iss": session.SessionManagerClaimsIssuer,
"jti": "some-token-id",
"exp": float64(time.Now().Add(time.Hour).Unix()),
}, "", nil
}
handler.revokeToken = func(_ context.Context, _ string, _ time.Duration) error {
return errors.New("redis connection refused")
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:4000/api/logout", http.NoBody)
require.NoError(t, err)
req.Header.Set("Cookie", "argocd.token="+nonOidcToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusSeeOther, rec.Code, "should still redirect even when revokeToken fails")
})
}

View file

@ -1029,6 +1029,44 @@ func RunCliWithStdin(stdin string, isKubeConextOnlyCli bool, args ...string) (st
return RunWithStdinWithRedactor(stdin, "", "../../dist/argocd", redactor, args...)
}
// RunCliWithToken executes an Argo CD CLI command using a specific auth token
// instead of the global test token. This is useful for session/logout tests
// that need to verify behavior with multiple or revoked tokens.
func RunCliWithToken(authToken string, args ...string) (string, error) {
if plainText {
args = append(args, "--plaintext")
}
args = append(args, "--server", apiServerAddress, "--auth-token", authToken, "--insecure")
redactor := func(text string) string {
if authToken == "" {
return text
}
authTokenPattern := "--auth-token " + authToken
return strings.ReplaceAll(text, authTokenPattern, "--auth-token ******")
}
return RunWithStdinWithRedactor("", "", "../../dist/argocd", redactor, args...)
}
// RunCliWithConfigFile executes an Argo CD CLI command using a custom config file
// instead of the global test config. This is used by session/logout tests to
// isolate per-token state across login/logout cycles.
func RunCliWithConfigFile(configPath string, args ...string) (string, error) {
if plainText {
args = append(args, "--plaintext")
}
args = append(args, "--server", apiServerAddress, "--config", configPath, "--insecure")
redactor := func(text string) string {
return text
}
return RunWithStdinWithRedactor("", "", "../../dist/argocd", redactor, args...)
}
// RunPluginCli executes an Argo CD CLI plugin with optional stdin input.
func RunPluginCli(stdin string, args ...string) (string, error) {
return RunWithStdin(stdin, "", "../../dist/argocd", args...)
@ -1334,3 +1372,21 @@ func GetToken() string {
func IsPlainText() bool {
return plainText
}
// SetParamInRBACConfigMap sets the parameter in argocd-rbac-cm config map
func SetParamInRBACConfigMap(key, value string) error {
return updateRBACConfigMap(func(cm *corev1.ConfigMap) error {
cm.Data[key] = value
return nil
})
}
// SetOIDCConfig sets the oidc.config in argocd-cm config map
func SetOIDCConfig(value string) error {
return updateSettingConfigMap(func(cm *corev1.ConfigMap) error {
cm.Data["oidc.config"] = value
cm.Data["url"] = "http://" + GetApiServerAddress()
delete(cm.Data, "dex.config")
return nil
})
}

View file

@ -0,0 +1,297 @@
package session
import (
"context"
"crypto/rand"
"crypto/rsa"
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
"github.com/oauth2-proxy/mockoidc"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"github.com/argoproj/argo-cd/v3/test/e2e/fixture"
"github.com/argoproj/argo-cd/v3/util/localconfig"
)
var (
// mockServer is a package-level singleton mock OIDC server shared across all tests.
mockServer *mockoidc.MockOIDC
mockServerOnce sync.Once
)
// Actions implements the "when" part of given/when/then for session/logout tests.
type Actions struct {
context *Context
// configPaths maps token names to per-token CLI config file paths
configPaths map[string]string
// tokenStore tracks named tokens read from CLI config files
tokenStore map[string]string
// lastOutput holds the stdout from the most recent CLI action
lastOutput string
// lastError holds the error from the most recent action
lastError error
}
// getTokenStore initializes the token store
func (a *Actions) getTokenStore() map[string]string {
if a.tokenStore == nil {
a.tokenStore = make(map[string]string)
}
return a.tokenStore
}
// getConfigPaths initializes the config paths map
func (a *Actions) getConfigPaths() map[string]string {
if a.configPaths == nil {
a.configPaths = make(map[string]string)
}
return a.configPaths
}
// configPathFor returns (or creates) a per-token temp config file path.
func (a *Actions) configPathFor(tokenName string) string {
a.context.T().Helper()
paths := a.getConfigPaths()
if p, ok := paths[tokenName]; ok {
return p
}
p := filepath.Join(a.context.T().TempDir(), tokenName+"-config")
paths[tokenName] = p
return p
}
func (a *Actions) getSharedMockOIDCServer() *mockoidc.MockOIDC {
mockServerOnce.Do(func() {
t := a.context.T()
// Create a fresh RSA Private Key for token signing
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
oidcPort := os.Getenv("ARGOCD_E2E_OIDC_PORT")
if oidcPort == "" {
oidcPort = "5556"
}
lc := net.ListenConfig{}
ctx, cancelFunc := context.WithTimeout(context.Background(), 60*time.Second)
defer cancelFunc()
ln, err := lc.Listen(ctx, "tcp", "localhost:"+oidcPort)
require.NoError(t, err)
mockServer, err = mockoidc.NewServer(rsaKey)
require.NoError(t, err)
err = mockServer.Start(ln, nil)
require.NoError(t, err)
t.Logf("OIDC server listening on %s", ln.Addr())
t.Cleanup(func() {
err := mockServer.Shutdown()
require.NoError(t, err, "error shutting down mock oidc server")
})
})
return mockServer
}
// WithDirectOIDC configures ArgoCD with oidc.config pointing directly to
// a shared mock OIDC server (bypassing Dex). Tests using this setup should
// use LoginWithSSO to mint OIDC tokens programmatically. This exercises the IDP
// token verification and revocation code path in SessionManager.VerifyToken.
func (a *Actions) WithDirectOIDC() *Actions {
a.context.T().Helper()
m := a.getSharedMockOIDCServer()
// Do NOT shut down the mock server in t.Cleanup. The API server's
// SessionManager caches the OIDC provider (mgr.prov) with this server's
// issuer URL. If we shut it down, subsequent tests would start a new
// server on a different port, causing issuer mismatch errors.
// Configure oidc.config in argocd-cm pointing to the mock OIDC server
oidcConfig := fmt.Sprintf("name: Mock OIDC\nissuer: %s\nclientID: %s\nclientSecret: %s\n",
m.Issuer(), m.Config().ClientID, m.Config().ClientSecret)
err := fixture.SetOIDCConfig(oidcConfig)
require.NoError(a.context.T(), err)
// Grant all users admin access so OIDC-authenticated users can call APIs
err = fixture.SetParamInRBACConfigMap("policy.default", "role:admin")
require.NoError(a.context.T(), err)
a.context.T().Logf("Direct OIDC Issuer: %s, ClientID: %s", m.Issuer(), m.Config().ClientID)
fixture.RestartAPIServer(a.context.T())
return a
}
// Login creates a new session as admin using the argocd CLI login command.
// Each token name gets its own isolated config file so multiple sessions
// can coexist without interfering with each other.
func (a *Actions) Login(tokenName string) *Actions {
a.context.T().Helper()
cfgPath := a.configPathFor(tokenName)
output, err := fixture.RunCliWithConfigFile(cfgPath,
"login",
fixture.GetApiServerAddress(),
"--username", "admin",
"--password", fixture.AdminPassword,
"--skip-test-tls",
)
require.NoError(a.context.T(), err, "CLI login failed: %s", output)
// Read the token back from the config file
localCfg, err := localconfig.ReadLocalConfig(cfgPath)
require.NoError(a.context.T(), err)
require.NotNil(a.context.T(), localCfg)
token := localCfg.GetToken(localCfg.CurrentContext)
require.NotEmpty(a.context.T(), token, "no token found in config file after login")
a.getTokenStore()[tokenName] = token
return a
}
// Logout revokes the named token using the argocd CLI logout command.
// This triggers the full CLI logout flow including server-side token revocation.
func (a *Actions) Logout(tokenName string) *Actions {
a.context.T().Helper()
cfgPath := a.getConfigPaths()[tokenName]
require.NotEmpty(a.context.T(), cfgPath, "config path for %q not found; call Login first", tokenName)
output, err := fixture.RunCliWithConfigFile(cfgPath,
"logout",
fixture.GetApiServerAddress(),
)
require.NoError(a.context.T(), err, "CLI logout failed: %s", output)
return a
}
// LoginWithSSO mints an OIDC token directly from the shared mock OIDC server,
// bypassing the normal browser-based SSO flow. Each call creates a unique user
// identity based on tokenName so multiple SSO sessions produce different tokens.
// This exercises the IDP token verification and revocation code path in
// SessionManager.VerifyToken.
func (a *Actions) LoginWithSSO(tokenName string) *Actions {
a.context.T().Helper()
require.NotNil(a.context.T(), mockServer, "LoginWithSSO requires WithDirectOIDC (mockServer is nil)")
m := mockServer
// Create a unique user for each token name
user := &mockoidc.MockUser{
Subject: "sso-" + tokenName,
Email: tokenName + "@example.com",
EmailVerified: true,
PreferredUsername: tokenName,
Groups: []string{"admins"},
}
// Create a session on the mock OIDC server
session, err := m.SessionStore.NewSession(
"openid email profile groups",
"", // nonce
user,
"", // codeChallenge
"", // codeChallengeMethod
)
require.NoError(a.context.T(), err)
// Mint a signed ID token using the mock server's keypair
idToken, err := session.IDToken(
m.Config(),
m.Keypair,
m.Now(),
)
require.NoError(a.context.T(), err)
a.getTokenStore()[tokenName] = idToken
// Write a local config file so CLI logout can find and revoke this token
serverAddr := fixture.GetApiServerAddress()
cfgPath := a.configPathFor(tokenName)
localCfg := localconfig.LocalConfig{
CurrentContext: serverAddr,
Contexts: []localconfig.ContextRef{
{Name: serverAddr, Server: serverAddr, User: serverAddr},
},
Servers: []localconfig.Server{
{Server: serverAddr, PlainText: true, Insecure: true},
},
Users: []localconfig.User{
{Name: serverAddr, AuthToken: idToken},
},
}
err = localconfig.WriteLocalConfig(localCfg, cfgPath)
require.NoError(a.context.T(), err)
return a
}
// runCli executes an argocd CLI command using the named token and records
// the output and error for assertion in Then().
func (a *Actions) runCli(tokenName string, args ...string) {
a.context.T().Helper()
token := a.getTokenStore()[tokenName]
require.NotEmpty(a.context.T(), token, "token %q not found; call Login first", tokenName)
a.lastOutput, a.lastError = fixture.RunCliWithToken(token, args...)
}
// GetUserInfo calls "argocd account get-user-info" using the named token.
func (a *Actions) GetUserInfo(tokenName string) *Actions {
a.context.T().Helper()
a.runCli(tokenName, "account", "get-user-info", "--grpc-web")
return a
}
// ListProjects calls "argocd proj list" using the named token.
func (a *Actions) ListProjects(tokenName string) *Actions {
a.context.T().Helper()
a.runCli(tokenName, "proj", "list", "--grpc-web")
return a
}
// ListApplications calls "argocd app list" using the named token.
func (a *Actions) ListApplications(tokenName string) *Actions {
a.context.T().Helper()
a.runCli(tokenName, "app", "list", "--grpc-web")
log.Debugf("[token: %s, output: %s, err: %s]", tokenName, a.lastOutput, a.lastError)
return a
}
// ListRepositories calls "argocd repo list" using the named token.
func (a *Actions) ListRepositories(tokenName string) *Actions {
a.context.T().Helper()
a.runCli(tokenName, "repo", "list", "--grpc-web")
return a
}
// ListAccounts calls "argocd account list" using the named token.
func (a *Actions) ListAccounts(tokenName string) *Actions {
a.context.T().Helper()
a.runCli(tokenName, "account", "list")
return a
}
// Sleep pauses execution for the given duration.
func (a *Actions) Sleep(d time.Duration) *Actions {
time.Sleep(d)
return a
}
func (a *Actions) Then() *Consequences {
a.context.T().Helper()
time.Sleep(fixture.WhenThenSleepInterval)
return &Consequences{context: a.context, actions: a}
}

View file

@ -0,0 +1,53 @@
package session
import (
"time"
"github.com/argoproj/argo-cd/v3/test/e2e/fixture"
)
// Consequences implements the "then" part of given/when/then for session/logout tests.
type Consequences struct {
context *Context
actions *Actions
}
// ActionShouldSucceed asserts that the last action completed without error.
func (c *Consequences) ActionShouldSucceed() *Consequences {
c.context.T().Helper()
if c.actions.lastError != nil {
c.context.T().Errorf("expected action to succeed, got error: %v", c.actions.lastError)
}
return c
}
// ActionShouldFail asserts that the last action returned an error and passes
// it to the callback for further inspection.
func (c *Consequences) ActionShouldFail(block func(err error)) *Consequences {
c.context.T().Helper()
if c.actions.lastError == nil {
c.context.T().Error("expected action to fail, but it succeeded")
return c
}
block(c.actions.lastError)
return c
}
// AndCLIOutput passes the CLI output and error from the last action to the
// callback for custom assertions.
func (c *Consequences) AndCLIOutput(block func(output string, err error)) *Consequences {
c.context.T().Helper()
block(c.actions.lastOutput, c.actions.lastError)
return c
}
// Given returns the Context to allow chaining back to setup.
func (c *Consequences) Given() *Context {
return c.context
}
// When returns the Actions to allow chaining back to actions.
func (c *Consequences) When() *Actions {
time.Sleep(fixture.WhenThenSleepInterval)
return c.actions
}

View file

@ -0,0 +1,30 @@
package session
import (
"testing"
"time"
"github.com/argoproj/argo-cd/v3/test/e2e/fixture"
)
// Context implements the "given" part of given/when/then for session/logout tests.
type Context struct {
*fixture.TestState
}
func Given(t *testing.T) *Context {
t.Helper()
state := fixture.EnsureCleanState(t)
return &Context{TestState: state}
}
// GivenWithSameState creates a new Context that shares the same TestState as an existing context.
func GivenWithSameState(ctx fixture.TestContext) *Context {
ctx.T().Helper()
return &Context{TestState: fixture.NewTestStateFromContext(ctx)}
}
func (c *Context) When() *Actions {
time.Sleep(fixture.WhenThenSleepInterval)
return &Actions{context: c}
}

172
test/e2e/logout_test.go Normal file
View file

@ -0,0 +1,172 @@
package e2e
import (
"testing"
"github.com/stretchr/testify/require"
sessionFixture "github.com/argoproj/argo-cd/v3/test/e2e/fixture/session"
)
// TestLogoutRevokesToken verifies that after logging out via the /auth/logout endpoint,
// the JWT token is revoked and can no longer be used for API calls.
func TestLogoutRevokesToken(t *testing.T) {
sessionFixture.Given(t).
When().
Login("token1").
GetUserInfo("token1").
ListAccounts("token1").
Then().
ActionShouldSucceed().
When().
Logout("token1").
GetUserInfo("token1").
ListAccounts("token1").
Then().
ActionShouldFail(func(err error) {
require.Contains(t, err.Error(), "token is revoked")
})
}
// TestLogoutDoesNotAffectOtherSessions verifies that revoking one session token
// does not invalidate a different session token for the same user.
func TestLogoutDoesNotAffectOtherSessions(t *testing.T) {
sessionFixture.Given(t).
When().
Login("token1").
Login("token2").
GetUserInfo("token1").
GetUserInfo("token2").
ListAccounts("token1").
Then().
ActionShouldSucceed().
When().
GetUserInfo("token2").
ListAccounts("token2").
Then().
ActionShouldSucceed().
When().
Logout("token1").
GetUserInfo("token1").
ListApplications("token1").
Then().
ActionShouldFail(func(err error) {
require.Contains(t, err.Error(), "token is revoked")
}).
When().
ListApplications("token2").
Then().
ActionShouldSucceed()
}
// TestLogoutRevokedTokenCannotAccessAPIs verifies that a revoked token is rejected
// across different API endpoints, not just GetUserInfo.
func TestLogoutRevokedTokenCannotAccessAPIs(t *testing.T) {
sessionFixture.Given(t).
When().
Login("token1").
GetUserInfo("token1").
Logout("token1").
GetUserInfo("token1").
ListAccounts("token1").
Then().
ActionShouldFail(func(err error) {
require.Contains(t, err.Error(), "token is revoked")
}).
// Project API
When().
ListProjects("token1").
Then().
ActionShouldFail(func(err error) {
require.Contains(t, err.Error(), "token is revoked")
}).
// Repository API
When().
ListRepositories("token1").
Then().
ActionShouldFail(func(err error) {
require.Contains(t, err.Error(), "token is revoked")
}).
// Application API
When().
ListApplications("token1").
Then().
ActionShouldFail(func(err error) {
require.Contains(t, err.Error(), "token is revoked")
})
}
// TestLogoutRevokesOIDCToken verifies that token revocation works for tokens
// issued directly by an external OIDC provider (bypassing Dex). This exercises
// the IDP token verification and revocation code path in SessionManager.VerifyToken.
func TestLogoutRevokesOIDCToken(t *testing.T) {
sessionFixture.Given(t).
When().
WithDirectOIDC().
LoginWithSSO("token1").
ListApplications("token1").
Then().
ActionShouldSucceed().
When().
Logout("token1").
ListApplications("token1").
Then().
ActionShouldFail(func(err error) {
require.Contains(t, err.Error(), "token is revoked")
})
}
// TestLogoutDoesNotAffectOtherOIDCSessions verifies that revoking one OIDC session
// token does not invalidate a different OIDC session token.
func TestLogoutDoesNotAffectOtherOIDCSessions(t *testing.T) {
sessionFixture.Given(t).
When().
WithDirectOIDC().
LoginWithSSO("token1").
LoginWithSSO("token2").
ListApplications("token1").
Then().
ActionShouldSucceed().
When().
Logout("token1").
ListApplications("token1").
Then().
ActionShouldFail(func(err error) {
require.Contains(t, err.Error(), "token is revoked")
}).
When().
ListApplications("token2").
Then().
ActionShouldSucceed()
}
// TestLogoutRevokedOIDCTokenCannotAccessAPIs verifies that a revoked OIDC token
// is rejected across different API endpoints.
func TestLogoutRevokedOIDCTokenCannotAccessAPIs(t *testing.T) {
sessionFixture.Given(t).
When().
WithDirectOIDC().
LoginWithSSO("token1").
GetUserInfo("token1").
Logout("token1").
GetUserInfo("token1").
ListApplications("token1").
Then().
ActionShouldFail(func(err error) {
require.Contains(t, err.Error(), "token is revoked")
}).
// Project API
When().
ListProjects("token1").
Then().
ActionShouldFail(func(err error) {
require.Contains(t, err.Error(), "token is revoked")
}).
// Repository API
When().
ListRepositories("token1").
Then().
ActionShouldFail(func(err error) {
require.Contains(t, err.Error(), "token is revoked")
})
}

View file

@ -219,6 +219,16 @@ func (l *LocalConfig) RemoveUser(serverName string) bool {
return false
}
// GetToken returns the token stored in the local file for the given server name
func (l *LocalConfig) GetToken(serverName string) string {
for _, u := range l.Users {
if u.Name == serverName {
return u.AuthToken
}
}
return ""
}
// Returns true if user was removed successfully
func (l *LocalConfig) RemoveToken(serverName string) bool {
for i, u := range l.Users {

View file

@ -9,17 +9,15 @@ import (
"path/filepath"
"testing"
"github.com/argoproj/argo-cd/v3/util/config"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
"github.com/argoproj/argo-cd/v3/util/config"
)
func TestGetUsername(t *testing.T) {
assert.Equal(t, "admin", GetUsername("admin:login"))
assert.Equal(t, "admin", GetUsername("admin"))
assert.Empty(t, GetUsername(""))
require.Equal(t, "admin", GetUsername("admin:login"))
require.Equal(t, "admin", GetUsername("admin"))
require.Empty(t, GetUsername(""))
}
func TestFilePermission(t *testing.T) {
@ -76,7 +74,7 @@ func TestFilePermission(t *testing.T) {
f, err := os.Create(filePath)
require.NoError(t, err, "Could not write create config file: %v", err)
defer func() {
assert.NoError(t, f.Close())
require.NoError(t, f.Close())
}()
err = f.Chmod(c.perm)
@ -86,7 +84,7 @@ func TestFilePermission(t *testing.T) {
require.NoError(t, err, "Could not access the fileinfo: %v", err)
if err := getFilePermission(fi); err != nil {
assert.EqualError(t, err, c.expectedError.Error())
require.EqualError(t, err, c.expectedError.Error())
} else {
require.NoError(t, c.expectedError)
}
@ -120,12 +118,16 @@ users:
- auth-token: vErrYS3c3tReFRe$hToken
name: localhost:8080`
const testConfigFilePath = "./testdata/local.config"
const (
testConfigFilePath = "./testdata/local.config"
testConfigFileName = "local.config"
testWriteFileName = "write-local.config"
)
func loadOpts(t *testing.T, opts string) {
t.Helper()
t.Setenv("ARGOCD_OPTS", opts)
assert.NoError(t, config.LoadFlags())
require.NoError(t, config.LoadFlags())
}
func TestGetPromptsEnabled_useCLIOpts_false_localConfigPromptsEnabled_true(t *testing.T) {
@ -140,7 +142,7 @@ func TestGetPromptsEnabled_useCLIOpts_false_localConfigPromptsEnabled_true(t *te
loadOpts(t, "--config "+testConfigFilePath)
assert.True(t, GetPromptsEnabled(false))
require.True(t, GetPromptsEnabled(false))
}
func TestGetPromptsEnabled_useCLIOpts_false_localConfigPromptsEnabled_false(t *testing.T) {
@ -155,7 +157,7 @@ func TestGetPromptsEnabled_useCLIOpts_false_localConfigPromptsEnabled_false(t *t
loadOpts(t, "--config "+testConfigFilePath)
assert.False(t, GetPromptsEnabled(false))
require.False(t, GetPromptsEnabled(false))
}
func TestGetPromptsEnabled_useCLIOpts_true_forcePromptsEnabled_default(t *testing.T) {
@ -170,7 +172,7 @@ func TestGetPromptsEnabled_useCLIOpts_true_forcePromptsEnabled_default(t *testin
loadOpts(t, "--config "+testConfigFilePath+" --prompts-enabled")
assert.True(t, GetPromptsEnabled(true))
require.True(t, GetPromptsEnabled(true))
}
func TestGetPromptsEnabled_useCLIOpts_true_forcePromptsEnabled_true(t *testing.T) {
@ -185,7 +187,7 @@ func TestGetPromptsEnabled_useCLIOpts_true_forcePromptsEnabled_true(t *testing.T
loadOpts(t, "--config "+testConfigFilePath+" --prompts-enabled=true")
assert.True(t, GetPromptsEnabled(true))
require.True(t, GetPromptsEnabled(true))
}
func TestGetPromptsEnabled_useCLIOpts_true_forcePromptsEnabled_false(t *testing.T) {
@ -200,13 +202,135 @@ func TestGetPromptsEnabled_useCLIOpts_true_forcePromptsEnabled_false(t *testing.
loadOpts(t, "--config "+testConfigFilePath+" --prompts-enabled=false")
assert.False(t, GetPromptsEnabled(true))
require.False(t, GetPromptsEnabled(true))
}
func TestGetToken_Exist(t *testing.T) {
testFilePath := filepath.Join(t.TempDir(), testConfigFileName)
err := os.WriteFile(testFilePath, []byte(testConfig), os.ModePerm)
require.NoError(t, err)
err = os.Chmod(testFilePath, 0o600)
require.NoError(t, err, "Could not change the file permission to 0600 %v", err)
localConfig, err := ReadLocalConfig(testFilePath)
require.NoError(t, err)
token := localConfig.GetToken(localConfig.CurrentContext)
require.Equal(t, "vErrYS3c3tReFRe$hToken", token)
}
func TestGetToken_Not_Exist(t *testing.T) {
testFilePath := filepath.Join(t.TempDir(), testConfigFileName)
err := os.WriteFile(testFilePath, []byte(testConfig), os.ModePerm)
require.NoError(t, err)
err = os.Chmod(testFilePath, 0o600)
require.NoError(t, err, "Could not change the file permission to 0600 %v", err)
localConfig, err := ReadLocalConfig(testFilePath)
require.NoError(t, err)
// serverName does exist in TestConfig
token := localConfig.GetToken("localhost")
require.Empty(t, token)
}
func TestRemoveToken_Exist(t *testing.T) {
testFilePath := filepath.Join(t.TempDir(), testConfigFileName)
err := os.WriteFile(testFilePath, []byte(testConfig), os.ModePerm)
require.NoError(t, err)
err = os.Chmod(testFilePath, 0o600)
require.NoError(t, err, "Could not change the file permission to 0600 %v", err)
localConfig, err := ReadLocalConfig(testFilePath)
require.NoError(t, err)
removed := localConfig.RemoveToken(localConfig.CurrentContext)
require.True(t, removed)
}
func TestRemoveToken_ClearsBothTokens(t *testing.T) {
testFilePath := filepath.Join(t.TempDir(), testConfigFileName)
err := os.WriteFile(testFilePath, []byte(testConfig), os.ModePerm)
require.NoError(t, err)
err = os.Chmod(testFilePath, 0o600)
require.NoError(t, err, "Could not change the file permission to 0600 %v", err)
localConfig, err := ReadLocalConfig(testFilePath)
require.NoError(t, err)
// Verify tokens exist before removal (use argocd1 which has both auth-token and refresh-token)
serverName := "argocd1.example.com:443"
require.Equal(t, "vErrYS3c3tReFRe$hToken", localConfig.GetToken(serverName))
removed := localConfig.RemoveToken(serverName)
require.True(t, removed)
// Verify both AuthToken and RefreshToken are cleared
require.Empty(t, localConfig.GetToken(serverName))
for _, u := range localConfig.Users {
if u.Name == serverName {
require.Empty(t, u.AuthToken, "AuthToken should be cleared after RemoveToken")
require.Empty(t, u.RefreshToken, "RefreshToken should be cleared after RemoveToken")
return
}
}
t.Fatal("user entry should still exist after RemoveToken")
}
func TestRemoveToken_Not_Exist(t *testing.T) {
testFilePath := filepath.Join(t.TempDir(), testConfigFileName)
err := os.WriteFile(testFilePath, []byte(testConfig), os.ModePerm)
require.NoError(t, err)
err = os.Chmod(testFilePath, 0o600)
require.NoError(t, err, "Could not change the file permission to 0600 %v", err)
localConfig, err := ReadLocalConfig(testFilePath)
require.NoError(t, err)
// serverName does exist in TestConfig
removed := localConfig.RemoveToken("localhost")
require.False(t, removed)
}
func TestValidateLocalConfig(t *testing.T) {
testFilePath := filepath.Join(t.TempDir(), testConfigFileName)
err := os.WriteFile(testFilePath, []byte(testConfig), os.ModePerm)
require.NoError(t, err)
err = os.Chmod(testFilePath, 0o600)
require.NoError(t, err, "Could not change the file permission to 0600 %v", err)
localConfig, err := ReadLocalConfig(testFilePath)
require.NoError(t, err)
err = ValidateLocalConfig(*localConfig)
require.NoError(t, err)
}
func TestWriteLocalConfig(t *testing.T) {
testFilePath := filepath.Join(t.TempDir(), testConfigFileName)
err := os.WriteFile(testFilePath, []byte(testConfig), os.ModePerm)
require.NoError(t, err)
err = os.Chmod(testFilePath, 0o600)
require.NoError(t, err, "Could not change the file permission to 0600 %v", err)
localConfig, err := ReadLocalConfig(testFilePath)
require.NoError(t, err)
testWriteFilePath := filepath.Join(t.TempDir(), testWriteFileName)
err = WriteLocalConfig(*localConfig, testWriteFilePath)
require.NoError(t, err)
}
func TestReadLocalConfig_FileNotExist(t *testing.T) {
config, err := ReadLocalConfig("/nonexistent/path/config")
require.NoError(t, err)
assert.Nil(t, config)
require.Nil(t, config)
}
func TestReadLocalConfig_InvalidYAML(t *testing.T) {
@ -216,7 +340,7 @@ func TestReadLocalConfig_InvalidYAML(t *testing.T) {
config, err := ReadLocalConfig(tmpFile)
require.Error(t, err)
assert.Nil(t, config)
require.Nil(t, config)
}
func TestReadLocalConfig_EmptyFile(t *testing.T) {
@ -227,7 +351,7 @@ func TestReadLocalConfig_EmptyFile(t *testing.T) {
config, err := ReadLocalConfig(tmpFile)
require.NoError(t, err)
// Empty file results in empty config, not nil
assert.NotNil(t, config)
require.NotNil(t, config)
}
func TestReadLocalConfig_ValidConfig(t *testing.T) {
@ -237,7 +361,7 @@ func TestReadLocalConfig_ValidConfig(t *testing.T) {
config, err := ReadLocalConfig(tmpFile)
require.NoError(t, err)
assert.NotNil(t, config)
assert.Equal(t, "localhost:8080", config.CurrentContext)
assert.Len(t, config.Contexts, 3)
require.NotNil(t, config)
require.Equal(t, "localhost:8080", config.CurrentContext)
require.Len(t, config.Contexts, 3)
}

View file

@ -100,8 +100,10 @@ type ClientApp struct {
provider Provider
// clientCache represent a cache of sso artifact
clientCache cache.CacheClient
// properties for azure workload identity.
azure azureApp
// domainHint is an optional hint to the identity provider about the domain the user belongs to.
// Used to pre-fill or streamline the login experience (e.g., for Azure AD multi-tenant scenarios).
domainHint string
azure azureApp
// preemptive token refresh threshold
refreshTokenThreshold time.Duration
}
@ -182,6 +184,14 @@ func GetScopesOrDefault(scopes []string) []string {
return scopes
}
func getDomainHint(settings *settings.ArgoCDSettings) string {
oidcConfig := settings.OIDCConfig()
if oidcConfig != nil {
return strings.TrimSpace(oidcConfig.DomainHint)
}
return ""
}
// NewClientApp will register the Argo CD client app (either via Dex or external OIDC) and return an
// object which has HTTP handlers for handling the HTTP responses for login and callback
func NewClientApp(settings *settings.ArgoCDSettings, dexServerAddr string, dexTLSConfig *dex.DexTLSConfig, baseHRef string, cacheClient cache.CacheClient) (*ClientApp, error) {
@ -193,6 +203,7 @@ func NewClientApp(settings *settings.ArgoCDSettings, dexServerAddr string, dexTL
if err != nil {
return nil, err
}
domainHint := getDomainHint(settings)
a := ClientApp{
clientID: settings.OAuth2ClientID(),
clientSecret: settings.OAuth2ClientSecret(),
@ -204,6 +215,7 @@ func NewClientApp(settings *settings.ArgoCDSettings, dexServerAddr string, dexTL
encryptionKey: encryptionKey,
clientCache: cacheClient,
azure: azureApp{mtx: &sync.RWMutex{}},
domainHint: domainHint,
refreshTokenThreshold: settings.OIDCRefreshTokenThreshold,
}
log.Infof("Creating client app (%s)", a.clientID)
@ -422,6 +434,9 @@ func (a *ClientApp) HandleLogin(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid redirect URL: the protocol and host (including port) must match and the path must be within allowed URLs if provided", http.StatusBadRequest)
return
}
if a.domainHint != "" {
opts = append(opts, oauth2.SetAuthURLParam("domain_hint", a.domainHint))
}
if a.usePKCE {
pkceVerifier = oauth2.GenerateVerifier()
opts = append(opts, oauth2.S256ChallengeOption(pkceVerifier))

View file

@ -49,6 +49,65 @@ func setupAzureIdentity(t *testing.T) {
t.Setenv("AZURE_FEDERATED_TOKEN_FILE", tokenFilePath)
}
func TestGetDomainHint(t *testing.T) {
t.Run("Returns domain hint when OIDC config is set", func(t *testing.T) {
settings := &settings.ArgoCDSettings{
OIDCConfigRAW: `
name: Test OIDC
issuer: https://example.com
clientID: test-client
clientSecret: test-secret
domainHint: example.com
`,
}
domainHint := getDomainHint(settings)
assert.Equal(t, "example.com", domainHint)
})
t.Run("Returns empty string when domain hint is not set", func(t *testing.T) {
settings := &settings.ArgoCDSettings{
OIDCConfigRAW: `
name: Test OIDC
issuer: https://example.com
clientID: test-client
clientSecret: test-secret
`,
}
domainHint := getDomainHint(settings)
assert.Empty(t, domainHint)
})
t.Run("Returns empty string when OIDC config is nil", func(t *testing.T) {
settings := &settings.ArgoCDSettings{
OIDCConfigRAW: "",
}
domainHint := getDomainHint(settings)
assert.Empty(t, domainHint)
})
t.Run("Returns empty string when YAML is malformed", func(t *testing.T) {
settings := &settings.ArgoCDSettings{
OIDCConfigRAW: `{this is not valid yaml at all]`,
}
domainHint := getDomainHint(settings)
assert.Empty(t, domainHint)
})
t.Run("Trims whitespaces from domain hint", func(t *testing.T) {
settings := &settings.ArgoCDSettings{
OIDCConfigRAW: `
name: Test OIDC
issuer: https://example.com
clientID: test-client
clientSecret: test-secret
domainHint: " example.com "
`,
}
domainHint := getDomainHint(settings)
assert.Equal(t, "example.com", domainHint)
})
}
func TestInferGrantType(t *testing.T) {
for _, path := range []string{"dex", "okta", "auth0", "onelogin"} {
t.Run(path, func(t *testing.T) {
@ -102,6 +161,33 @@ func TestIDTokenClaims(t *testing.T) {
assert.JSONEq(t, "{\"id_token\":{\"groups\":{\"essential\":true}}}", values.Get("claims"))
}
func TestHandleLogin_IncludesDomainHint(t *testing.T) {
oidcTestServer := test.GetOIDCTestServer(t, nil)
t.Cleanup(oidcTestServer.Close)
cdSettings := &settings.ArgoCDSettings{
URL: "https://argocd.example.com",
OIDCTLSInsecureSkipVerify: true,
OIDCConfigRAW: fmt.Sprintf(`
name: Test
issuer: %s
clientID: test-client-id
clientSecret: test-client-secret
domainHint: example.com
requestedScopes: ["openid", "profile", "email", "groups"]`, oidcTestServer.URL),
}
app, err := NewClientApp(cdSettings, "", nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour))
require.NoError(t, err)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://argocd.example.com/auth/login", http.NoBody)
w := httptest.NewRecorder()
app.HandleLogin(w, req)
assert.Equal(t, http.StatusSeeOther, w.Code)
location := w.Header().Get("Location")
assert.Contains(t, location, "domain_hint=example.com")
}
type fakeProvider struct {
EndpointError bool
}

View file

@ -287,7 +287,10 @@ func (mgr *SessionManager) Parse(tokenString string) (jwt.Claims, string, error)
return nil, "", fmt.Errorf("account %s does not have '%s' capability", subject, capability)
}
if id == "" || mgr.storage.IsTokenRevoked(id) {
if id == "" {
return nil, "", errors.New("token does not have a unique identifier (jti claim) and cannot be validated")
}
if mgr.storage.IsTokenRevoked(id) {
return nil, "", errors.New("token is revoked, please re-login")
} else if capability == settings.AccountCapabilityApiKey && account.TokenIndex(id) == -1 {
return nil, "", fmt.Errorf("account %s does not have token with id %s", subject, id)
@ -303,10 +306,12 @@ func (mgr *SessionManager) Parse(tokenString string) (jwt.Claims, string, error)
remainingDuration := time.Until(exp)
if remainingDuration < autoRegenerateTokenDuration && capability == settings.AccountCapabilityLogin {
if uniqueId, err := uuid.NewRandom(); err == nil {
if val, err := mgr.Create(fmt.Sprintf("%s:%s", subject, settings.AccountCapabilityLogin), int64(tokenExpDuration.Seconds()), uniqueId.String()); err == nil {
newToken = val
}
var uniqueId uuid.UUID
if uniqueId, err = uuid.NewRandom(); err != nil {
return nil, "", fmt.Errorf("could not create UUID for new JWT token: %w", err)
}
if newToken, err = mgr.Create(fmt.Sprintf("%s:%s", subject, settings.AccountCapabilityLogin), int64(tokenExpDuration.Seconds()), uniqueId.String()); err != nil {
return nil, "", fmt.Errorf("could not create new JWT token: %w", err)
}
}
}
@ -596,6 +601,20 @@ func (mgr *SessionManager) VerifyToken(ctx context.Context, tokenString string)
return nil, "", common.ErrTokenVerification
}
id, ok := claims["jti"].(string)
if !ok {
log.Warnf("token does not have jti claim")
id = ""
}
// Workaround for Dex token, because does not have jti.
if id == "" {
id = idToken.AccessTokenHash
}
if mgr.storage.IsTokenRevoked(id) {
return nil, "", errors.New("token is revoked, please re-login")
}
var claims jwt.MapClaims
err = idToken.Claims(&claims)
if err != nil {

View file

@ -34,6 +34,7 @@ import (
"github.com/argoproj/argo-cd/v3/util"
"github.com/argoproj/argo-cd/v3/util/cache"
"github.com/argoproj/argo-cd/v3/util/crypto"
"github.com/argoproj/argo-cd/v3/util/dex"
jwtutil "github.com/argoproj/argo-cd/v3/util/jwt"
"github.com/argoproj/argo-cd/v3/util/oidc"
"github.com/argoproj/argo-cd/v3/util/password"
@ -143,21 +144,80 @@ func TestSessionManager_AdminToken_Revoked(t *testing.T) {
mgr := newSessionManager(settingsMgr, getProjLister(), storage)
token, err := mgr.Create("admin:login", 0, "123")
token, err := mgr.Create("admin:login", int64(autoRegenerateTokenDuration.Seconds()*2), "123")
require.NoError(t, err)
err = storage.RevokeToken(t.Context(), "123", time.Hour)
err = storage.RevokeToken(t.Context(), "123", autoRegenerateTokenDuration*2)
require.NoError(t, err)
_, _, err = mgr.Parse(token)
assert.EqualError(t, err, "token is revoked, please re-login")
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", 0, "abc")
token, err := mgr.Create("admin:login", int64(autoRegenerateTokenDuration.Seconds()*2), "abc")
require.NoError(t, err, "Could not create token")
_, _, err = mgr.Parse(token)
@ -168,7 +228,7 @@ 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", 0, "abc")
token, err := mgr.Create("admin", int64(autoRegenerateTokenDuration.Seconds()*2), "abc")
require.NoError(t, err, "Could not create token")
_, _, err = mgr.Parse(token)
@ -202,7 +262,7 @@ func TestSessionManager_ProjectToken(t *testing.T) {
mapClaims, err := jwtutil.MapClaims(claims)
require.NoError(t, err)
assert.Equal(t, "proj:default:test", mapClaims["sub"])
require.Equal(t, "proj:default:test", mapClaims["sub"])
})
t.Run("Token Revoked", func(t *testing.T) {
@ -1287,6 +1347,184 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL),
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) {

View file

@ -134,6 +134,20 @@ func dexMockHandler(t *testing.T, url string) func(http.ResponseWriter, *http.Re
"claims_supported": ["sub", "aud", "exp"]
}`, url)
require.NoError(t, err)
case "/api/dex/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 = w.Write(out)
require.NoError(t, err)
default:
w.WriteHeader(http.StatusNotFound)
}