mirror of
https://github.com/hanzoai/deploy.git
synced 2026-10-07 08:44:50 +00:00
* 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]>
1837 lines
60 KiB
Go
1837 lines
60 KiB
Go
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/oauth2"
|
|
"sigs.k8s.io/yaml"
|
|
|
|
"github.com/hanzoai/deploy/common"
|
|
"github.com/hanzoai/deploy/server/settings/oidc"
|
|
"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/settings"
|
|
"github.com/hanzoai/deploy/util/test"
|
|
)
|
|
|
|
func setupAzureIdentity(t *testing.T) {
|
|
t.Helper()
|
|
|
|
tempDir := t.TempDir()
|
|
tokenFilePath := filepath.Join(tempDir, "token.txt")
|
|
tempFile, err := os.Create(tokenFilePath)
|
|
require.NoError(t, err)
|
|
_, err = tempFile.WriteString("serviceAccountToken")
|
|
require.NoError(t, err)
|
|
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) {
|
|
rawConfig, err := os.ReadFile("testdata/" + path + ".json")
|
|
require.NoError(t, err)
|
|
var config OIDCConfiguration
|
|
err = json.Unmarshal(rawConfig, &config)
|
|
require.NoError(t, err)
|
|
grantType := InferGrantType(&config)
|
|
assert.Equal(t, GrantTypeAuthorizationCode, grantType)
|
|
|
|
var noCodeResponseTypes []string
|
|
for _, supportedResponseType := range config.ResponseTypesSupported {
|
|
if supportedResponseType != ResponseTypeCode {
|
|
noCodeResponseTypes = append(noCodeResponseTypes, supportedResponseType)
|
|
}
|
|
}
|
|
|
|
config.ResponseTypesSupported = noCodeResponseTypes
|
|
grantType = InferGrantType(&config)
|
|
assert.Equal(t, GrantTypeImplicit, grantType)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIDTokenClaims(t *testing.T) {
|
|
oauth2Config := &oauth2.Config{
|
|
ClientID: "DUMMY_OIDC_PROVIDER",
|
|
ClientSecret: "0987654321",
|
|
Endpoint: oauth2.Endpoint{AuthURL: "https://argocd-dev.onelogin.com/oidc/auth", TokenURL: "https://argocd-dev.onelogin.com/oidc/token"},
|
|
Scopes: []string{"oidc", "profile", "groups"},
|
|
RedirectURL: "https://argocd-dev.io/redirect_url",
|
|
}
|
|
|
|
var opts []oauth2.AuthCodeOption
|
|
requestedClaims := make(map[string]*oidc.Claim)
|
|
|
|
opts = AppendClaimsAuthenticationRequestParameter(opts, requestedClaims)
|
|
assert.Empty(t, opts)
|
|
|
|
requestedClaims["groups"] = &oidc.Claim{Essential: true}
|
|
opts = AppendClaimsAuthenticationRequestParameter(opts, requestedClaims)
|
|
assert.Len(t, opts, 1)
|
|
|
|
authCodeURL, err := url.Parse(oauth2Config.AuthCodeURL("TEST", opts...))
|
|
require.NoError(t, err)
|
|
|
|
values, err := url.ParseQuery(authCodeURL.RawQuery)
|
|
require.NoError(t, err)
|
|
|
|
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
|
|
}
|
|
|
|
func (p *fakeProvider) Endpoint() (*oauth2.Endpoint, error) {
|
|
if p.EndpointError {
|
|
return nil, errors.New("fake provider endpoint error")
|
|
}
|
|
return &oauth2.Endpoint{}, nil
|
|
}
|
|
|
|
func (p *fakeProvider) ParseConfig() (*OIDCConfiguration, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (p *fakeProvider) Verify(_ context.Context, _ string, _ *settings.ArgoCDSettings) (*gooidc.IDToken, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestHandleCallback(t *testing.T) {
|
|
app := ClientApp{provider: &fakeProvider{}, settings: &settings.ArgoCDSettings{}}
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com/foo", http.NoBody)
|
|
req.Form = url.Values{
|
|
"error": []string{"login-failed"},
|
|
"error_description": []string{"<script>alert('hello')</script>"},
|
|
}
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleCallback(w, req)
|
|
|
|
assert.Equal(t, "login-failed: <script>alert('hello')</script>\n", w.Body.String())
|
|
}
|
|
|
|
func TestClientApp_HandleLogin(t *testing.T) {
|
|
oidcTestServer := test.GetOIDCTestServer(t, nil)
|
|
t.Cleanup(oidcTestServer.Close)
|
|
|
|
dexTestServer := test.GetDexTestServer(t)
|
|
t.Cleanup(dexTestServer.Close)
|
|
|
|
t.Run("oidc certificate checking during login should toggle on config", func(t *testing.T) {
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
OIDCConfigRAW: fmt.Sprintf(`
|
|
name: Test
|
|
issuer: %s
|
|
clientID: xxx
|
|
clientSecret: yyy
|
|
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
|
}
|
|
app, err := NewClientApp(cdSettings, dexTestServer.URL, 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.Contains(t, w.Body.String(), "certificate signed by unknown authority")
|
|
|
|
cdSettings.OIDCTLSInsecureSkipVerify = true
|
|
|
|
app, err = NewClientApp(cdSettings, dexTestServer.URL, nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
app.HandleLogin(w, req)
|
|
|
|
assert.NotContains(t, w.Body.String(), "certificate is not trusted")
|
|
assert.NotContains(t, w.Body.String(), "certificate signed by unknown authority")
|
|
})
|
|
|
|
t.Run("dex certificate checking during login should toggle on config", func(t *testing.T) {
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
DexConfig: `connectors:
|
|
- type: github
|
|
name: GitHub
|
|
config:
|
|
clientID: aabbccddeeff00112233
|
|
clientSecret: aabbccddeeff00112233`,
|
|
}
|
|
cert, err := tls.X509KeyPair(test.Cert, test.PrivateKey)
|
|
require.NoError(t, err)
|
|
cdSettings.Certificate = &cert
|
|
app, err := NewClientApp(cdSettings, dexTestServer.URL, 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)
|
|
|
|
if !strings.Contains(w.Body.String(), "certificate signed by unknown authority") && !strings.Contains(w.Body.String(), "certificate is not trusted") {
|
|
t.Fatal("did not receive expected certificate verification failure error")
|
|
}
|
|
|
|
app, err = NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
app.HandleLogin(w, req)
|
|
|
|
assert.NotContains(t, w.Body.String(), "certificate is not trusted")
|
|
assert.NotContains(t, w.Body.String(), "certificate signed by unknown authority")
|
|
})
|
|
|
|
t.Run("OIDC auth", func(t *testing.T) {
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
OIDCTLSInsecureSkipVerify: true,
|
|
}
|
|
oidcConfig := settings.OIDCConfig{
|
|
Name: "Test",
|
|
Issuer: oidcTestServer.URL,
|
|
ClientID: "xxx",
|
|
ClientSecret: "yyy",
|
|
}
|
|
oidcConfigRaw, err := yaml.Marshal(oidcConfig)
|
|
require.NoError(t, err)
|
|
cdSettings.OIDCConfigRAW = string(oidcConfigRaw)
|
|
|
|
app, err := NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "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, err := url.Parse(w.Header().Get("Location"))
|
|
require.NoError(t, err)
|
|
values, err := url.ParseQuery(location.RawQuery)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"openid", "profile", "email", "groups"}, strings.Split(values.Get("scope"), " "))
|
|
assert.Equal(t, "xxx", values.Get("client_id"))
|
|
assert.Equal(t, "code", values.Get("response_type"))
|
|
})
|
|
|
|
t.Run("OIDC auth with custom scopes", func(t *testing.T) {
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
OIDCTLSInsecureSkipVerify: true,
|
|
}
|
|
oidcConfig := settings.OIDCConfig{
|
|
Name: "Test",
|
|
Issuer: oidcTestServer.URL,
|
|
ClientID: "xxx",
|
|
ClientSecret: "yyy",
|
|
RequestedScopes: []string{"oidc"},
|
|
}
|
|
oidcConfigRaw, err := yaml.Marshal(oidcConfig)
|
|
require.NoError(t, err)
|
|
cdSettings.OIDCConfigRAW = string(oidcConfigRaw)
|
|
|
|
app, err := NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "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, err := url.Parse(w.Header().Get("Location"))
|
|
require.NoError(t, err)
|
|
values, err := url.ParseQuery(location.RawQuery)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"oidc"}, strings.Split(values.Get("scope"), " "))
|
|
assert.Equal(t, "xxx", values.Get("client_id"))
|
|
assert.Equal(t, "code", values.Get("response_type"))
|
|
})
|
|
|
|
t.Run("Dex auth", func(t *testing.T) {
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: dexTestServer.URL,
|
|
}
|
|
dexConfig := map[string]any{
|
|
"connectors": []map[string]any{
|
|
{
|
|
"type": "github",
|
|
"name": "GitHub",
|
|
"config": map[string]any{
|
|
"clientId": "aabbccddeeff00112233",
|
|
"clientSecret": "aabbccddeeff00112233",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
dexConfigRaw, err := yaml.Marshal(dexConfig)
|
|
require.NoError(t, err)
|
|
cdSettings.DexConfig = string(dexConfigRaw)
|
|
|
|
app, err := NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "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, err := url.Parse(w.Header().Get("Location"))
|
|
require.NoError(t, err)
|
|
values, err := url.ParseQuery(location.RawQuery)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"openid", "profile", "email", "groups", common.DexFederatedScope}, strings.Split(values.Get("scope"), " "))
|
|
assert.Equal(t, common.ArgoCDClientAppID, values.Get("client_id"))
|
|
assert.Equal(t, "code", values.Get("response_type"))
|
|
})
|
|
|
|
t.Run("with additional base URL", func(t *testing.T) {
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
AdditionalURLs: []string{"https://localhost:8080", "https://other.argocd.example.com"},
|
|
OIDCTLSInsecureSkipVerify: true,
|
|
DexConfig: `connectors:
|
|
- type: github
|
|
name: GitHub
|
|
config:
|
|
clientID: aabbccddeeff00112233
|
|
clientSecret: aabbccddeeff00112233`,
|
|
OIDCConfigRAW: fmt.Sprintf(`
|
|
name: Test
|
|
issuer: %s
|
|
clientID: xxx
|
|
clientSecret: yyy
|
|
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
|
}
|
|
cert, err := tls.X509KeyPair(test.Cert, test.PrivateKey)
|
|
require.NoError(t, err)
|
|
cdSettings.Certificate = &cert
|
|
app, err := NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
t.Run("should accept login redirecting on the main domain", func(t *testing.T) {
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://argocd.example.com/auth/login", http.NoBody)
|
|
|
|
req.URL.RawQuery = url.Values{
|
|
"return_url": []string{"https://argocd.example.com/applications"},
|
|
}.Encode()
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleLogin(w, req)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
location, err := url.Parse(w.Header().Get("Location"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fmt.Sprintf("%s://%s", location.Scheme, location.Host), oidcTestServer.URL)
|
|
assert.Equal(t, "/auth", location.Path)
|
|
assert.Equal(t, "https://argocd.example.com/auth/callback", location.Query().Get("redirect_uri"))
|
|
})
|
|
|
|
t.Run("should accept login redirecting on the alternative domains", func(t *testing.T) {
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://localhost:8080/auth/login", http.NoBody)
|
|
|
|
req.URL.RawQuery = url.Values{
|
|
"return_url": []string{"https://localhost:8080/applications"},
|
|
}.Encode()
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleLogin(w, req)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
location, err := url.Parse(w.Header().Get("Location"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fmt.Sprintf("%s://%s", location.Scheme, location.Host), oidcTestServer.URL)
|
|
assert.Equal(t, "/auth", location.Path)
|
|
assert.Equal(t, "https://localhost:8080/auth/callback", location.Query().Get("redirect_uri"))
|
|
})
|
|
|
|
t.Run("should accept login redirecting on the alternative domains", func(t *testing.T) {
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://other.argocd.example.com/auth/login", http.NoBody)
|
|
|
|
req.URL.RawQuery = url.Values{
|
|
"return_url": []string{"https://other.argocd.example.com/applications"},
|
|
}.Encode()
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleLogin(w, req)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
location, err := url.Parse(w.Header().Get("Location"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fmt.Sprintf("%s://%s", location.Scheme, location.Host), oidcTestServer.URL)
|
|
assert.Equal(t, "/auth", location.Path)
|
|
assert.Equal(t, "https://other.argocd.example.com/auth/callback", location.Query().Get("redirect_uri"))
|
|
})
|
|
|
|
t.Run("should deny login redirecting on the alternative domains", func(t *testing.T) {
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://not-argocd.example.com/auth/login", http.NoBody)
|
|
|
|
req.URL.RawQuery = url.Values{
|
|
"return_url": []string{"https://not-argocd.example.com/applications"},
|
|
}.Encode()
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleLogin(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
assert.Empty(t, w.Header().Get("Location"))
|
|
})
|
|
})
|
|
}
|
|
|
|
func Test_Login_Flow(t *testing.T) {
|
|
// Show that SSO login works when no redirect URL is provided, and we fall back to the configured base href for the
|
|
// Argo CD instance.
|
|
|
|
oidcTestServer := test.GetOIDCTestServer(t, nil)
|
|
t.Cleanup(oidcTestServer.Close)
|
|
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
OIDCConfigRAW: fmt.Sprintf(`
|
|
name: Test
|
|
issuer: %s
|
|
clientID: test-client-id
|
|
clientSecret: test-client-secret
|
|
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
|
OIDCTLSInsecureSkipVerify: true,
|
|
}
|
|
// The base href (the last argument for NewClientApp) is what HandleLogin will fall back to when no explicit
|
|
// redirect URL is given.
|
|
app, err := NewClientApp(cdSettings, "", nil, "/", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://argocd.example.com/auth/login", http.NoBody)
|
|
|
|
app.HandleLogin(w, req)
|
|
|
|
redirectURL, err := w.Result().Location()
|
|
require.NoError(t, err)
|
|
|
|
state := redirectURL.Query().Get("state")
|
|
|
|
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("https://argocd.example.com/auth/callback?state=%s&code=abc", state), http.NoBody)
|
|
for _, cookie := range w.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
app.HandleCallback(w, req)
|
|
|
|
assert.Equal(t, 303, w.Code)
|
|
assert.NotContains(t, w.Body.String(), ErrInvalidRedirectURL.Error())
|
|
}
|
|
|
|
func Test_Login_Flow_With_PKCE(t *testing.T) {
|
|
var codeChallenge string
|
|
|
|
oidcTestServer := test.GetOIDCTestServer(t, func(r *http.Request) {
|
|
codeVerifier := r.FormValue("code_verifier")
|
|
assert.NotEmpty(t, codeVerifier)
|
|
assert.Equal(t, oauth2.S256ChallengeFromVerifier(codeVerifier), codeChallenge)
|
|
})
|
|
t.Cleanup(oidcTestServer.Close)
|
|
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://example.com/argocd",
|
|
OIDCConfigRAW: fmt.Sprintf(`
|
|
name: Test
|
|
issuer: %s
|
|
clientID: test-client-id
|
|
clientSecret: test-client-secret
|
|
requestedScopes: ["oidc"]
|
|
enablePKCEAuthentication: true`, oidcTestServer.URL),
|
|
OIDCTLSInsecureSkipVerify: true,
|
|
}
|
|
app, err := NewClientApp(cdSettings, "", nil, "/", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://example.com/argocd/auth/login", http.NoBody)
|
|
|
|
app.HandleLogin(w, req)
|
|
|
|
redirectURL, err := w.Result().Location()
|
|
require.NoError(t, err)
|
|
|
|
codeChallenge = redirectURL.Query().Get("code_challenge")
|
|
|
|
assert.NotEmpty(t, codeChallenge)
|
|
assert.Equal(t, "S256", redirectURL.Query().Get("code_challenge_method"))
|
|
|
|
state := redirectURL.Query().Get("state")
|
|
|
|
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("https://example.com/argocd/auth/callback?state=%s&code=abc", state), http.NoBody)
|
|
for _, cookie := range w.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
app.HandleCallback(w, req)
|
|
|
|
assert.Equal(t, 303, w.Code)
|
|
assert.NotContains(t, w.Body.String(), ErrInvalidRedirectURL.Error())
|
|
}
|
|
|
|
func TestClientApp_HandleCallback(t *testing.T) {
|
|
oidcTestServer := test.GetOIDCTestServer(t, nil)
|
|
t.Cleanup(oidcTestServer.Close)
|
|
|
|
dexTestServer := test.GetDexTestServer(t)
|
|
t.Cleanup(dexTestServer.Close)
|
|
|
|
t.Run("oidc certificate checking during oidc callback should toggle on config", func(t *testing.T) {
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
OIDCConfigRAW: fmt.Sprintf(`
|
|
name: Test
|
|
issuer: %s
|
|
clientID: xxx
|
|
clientSecret: yyy
|
|
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
|
}
|
|
app, err := NewClientApp(cdSettings, dexTestServer.URL, 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/callback", http.NoBody)
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleCallback(w, req)
|
|
|
|
if !strings.Contains(w.Body.String(), "certificate signed by unknown authority") && !strings.Contains(w.Body.String(), "certificate is not trusted") {
|
|
t.Fatalf("did not receive expected certificate verification failure error: %v", w.Code)
|
|
}
|
|
|
|
cdSettings.OIDCTLSInsecureSkipVerify = true
|
|
|
|
app, err = NewClientApp(cdSettings, dexTestServer.URL, nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
app.HandleCallback(w, req)
|
|
|
|
assert.NotContains(t, w.Body.String(), "certificate is not trusted")
|
|
assert.NotContains(t, w.Body.String(), "certificate signed by unknown authority")
|
|
})
|
|
|
|
t.Run("dex certificate checking during oidc callback should toggle on config", func(t *testing.T) {
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
DexConfig: `connectors:
|
|
- type: github
|
|
name: GitHub
|
|
config:
|
|
clientID: aabbccddeeff00112233
|
|
clientSecret: aabbccddeeff00112233`,
|
|
}
|
|
cert, err := tls.X509KeyPair(test.Cert, test.PrivateKey)
|
|
require.NoError(t, err)
|
|
cdSettings.Certificate = &cert
|
|
app, err := NewClientApp(cdSettings, dexTestServer.URL, 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/callback", http.NoBody)
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleCallback(w, req)
|
|
|
|
if !strings.Contains(w.Body.String(), "certificate signed by unknown authority") && !strings.Contains(w.Body.String(), "certificate is not trusted") {
|
|
t.Fatal("did not receive expected certificate verification failure error")
|
|
}
|
|
|
|
app, err = NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
app.HandleCallback(w, req)
|
|
|
|
assert.NotContains(t, w.Body.String(), "certificate is not trusted")
|
|
assert.NotContains(t, w.Body.String(), "certificate signed by unknown authority")
|
|
})
|
|
}
|
|
|
|
func Test_azureApp_getFederatedServiceAccountToken(t *testing.T) {
|
|
app := azureApp{mtx: &sync.RWMutex{}}
|
|
|
|
setupAzureIdentity(t)
|
|
|
|
t.Run("before the method call assertion should be empty.", func(t *testing.T) {
|
|
assert.Empty(t, app.assertion)
|
|
})
|
|
|
|
t.Run("Fetch the token value from the file", func(t *testing.T) {
|
|
_, err := app.getFederatedServiceAccountToken(t.Context())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "serviceAccountToken", app.assertion)
|
|
})
|
|
|
|
t.Run("Workload Identity Not enabled.", func(t *testing.T) {
|
|
t.Setenv("AZURE_FEDERATED_TOKEN_FILE", "")
|
|
_, err := app.getFederatedServiceAccountToken(t.Context())
|
|
assert.ErrorContains(t, err, "AZURE_FEDERATED_TOKEN_FILE env variable not found, make sure workload identity is enabled on the cluster")
|
|
})
|
|
|
|
t.Run("Workload Identity invalid file", func(t *testing.T) {
|
|
t.Setenv("AZURE_FEDERATED_TOKEN_FILE", filepath.Join(t.TempDir(), "invalid.txt"))
|
|
_, err := app.getFederatedServiceAccountToken(t.Context())
|
|
assert.ErrorContains(t, err, "AZURE_FEDERATED_TOKEN_FILE specified file does not exist")
|
|
})
|
|
|
|
t.Run("Concurrent access to the function", func(t *testing.T) {
|
|
currentExpiryTime := app.expires
|
|
|
|
var wg sync.WaitGroup
|
|
numGoroutines := 10
|
|
wg.Add(numGoroutines)
|
|
for range numGoroutines {
|
|
go func() {
|
|
defer wg.Done()
|
|
_, err := app.getFederatedServiceAccountToken(t.Context())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "serviceAccountToken", app.assertion)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
// Event with multiple concurrent calls the expiry time should not change untile it passes.
|
|
assert.Equal(t, currentExpiryTime, app.expires)
|
|
})
|
|
|
|
t.Run("Concurrent access to the function when the current token expires", func(t *testing.T) {
|
|
var wg sync.WaitGroup
|
|
currentExpiryTime := app.expires
|
|
app.expires = time.Now()
|
|
numGoroutines := 10
|
|
wg.Add(numGoroutines)
|
|
for range numGoroutines {
|
|
go func() {
|
|
defer wg.Done()
|
|
_, err := app.getFederatedServiceAccountToken(t.Context())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "serviceAccountToken", app.assertion)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
assert.NotEqual(t, currentExpiryTime, app.expires)
|
|
})
|
|
}
|
|
|
|
func TestClientAppWithAzureWorkloadIdentity_HandleCallback(t *testing.T) {
|
|
tokenRequestAssertions := func(r *http.Request) {
|
|
err := r.ParseForm()
|
|
require.NoError(t, err)
|
|
|
|
formData := r.Form
|
|
clientAssertion := formData.Get("client_assertion")
|
|
clientAssertionType := formData.Get("client_assertion_type")
|
|
assert.Equal(t, "serviceAccountToken", clientAssertion)
|
|
assert.Equal(t, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", clientAssertionType)
|
|
}
|
|
|
|
oidcTestServer := test.GetAzureOIDCTestServer(t, tokenRequestAssertions)
|
|
t.Cleanup(oidcTestServer.Close)
|
|
|
|
dexTestServer := test.GetDexTestServer(t)
|
|
t.Cleanup(dexTestServer.Close)
|
|
signature, err := util.MakeSignature(32)
|
|
require.NoError(t, err)
|
|
|
|
setupAzureIdentity(t)
|
|
|
|
t.Run("oidc certificate checking during oidc callback should toggle on config", func(t *testing.T) {
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
ServerSignature: signature,
|
|
OIDCConfigRAW: fmt.Sprintf(`
|
|
name: Test
|
|
issuer: %s
|
|
clientID: xxx
|
|
azure:
|
|
useWorkloadIdentity: true
|
|
skipAudienceCheckWhenTokenHasNoAudience: true
|
|
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
|
}
|
|
|
|
app, err := NewClientApp(cdSettings, dexTestServer.URL, 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/callback", http.NoBody)
|
|
req.Form = url.Values{
|
|
"code": {"abc"},
|
|
"state": {"123"},
|
|
}
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleCallback(w, req)
|
|
|
|
if !strings.Contains(w.Body.String(), "certificate signed by unknown authority") && !strings.Contains(w.Body.String(), "certificate is not trusted") {
|
|
t.Fatalf("did not receive expected certificate verification failure error: %v", w.Code)
|
|
}
|
|
|
|
cdSettings.OIDCTLSInsecureSkipVerify = true
|
|
|
|
app, err = NewClientApp(cdSettings, dexTestServer.URL, nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
key, err := cdSettings.GetServerEncryptionKey()
|
|
require.NoError(t, err)
|
|
encrypted, _ := crypto.Encrypt([]byte("123"), key)
|
|
req.AddCookie(&http.Cookie{Name: common.StateCookieName, Value: hex.EncodeToString(encrypted)})
|
|
|
|
app.HandleCallback(w, req)
|
|
|
|
assert.NotContains(t, w.Body.String(), "certificate is not trusted")
|
|
assert.NotContains(t, w.Body.String(), "certificate signed by unknown authority")
|
|
})
|
|
}
|
|
|
|
func TestIsValidRedirect(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
valid bool
|
|
redirectURL string
|
|
allowedURLs []string
|
|
}{
|
|
{
|
|
name: "Single allowed valid URL",
|
|
valid: true,
|
|
redirectURL: "https://localhost:4000",
|
|
allowedURLs: []string{"https://localhost:4000/"},
|
|
},
|
|
{
|
|
name: "Empty URL",
|
|
valid: true,
|
|
redirectURL: "",
|
|
allowedURLs: []string{"https://localhost:4000/"},
|
|
},
|
|
{
|
|
name: "Trailing single slash and empty suffix are handled the same",
|
|
valid: true,
|
|
redirectURL: "https://localhost:4000/",
|
|
allowedURLs: []string{"https://localhost:4000"},
|
|
},
|
|
{
|
|
name: "Multiple valid URLs with one allowed",
|
|
valid: true,
|
|
redirectURL: "https://localhost:4000",
|
|
allowedURLs: []string{"https://wherever:4000", "https://localhost:4000"},
|
|
},
|
|
{
|
|
name: "Multiple valid URLs with none allowed",
|
|
valid: false,
|
|
redirectURL: "https://localhost:4000",
|
|
allowedURLs: []string{"https://wherever:4000", "https://invalid:4000"},
|
|
},
|
|
{
|
|
name: "Invalid redirect URL because path prefix does not match",
|
|
valid: false,
|
|
redirectURL: "https://localhost:4000/applications",
|
|
allowedURLs: []string{"https://localhost:4000/argocd"},
|
|
},
|
|
{
|
|
name: "Valid redirect URL because prefix matches",
|
|
valid: true,
|
|
redirectURL: "https://localhost:4000/argocd/applications",
|
|
allowedURLs: []string{"https://localhost:4000/argocd"},
|
|
},
|
|
{
|
|
name: "Invalid redirect URL because resolved path does not match prefix",
|
|
valid: false,
|
|
redirectURL: "https://localhost:4000/argocd/../applications",
|
|
allowedURLs: []string{"https://localhost:4000/argocd"},
|
|
},
|
|
{
|
|
name: "Invalid redirect URL because scheme mismatch",
|
|
valid: false,
|
|
redirectURL: "http://localhost:4000",
|
|
allowedURLs: []string{"https://localhost:4000"},
|
|
},
|
|
{
|
|
name: "Invalid redirect URL because port mismatch",
|
|
valid: false,
|
|
redirectURL: "https://localhost",
|
|
allowedURLs: []string{"https://localhost:80"},
|
|
},
|
|
{
|
|
name: "Invalid redirect URL because of CRLF in path",
|
|
valid: false,
|
|
redirectURL: "https://localhost:80/argocd\r\n",
|
|
allowedURLs: []string{"https://localhost:80/argocd\r\n"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
res := isValidRedirectURL(tt.redirectURL, tt.allowedURLs)
|
|
assert.Equal(t, res, tt.valid)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateAppState(t *testing.T) {
|
|
signature, err := util.MakeSignature(32)
|
|
require.NoError(t, err)
|
|
expectedReturnURL := "http://argocd.example.com/"
|
|
app, err := NewClientApp(&settings.ArgoCDSettings{ServerSignature: signature, URL: expectedReturnURL}, "", nil, "", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
generateResponse := httptest.NewRecorder()
|
|
expectedPKCEVerifier := oauth2.GenerateVerifier()
|
|
state, err := app.generateAppState(expectedReturnURL, expectedPKCEVerifier, generateResponse)
|
|
require.NoError(t, err)
|
|
|
|
t.Run("VerifyAppState_Successful", func(t *testing.T) {
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
|
for _, cookie := range generateResponse.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
returnURL, pkceVerifier, err := app.verifyAppState(req, httptest.NewRecorder(), state)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, expectedReturnURL, returnURL)
|
|
assert.Equal(t, expectedPKCEVerifier, pkceVerifier)
|
|
})
|
|
|
|
t.Run("VerifyAppState_Failed", func(t *testing.T) {
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
|
for _, cookie := range generateResponse.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
_, _, err := app.verifyAppState(req, httptest.NewRecorder(), "wrong state")
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestGenerateAppState_XSS(t *testing.T) {
|
|
signature, err := util.MakeSignature(32)
|
|
require.NoError(t, err)
|
|
app, err := NewClientApp(
|
|
&settings.ArgoCDSettings{
|
|
// Only return URLs starting with this base should be allowed.
|
|
URL: "https://argocd.example.com",
|
|
ServerSignature: signature,
|
|
},
|
|
"", nil, "", cache.NewInMemoryCache(24*time.Hour),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
t.Run("XSS fails", func(t *testing.T) {
|
|
// This attack assumes the attacker has compromised the server's secret key. We use `generateAppState` here for
|
|
// convenience, but an attacker with access to the server secret could write their own code to generate the
|
|
// malicious cookie.
|
|
|
|
expectedReturnURL := "javascript: alert('hi')"
|
|
generateResponse := httptest.NewRecorder()
|
|
state, err := app.generateAppState(expectedReturnURL, "", generateResponse)
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
|
for _, cookie := range generateResponse.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
returnURL, _, err := app.verifyAppState(req, httptest.NewRecorder(), state)
|
|
require.ErrorIs(t, err, ErrInvalidRedirectURL)
|
|
assert.Empty(t, returnURL)
|
|
})
|
|
|
|
t.Run("valid return URL succeeds", func(t *testing.T) {
|
|
expectedReturnURL := "https://argocd.example.com/some/path"
|
|
generateResponse := httptest.NewRecorder()
|
|
state, err := app.generateAppState(expectedReturnURL, "", generateResponse)
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
|
for _, cookie := range generateResponse.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
returnURL, _, err := app.verifyAppState(req, httptest.NewRecorder(), state)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, expectedReturnURL, returnURL)
|
|
})
|
|
}
|
|
|
|
func TestGenerateAppState_NoReturnURL(t *testing.T) {
|
|
signature, err := util.MakeSignature(32)
|
|
require.NoError(t, err)
|
|
cdSettings := &settings.ArgoCDSettings{ServerSignature: signature}
|
|
key, err := cdSettings.GetServerEncryptionKey()
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
|
encrypted, err := crypto.Encrypt([]byte("123"), key)
|
|
require.NoError(t, err)
|
|
app, err := NewClientApp(cdSettings, "", nil, "/argo-cd", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
req.AddCookie(&http.Cookie{Name: common.StateCookieName, Value: hex.EncodeToString(encrypted)})
|
|
returnURL, _, err := app.verifyAppState(req, httptest.NewRecorder(), "123")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "/argo-cd", returnURL)
|
|
}
|
|
|
|
func TestGetUserInfo(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
userInfoPath string
|
|
expectedOutput any
|
|
expectError bool
|
|
expectUnauthenticated bool
|
|
expectedCacheItems []struct { // items to check in cache after function call
|
|
key string
|
|
value string
|
|
expectEncrypted bool
|
|
expectError bool
|
|
}
|
|
idpHandler func(w http.ResponseWriter, r *http.Request)
|
|
idpHandlerUserInfo func(w http.ResponseWriter, r *http.Request) // same as idpHandler but listening on userInfoBaseURL instead of issuerURL
|
|
idpClaims jwt.MapClaims // as per specification sub and exp are REQUIRED fields
|
|
cache cache.CacheClient
|
|
cacheItems []struct { // items to put in cache before execution
|
|
key string
|
|
value string
|
|
encrypt bool
|
|
}
|
|
}{
|
|
{
|
|
name: "call UserInfo with wrong userInfoPath",
|
|
userInfoPath: "/user",
|
|
expectedOutput: jwt.MapClaims(nil),
|
|
expectError: true,
|
|
expectUnauthenticated: false,
|
|
expectedCacheItems: []struct {
|
|
key string
|
|
value string
|
|
expectEncrypted bool
|
|
expectError bool
|
|
}{
|
|
{
|
|
key: FormatUserInfoResponseCacheKey("randomUser"),
|
|
expectError: true,
|
|
},
|
|
},
|
|
idpClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
idpHandler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
},
|
|
cache: cache.NewInMemoryCache(24 * time.Hour),
|
|
cacheItems: []struct {
|
|
key string
|
|
value string
|
|
encrypt bool
|
|
}{
|
|
{
|
|
key: FormatAccessTokenCacheKey("randomUser"),
|
|
value: "FakeAccessToken",
|
|
encrypt: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "call UserInfo with bad accessToken",
|
|
userInfoPath: "/user-info",
|
|
expectedOutput: jwt.MapClaims(nil),
|
|
expectError: false,
|
|
expectUnauthenticated: true,
|
|
expectedCacheItems: []struct {
|
|
key string
|
|
value string
|
|
expectEncrypted bool
|
|
expectError bool
|
|
}{
|
|
{
|
|
key: FormatUserInfoResponseCacheKey("randomUser"),
|
|
expectError: true,
|
|
},
|
|
},
|
|
idpClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
idpHandler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
},
|
|
cache: cache.NewInMemoryCache(24 * time.Hour),
|
|
cacheItems: []struct {
|
|
key string
|
|
value string
|
|
encrypt bool
|
|
}{
|
|
{
|
|
key: FormatAccessTokenCacheKey("randomUser"),
|
|
value: "FakeAccessToken",
|
|
encrypt: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "call UserInfo with garbage returned",
|
|
userInfoPath: "/user-info",
|
|
expectedOutput: jwt.MapClaims(nil),
|
|
expectError: true,
|
|
expectUnauthenticated: false,
|
|
expectedCacheItems: []struct {
|
|
key string
|
|
value string
|
|
expectEncrypted bool
|
|
expectError bool
|
|
}{
|
|
{
|
|
key: FormatUserInfoResponseCacheKey("randomUser"),
|
|
expectError: true,
|
|
},
|
|
},
|
|
idpClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
idpHandler: func(w http.ResponseWriter, _ *http.Request) {
|
|
userInfoBytes := `
|
|
notevenJsongarbage
|
|
`
|
|
_, err := w.Write([]byte(userInfoBytes))
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusTeapot)
|
|
},
|
|
cache: cache.NewInMemoryCache(24 * time.Hour),
|
|
cacheItems: []struct {
|
|
key string
|
|
value string
|
|
encrypt bool
|
|
}{
|
|
{
|
|
key: FormatAccessTokenCacheKey("randomUser"),
|
|
value: "FakeAccessToken",
|
|
encrypt: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "call UserInfo without accessToken in cache",
|
|
userInfoPath: "/user-info",
|
|
expectedOutput: jwt.MapClaims(nil),
|
|
expectError: true,
|
|
expectUnauthenticated: true,
|
|
expectedCacheItems: []struct {
|
|
key string
|
|
value string
|
|
expectEncrypted bool
|
|
expectError bool
|
|
}{
|
|
{
|
|
key: FormatUserInfoResponseCacheKey("randomUser"),
|
|
expectError: true,
|
|
},
|
|
},
|
|
idpClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
idpHandler: func(w http.ResponseWriter, _ *http.Request) {
|
|
userInfoBytes := `
|
|
{
|
|
"groups":["githubOrg:engineers"]
|
|
}`
|
|
w.Header().Set("content-type", "application/json")
|
|
_, err := w.Write([]byte(userInfoBytes))
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
cache: cache.NewInMemoryCache(24 * time.Hour),
|
|
},
|
|
{
|
|
name: "call UserInfo with valid accessToken in cache",
|
|
userInfoPath: "/user-info",
|
|
expectedOutput: jwt.MapClaims{"groups": []any{"githubOrg:engineers"}},
|
|
expectError: false,
|
|
expectUnauthenticated: false,
|
|
expectedCacheItems: []struct {
|
|
key string
|
|
value string
|
|
expectEncrypted bool
|
|
expectError bool
|
|
}{
|
|
{
|
|
key: FormatUserInfoResponseCacheKey("randomUser"),
|
|
value: "{\"groups\":[\"githubOrg:engineers\"]}",
|
|
expectEncrypted: true,
|
|
expectError: false,
|
|
},
|
|
},
|
|
idpClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
idpHandler: func(w http.ResponseWriter, _ *http.Request) {
|
|
userInfoBytes := `
|
|
{
|
|
"groups":["githubOrg:engineers"]
|
|
}`
|
|
w.Header().Set("content-type", "application/json")
|
|
_, err := w.Write([]byte(userInfoBytes))
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
cache: cache.NewInMemoryCache(24 * time.Hour),
|
|
cacheItems: []struct {
|
|
key string
|
|
value string
|
|
encrypt bool
|
|
}{
|
|
{
|
|
key: FormatAccessTokenCacheKey("randomUser"),
|
|
value: "FakeAccessToken",
|
|
encrypt: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "call UserInfo on separate endpoint",
|
|
userInfoPath: "/user-info",
|
|
expectedOutput: jwt.MapClaims{"groups": []any{"githubOrg:developers"}}, // response from separate idpHandlerUserInfo expected
|
|
expectError: false,
|
|
expectUnauthenticated: false,
|
|
expectedCacheItems: []struct {
|
|
key string
|
|
value string
|
|
expectEncrypted bool
|
|
expectError bool
|
|
}{
|
|
{
|
|
key: FormatUserInfoResponseCacheKey("randomUser"),
|
|
value: "{\"groups\":[\"githubOrg:developers\"]}",
|
|
expectEncrypted: true,
|
|
expectError: false,
|
|
},
|
|
},
|
|
idpClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
idpHandler: func(w http.ResponseWriter, _ *http.Request) {
|
|
userInfoBytes := `
|
|
{
|
|
"groups":["githubOrg:engineers"]
|
|
}`
|
|
w.Header().Set("content-type", "application/json")
|
|
_, err := w.Write([]byte(userInfoBytes))
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
idpHandlerUserInfo: func(w http.ResponseWriter, _ *http.Request) {
|
|
userInfoBytes := `
|
|
{
|
|
"groups":["githubOrg:developers"]
|
|
}`
|
|
w.Header().Set("content-type", "application/json")
|
|
_, err := w.Write([]byte(userInfoBytes))
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
cache: cache.NewInMemoryCache(24 * time.Hour),
|
|
cacheItems: []struct {
|
|
key string
|
|
value string
|
|
encrypt bool
|
|
}{
|
|
{
|
|
key: FormatAccessTokenCacheKey("randomUser"),
|
|
value: "FakeAccessToken",
|
|
encrypt: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var tsUserInfo *httptest.Server
|
|
if tt.idpHandlerUserInfo != nil {
|
|
tsUserInfo = httptest.NewServer(http.HandlerFunc(tt.idpHandlerUserInfo))
|
|
} else {
|
|
tsUserInfo = httptest.NewServer(http.HandlerFunc(tt.idpHandler))
|
|
}
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(tt.idpHandler))
|
|
defer ts.Close()
|
|
|
|
signature, err := util.MakeSignature(32)
|
|
require.NoError(t, err)
|
|
cdSettings := &settings.ArgoCDSettings{ServerSignature: signature}
|
|
encryptionKey, err := cdSettings.GetServerEncryptionKey()
|
|
require.NoError(t, err)
|
|
a, _ := NewClientApp(cdSettings, "", nil, "/argo-cd", tt.cache)
|
|
|
|
for _, item := range tt.cacheItems {
|
|
var newValue []byte
|
|
newValue = []byte(item.value)
|
|
if item.encrypt {
|
|
newValue, err = crypto.Encrypt([]byte(item.value), encryptionKey)
|
|
require.NoError(t, err)
|
|
}
|
|
err := a.clientCache.Set(&cache.Item{
|
|
Key: item.key,
|
|
Object: newValue,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
got, unauthenticated, err := a.GetUserInfo(t.Context(), tt.idpClaims, ts.URL, tsUserInfo.URL, tt.userInfoPath)
|
|
assert.Equal(t, tt.expectedOutput, got)
|
|
assert.Equal(t, tt.expectUnauthenticated, unauthenticated)
|
|
if tt.expectError {
|
|
require.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
for _, item := range tt.expectedCacheItems {
|
|
var tmpValue []byte
|
|
err := a.clientCache.Get(item.key, &tmpValue)
|
|
if item.expectError {
|
|
require.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
if item.expectEncrypted {
|
|
tmpValue, err = crypto.Decrypt(tmpValue, encryptionKey)
|
|
require.NoError(t, err)
|
|
}
|
|
assert.Equal(t, item.value, string(tmpValue))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetGroupsClaimFromEndpoint(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
inputClaims jwt.MapClaims // function input
|
|
cacheClaims jwt.MapClaims // userinfo response
|
|
expectedClaims jwt.MapClaims // function output
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "set correct groups from userinfo endpoint", // enriches the JWT claims with information from the userinfo endpoint, default case
|
|
inputClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
cacheClaims: jwt.MapClaims{"sub": "randomUser", "groups": []string{"githubOrg:example"}, "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
expectedClaims: jwt.MapClaims{"sub": "randomUser", "groups": []any{"githubOrg:example"}, "exp": float64(time.Now().Add(5 * time.Minute).Unix())}, // the groups must be of type any since the response we get was parsed by GetUserInfo and we don't yet know the type of the groups claim
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "return error for wrong userinfo claims returned", // when there's an error in this feature, the claims should be untouched for the rest to still proceed
|
|
inputClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
cacheClaims: jwt.MapClaims{"sub": "wrongUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
expectedClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "override groups already defined in input claims", // this is expected behavior since input claims might have been truncated (HTTP header 4K limit)
|
|
inputClaims: jwt.MapClaims{"sub": "randomUser", "groups": []string{"groupfromjwt"}, "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
cacheClaims: jwt.MapClaims{"sub": "randomUser", "groups": []string{"superusers", "usergroup", "support-group"}, "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
expectedClaims: jwt.MapClaims{"sub": "randomUser", "groups": []any{"superusers", "usergroup", "support-group"}, "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "empty cache and non-rechable userinfo endpoint", // this will try to reach the userinfo endpoint defined in the test and fail
|
|
inputClaims: jwt.MapClaims{"sub": "randomUser", "groups": []string{"groupfromjwt"}, "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
cacheClaims: nil, // the test doesn't set the cache for an empty object
|
|
expectedClaims: jwt.MapClaims{"sub": "randomUser", "groups": []string{"groupfromjwt"}, "exp": float64(time.Now().Add(5 * time.Minute).Unix())},
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// create the ClientApp
|
|
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: /`,
|
|
}
|
|
a, err := NewClientApp(cdSettings, "", nil, "/argo-cd", userInfoCache)
|
|
require.NoError(t, err, "failed creating clientapp")
|
|
|
|
// prepoluate cache to predict what the GetUserInfo function will return to the SetGroupsClaimFromEndpoint function (without having to mock the userinfo response)
|
|
encryptionKey, err := cdSettings.GetServerEncryptionKey()
|
|
require.NoError(t, err, "failed obtaining encryption key from settings")
|
|
|
|
// set fake accessToken for function to not return early
|
|
encAccessToken, err := crypto.Encrypt([]byte("123456"), encryptionKey)
|
|
require.NoError(t, err, "failed encrypting dummy access token")
|
|
err = a.clientCache.Set(&cache.Item{
|
|
Key: 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 (GetUserInfo has a separate test, here we focus on SetUserInfoGroups)
|
|
if tt.cacheClaims != nil {
|
|
cacheClaims, err := json.Marshal(tt.cacheClaims)
|
|
require.NoError(t, err)
|
|
encCacheClaims, err := crypto.Encrypt([]byte(cacheClaims), encryptionKey)
|
|
require.NoError(t, err, "failed encrypting dummy access token")
|
|
err = a.clientCache.Set(&cache.Item{
|
|
Key: FormatUserInfoResponseCacheKey("randomUser"),
|
|
Object: encCacheClaims,
|
|
})
|
|
require.NoError(t, err, "failed setting item to in-memory cache")
|
|
}
|
|
|
|
receivedClaims, err := a.SetGroupsClaimFromEndpoint(t.Context(), tt.inputClaims, "argocd")
|
|
if tt.expectError {
|
|
require.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
assert.Equal(t, tt.expectedClaims, receivedClaims) // check that the claims were successfully enriched with what we expect
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetOidcTokenCacheFromJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
oidcTokenCache *OidcTokenCache
|
|
expectErrorContains string
|
|
expectIdToken string
|
|
}{
|
|
{
|
|
name: "empty",
|
|
oidcTokenCache: &OidcTokenCache{},
|
|
expectErrorContains: "empty token",
|
|
},
|
|
{
|
|
name: "empty id token",
|
|
oidcTokenCache: &OidcTokenCache{
|
|
Token: &oauth2.Token{},
|
|
},
|
|
expectIdToken: "",
|
|
},
|
|
{
|
|
name: "simple",
|
|
oidcTokenCache: NewOidcTokenCache("", (&oauth2.Token{}).WithExtra(map[string]any{"id_token": "simple"}), time.Time{}),
|
|
expectIdToken: "simple",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tokenJSON, err := json.Marshal(tt.oidcTokenCache)
|
|
require.NoError(t, err)
|
|
token, err := GetOidcTokenCacheFromJSON(tokenJSON)
|
|
if tt.expectErrorContains != "" {
|
|
assert.ErrorContains(t, err, tt.expectErrorContains)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
if tt.expectIdToken != "" {
|
|
assert.Equal(t, tt.expectIdToken, token.Token.Extra("id_token").(string))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClientApp_GetTokenSourceFromCache(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
oidcTokenCache *OidcTokenCache
|
|
expectErrorContains string
|
|
provider Provider
|
|
}{
|
|
{
|
|
name: "provider error",
|
|
oidcTokenCache: &OidcTokenCache{},
|
|
expectErrorContains: "fake provider endpoint error",
|
|
provider: &fakeProvider{
|
|
EndpointError: true,
|
|
},
|
|
},
|
|
{
|
|
name: "empty oidcTokenCache",
|
|
expectErrorContains: "oidcTokenCache is required",
|
|
provider: &fakeProvider{},
|
|
},
|
|
{
|
|
name: "simple",
|
|
oidcTokenCache: NewOidcTokenCache("", (&oauth2.Token{}).WithExtra(map[string]any{"id_token": "simple"}), time.Time{}),
|
|
provider: &fakeProvider{},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
app := ClientApp{provider: tt.provider, settings: &settings.ArgoCDSettings{}}
|
|
tokenSource, err := app.GetTokenSourceFromCache(t.Context(), tt.oidcTokenCache)
|
|
if tt.expectErrorContains != "" {
|
|
assert.ErrorContains(t, err, tt.expectErrorContains)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, tokenSource)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClientApp_GetUpdatedOidcTokenFromCache(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
subject string
|
|
session string
|
|
insertIntoCache bool
|
|
oidcTokenCache *OidcTokenCache
|
|
expectErrorContains string
|
|
expectTokenNotNil bool
|
|
}{
|
|
{
|
|
name: "empty token cache",
|
|
subject: "alice",
|
|
session: "111",
|
|
insertIntoCache: true,
|
|
expectErrorContains: "failed to unmarshal cached oidc token: empty token",
|
|
},
|
|
{
|
|
name: "no refresh token",
|
|
subject: "alice",
|
|
session: "111",
|
|
insertIntoCache: true,
|
|
oidcTokenCache: &OidcTokenCache{Token: &oauth2.Token{}},
|
|
expectErrorContains: "failed to refresh token from source: oauth2: token expired and refresh token is not set",
|
|
},
|
|
{
|
|
name: "cache miss",
|
|
subject: "",
|
|
session: "",
|
|
insertIntoCache: false,
|
|
},
|
|
{
|
|
name: "updated token from cache",
|
|
subject: "alice",
|
|
session: "111",
|
|
insertIntoCache: true,
|
|
oidcTokenCache: &OidcTokenCache{Token: &oauth2.Token{
|
|
RefreshToken: "not empty",
|
|
}},
|
|
expectTokenNotNil: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
oidcTestServer := test.GetOIDCTestServer(t, nil)
|
|
t.Cleanup(oidcTestServer.Close)
|
|
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
OIDCConfigRAW: fmt.Sprintf(`
|
|
name: Test
|
|
issuer: %s
|
|
clientID: test-client-id
|
|
clientSecret: test-client-secret
|
|
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
|
OIDCTLSInsecureSkipVerify: true,
|
|
UserSessionDuration: 24 * time.Hour,
|
|
}
|
|
app, err := NewClientApp(cdSettings, "", nil, "/", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
if tt.insertIntoCache {
|
|
oidcTokenCacheJSON, err := json.Marshal(tt.oidcTokenCache)
|
|
require.NoError(t, err)
|
|
require.NoError(t, app.SetValueInEncryptedCache(t.Context(), formatOidcTokenCacheKey(tt.subject, tt.session), oidcTokenCacheJSON, time.Minute))
|
|
}
|
|
token, err := app.GetUpdatedOidcTokenFromCache(t.Context(), tt.subject, tt.session)
|
|
if tt.expectErrorContains != "" {
|
|
assert.ErrorContains(t, err, tt.expectErrorContains)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
if tt.expectTokenNotNil {
|
|
assert.NotNil(t, token)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSessionRemainingTTL(t *testing.T) {
|
|
const dur = 10 * time.Minute
|
|
|
|
t.Run("zero session start returns original full duration", func(t *testing.T) {
|
|
ttl := sessionRemainingTTL(time.Time{}, dur)
|
|
assert.Equal(t, dur, ttl)
|
|
})
|
|
|
|
t.Run("active session returns remaining time", func(t *testing.T) {
|
|
sessionStart := time.Now().Add(-4 * time.Minute)
|
|
ttl := sessionRemainingTTL(sessionStart, dur)
|
|
// ~6 min remaining; accept ±5s of test jitter
|
|
assert.InDelta(t, (6 * time.Minute).Seconds(), ttl.Seconds(), 5)
|
|
})
|
|
|
|
t.Run("expired session returns zero", func(t *testing.T) {
|
|
sessionStart := time.Now().Add(-11 * time.Minute)
|
|
ttl := sessionRemainingTTL(sessionStart, dur)
|
|
assert.Equal(t, time.Duration(0), ttl)
|
|
})
|
|
}
|
|
|
|
func TestClientApp_GetUpdatedOidcTokenFromCache_SessionCeiling(t *testing.T) {
|
|
// 10 minute UserSessionDuration, 9 minutes of current session already passed
|
|
const sessionDuration = 10 * time.Minute
|
|
sessionStart := time.Now().Add(-9 * time.Minute)
|
|
|
|
oidcTestServer := test.GetOIDCTestServer(t, nil)
|
|
t.Cleanup(oidcTestServer.Close)
|
|
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
OIDCConfigRAW: fmt.Sprintf(`
|
|
name: Test
|
|
issuer: %s
|
|
clientID: test-client-id
|
|
clientSecret: test-client-secret
|
|
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
|
OIDCTLSInsecureSkipVerify: true,
|
|
UserSessionDuration: sessionDuration,
|
|
}
|
|
app, err := NewClientApp(cdSettings, "", nil, "/", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
sub, sid := "alice", "s1"
|
|
oidcTokenCacheJSON, err := json.Marshal(&OidcTokenCache{
|
|
Token: &oauth2.Token{RefreshToken: "not empty"},
|
|
SessionStart: sessionStart,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, app.SetValueInEncryptedCache(t.Context(), formatOidcTokenCacheKey(sub, sid), oidcTokenCacheJSON, sessionDuration))
|
|
|
|
_, err = app.GetUpdatedOidcTokenFromCache(t.Context(), sub, sid)
|
|
require.NoError(t, err)
|
|
|
|
// Retrieve the updated entry and confirm SessionStart was not reset to now.
|
|
updatedJSON, err := app.GetValueFromEncryptedCache(t.Context(), formatOidcTokenCacheKey(sub, sid))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, updatedJSON, "cache entry should exist after refresh")
|
|
|
|
updatedCache, err := GetOidcTokenCacheFromJSON(updatedJSON)
|
|
require.NoError(t, err)
|
|
|
|
// TTL should be less than 1 minute after token refresh
|
|
assert.WithinDuration(t, sessionStart, updatedCache.SessionStart, 5*time.Second,
|
|
"SessionStart must be preserved across token refreshes")
|
|
}
|
|
|
|
func TestClientApp_GetUpdatedOidcTokenFromCache_SessionCeilingExceeded(t *testing.T) {
|
|
const sessionDuration = 10 * time.Minute
|
|
sessionStart := time.Now().Add(-11 * time.Minute)
|
|
|
|
oidcTestServer := test.GetOIDCTestServer(t, nil)
|
|
t.Cleanup(oidcTestServer.Close)
|
|
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
OIDCConfigRAW: fmt.Sprintf(`
|
|
name: Test
|
|
issuer: %s
|
|
clientID: test-client-id
|
|
clientSecret: test-client-secret
|
|
requestedScopes: ["oidc"]`, oidcTestServer.URL),
|
|
OIDCTLSInsecureSkipVerify: true,
|
|
UserSessionDuration: sessionDuration,
|
|
}
|
|
app, err := NewClientApp(cdSettings, "", nil, "/", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
sub, sid := "alice", "s1"
|
|
cacheKey := formatOidcTokenCacheKey(sub, sid)
|
|
originalJSON, err := json.Marshal(&OidcTokenCache{
|
|
Token: &oauth2.Token{RefreshToken: "not empty"},
|
|
SessionStart: sessionStart,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, app.SetValueInEncryptedCache(t.Context(), cacheKey, originalJSON, time.Minute))
|
|
|
|
token, err := app.GetUpdatedOidcTokenFromCache(t.Context(), sub, sid)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, token, "expired session must not yield a refreshed token")
|
|
|
|
// The refreshed token must not overwrite the cache entry with the default TTL
|
|
cachedValue, err := app.GetValueFromEncryptedCache(t.Context(), cacheKey)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, string(originalJSON), string(cachedValue),
|
|
"expired session must not be re-cached with the cache's default TTL")
|
|
}
|
|
|
|
func TestClientApp_CheckAndGetRefreshToken(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expectErrorContains string
|
|
expectNewToken bool
|
|
groupClaims jwt.MapClaims
|
|
refreshTokenThreshold string
|
|
}{
|
|
{
|
|
name: "no new token",
|
|
groupClaims: jwt.MapClaims{
|
|
"aud": common.ArgoCDClientAppID,
|
|
"exp": float64(time.Now().Add(time.Hour).Unix()),
|
|
"sub": "randomUser",
|
|
"sid": "1111",
|
|
"iss": "issuer",
|
|
"groups": "group1",
|
|
},
|
|
expectNewToken: false,
|
|
refreshTokenThreshold: "1m",
|
|
},
|
|
{
|
|
name: "new token",
|
|
groupClaims: jwt.MapClaims{
|
|
"aud": common.ArgoCDClientAppID,
|
|
"exp": float64(time.Now().Add(55 * time.Second).Unix()),
|
|
"sub": "randomUser",
|
|
"sid": "1111",
|
|
"iss": "issuer",
|
|
"groups": "group1",
|
|
},
|
|
expectNewToken: true,
|
|
refreshTokenThreshold: "1m",
|
|
},
|
|
{
|
|
name: "parse error",
|
|
groupClaims: jwt.MapClaims{
|
|
"aud": common.ArgoCDClientAppID,
|
|
"exp": float64(time.Now().Add(time.Minute).Unix()),
|
|
"sub": "randomUser",
|
|
"sid": "1111",
|
|
"iss": "issuer",
|
|
"groups": "group1",
|
|
},
|
|
expectNewToken: false,
|
|
refreshTokenThreshold: "1xx",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
oidcTestServer := test.GetOIDCTestServer(t, nil)
|
|
t.Cleanup(oidcTestServer.Close)
|
|
|
|
cdSettings := &settings.ArgoCDSettings{
|
|
URL: "https://argocd.example.com",
|
|
OIDCConfigRAW: fmt.Sprintf(`
|
|
name: Test
|
|
issuer: %s
|
|
clientID: test-client-id
|
|
clientSecret: test-client-secret
|
|
refreshTokenThreshold: %s
|
|
requestedScopes: ["oidc"]`, oidcTestServer.URL, tt.refreshTokenThreshold),
|
|
OIDCTLSInsecureSkipVerify: true,
|
|
UserSessionDuration: 24 * time.Hour,
|
|
}
|
|
// The base href (the last argument for NewClientApp) is what HandleLogin will fall back to when no explicit
|
|
// redirect URL is given.
|
|
app, err := NewClientApp(cdSettings, "", nil, "/", cache.NewInMemoryCache(24*time.Hour))
|
|
require.NoError(t, err)
|
|
oidcTokenCacheJSON, err := json.Marshal(&OidcTokenCache{Token: &oauth2.Token{
|
|
RefreshToken: "not empty",
|
|
}})
|
|
require.NoError(t, err)
|
|
sub := jwtutil.StringField(tt.groupClaims, "sub")
|
|
require.NotEmpty(t, sub)
|
|
sid := jwtutil.StringField(tt.groupClaims, "sid")
|
|
require.NotEmpty(t, sid)
|
|
require.NoError(t, app.SetValueInEncryptedCache(t.Context(), formatOidcTokenCacheKey(sub, sid), oidcTokenCacheJSON, time.Minute))
|
|
token, err := app.CheckAndRefreshToken(t.Context(), tt.groupClaims, cdSettings.RefreshTokenThreshold())
|
|
if tt.expectErrorContains != "" {
|
|
require.ErrorContains(t, err, tt.expectErrorContains)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
if tt.expectNewToken {
|
|
require.NotEmpty(t, token)
|
|
} else {
|
|
require.Empty(t, token)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClientApp_getRedirectURIForRequest(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
req *http.Request
|
|
expectLogContains string
|
|
expectedRequestURI string
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "empty",
|
|
req: &http.Request{
|
|
URL: &url.URL{},
|
|
},
|
|
},
|
|
{
|
|
name: "nil URL",
|
|
expectLogContains: "falling back to configured redirect URI",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
app := ClientApp{provider: &fakeProvider{}, settings: &settings.ArgoCDSettings{}}
|
|
hook := test.LogHook{}
|
|
log.AddHook(&hook)
|
|
t.Cleanup(func() {
|
|
log.StandardLogger().ReplaceHooks(log.LevelHooks{})
|
|
})
|
|
redirectURI := app.getRedirectURIForRequest(tt.req)
|
|
if tt.expectLogContains != "" {
|
|
assert.NotEmpty(t, hook.GetRegexMatchesInEntries(tt.expectLogContains), "expected log")
|
|
} else {
|
|
assert.Empty(t, hook.Entries, "expected log")
|
|
}
|
|
if tt.req == nil {
|
|
return
|
|
}
|
|
expectedRedirectURI, err := app.settings.RedirectURLForRequest(tt.req)
|
|
if tt.expectError {
|
|
assert.Error(t, err)
|
|
return
|
|
}
|
|
assert.Equal(t, expectedRedirectURI, redirectURI, "expected URI")
|
|
})
|
|
}
|
|
}
|