Compare commits

...

13 Commits

Author SHA1 Message Date
argo-bot
e5f1194a6d Bump version to 2.5.9 2023-01-27 23:14:41 +00:00
argo-bot
ef4f103ee8 Bump version to 2.5.9 2023-01-27 23:14:35 +00:00
Eugen Friedland
1925612f1b fix(health): Handling SparkApplication CRD health status if dynamic allocation is enabled (#7557) (#11522)
Signed-off-by: Yevgeniy Fridland <yevg.mord@gmail.com>
Co-authored-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>
2023-01-27 15:17:20 -05:00
Michael Crenshaw
86f75b05ea fix: add CLI client IDs to default OIDC allowed audiences (#12170) (#12179)
* fix(settings): add CLI client ID in default OAuth2 allowed audiences

Signed-off-by: Yann Soubeyrand <yann.soubeyrand@camptocamp.com>

* fix: add CLI client IDs to default OIDC allowed audiences (#12170)

Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>

* docs

Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>

* test

Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>

* handle expired token properly

Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>

---------

Signed-off-by: Yann Soubeyrand <yann.soubeyrand@camptocamp.com>
Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>
Co-authored-by: Yann Soubeyrand <yann.soubeyrand@camptocamp.com>
2023-01-27 14:43:09 -05:00
Leonardo Luz Almeida
35546fd856 chore: Refactor terminal handler to use auth-middleware (#12052)
* chore: Refactor terminal handler to use auth-middleware

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* remove context key for now

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* implement unit-tests

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* remove claim valid check for now

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* remove unnecessary test

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* fix lint

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* don't too much details in http response

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* Fix error

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* Fix lint

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* trigger build

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* builder pattern in terminal feature-flag middleware

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>
2023-01-27 13:25:34 -05:00
Ian Delahorne
9ce407fec0 fix: Add namespace to sub-application link URLs (#11946)
Signed-off-by: Ian Delahorne <ian@patreon.com>
Co-authored-by: Remington Breeze <remington@breeze.software>
2023-01-27 13:19:36 -05:00
argo-bot
bbe870ff59 Bump version to 2.5.8 2023-01-25 16:01:15 +00:00
argo-bot
af321b8ff3 Bump version to 2.5.8 2023-01-25 16:01:01 +00:00
Dan Garfield
50b9f19d3c Merge pull request from GHSA-q9hr-j4rf-8fjc
* fix: verify audience claim

Co-Authored-By: Vladimir Pouzanov <farcaller@gmail.com>
Signed-off-by: CI <350466+crenshaw-dev@users.noreply.github.com>

* fix lint

Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>

* go mod tidy

Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>

* handle single aud claim marshaled as a string

Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>

Signed-off-by: CI <350466+crenshaw-dev@users.noreply.github.com>
Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>
Co-authored-by: CI <350466+crenshaw-dev@users.noreply.github.com>
Co-authored-by: Vladimir Pouzanov <farcaller@gmail.com>
2023-01-25 09:15:04 -05:00
Dan Garfield
1f82078e74 Merge pull request from GHSA-6p4m-hw2h-6gmw
Signed-off-by: ChangZhuo Chen (陳昌倬) <czchen@czchen.org>

add test

Signed-off-by: CI <350466+crenshaw-dev@users.noreply.github.com>

better comment

Signed-off-by: CI <350466+crenshaw-dev@users.noreply.github.com>

Signed-off-by: CI <350466+crenshaw-dev@users.noreply.github.com>
Co-authored-by: ChangZhuo Chen (陳昌倬) <czchen@czchen.org>
2023-01-25 09:14:29 -05:00
Mike Bryant
37c08332d5 fix: Support resource actions for apps in different Namespace (#12115)
Signed-off-by: Mike Bryant <mike.bryant@mettle.co.uk>

Signed-off-by: Mike Bryant <mike.bryant@mettle.co.uk>
2023-01-24 17:32:54 -05:00
Justin Marquis
a94ff15ebd chore: disable docker sbom and attestations (#12059)
Signed-off-by: Justin Marquis <34fathombelow@protonmail.com>

Signed-off-by: Justin Marquis <34fathombelow@protonmail.com>
2023-01-20 10:03:22 -05:00
Nicholas Morey
4bd9f36182 docs: clarify value for disabling tools (#11395)
* docs: clarify value for disabling tools

Although it is implied to set the value for the key to `false`, this explicitly states it to add clarity. Along with some wording changes.

Signed-off-by: Nicholas Morey <nicholas@morey.tech>

* docs: add use-case for disabling tools

Co-authored-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>
Signed-off-by: Nicholas Morey <nicholas@morey.tech>

Signed-off-by: Nicholas Morey <nicholas@morey.tech>
Co-authored-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>
2023-01-18 16:46:48 -05:00
40 changed files with 1325 additions and 154 deletions

View File

@@ -61,7 +61,7 @@ jobs:
IMAGE_PLATFORMS=linux/amd64,linux/arm64,linux/s390x,linux/ppc64le
fi
echo "Building image for platforms: $IMAGE_PLATFORMS"
docker buildx build --platform $IMAGE_PLATFORMS --push="${{ github.event_name == 'push' }}" \
docker buildx build --platform $IMAGE_PLATFORMS --sbom=false --provenance=false --push="${{ github.event_name == 'push' }}" \
-t ghcr.io/argoproj/argocd:${{ steps.image.outputs.tag }} \
-t quay.io/argoproj/argocd:latest .
working-directory: ./src/github.com/argoproj/argo-cd

View File

@@ -207,7 +207,7 @@ jobs:
set -ue
git clean -fd
mkdir -p dist/
docker buildx build --platform linux/amd64,linux/arm64,linux/s390x,linux/ppc64le --push -t ${IMAGE_NAMESPACE}/argocd:v${TARGET_VERSION} -t argoproj/argocd:v${TARGET_VERSION} .
docker buildx build --platform linux/amd64,linux/arm64,linux/s390x,linux/ppc64le --sbom=false --provenance=false --push -t ${IMAGE_NAMESPACE}/argocd:v${TARGET_VERSION} -t argoproj/argocd:v${TARGET_VERSION} .
make release-cli
make checksums
chmod +x ./dist/argocd-linux-amd64

View File

@@ -155,6 +155,7 @@ Currently, the following organizations are **officially** using Argo CD:
1. [Packlink](https://www.packlink.com/)
1. [Pandosearch](https://www.pandosearch.com/en/home)
1. [PagerDuty](https://www.pagerduty.com/)
1. [Patreon](https://www.patreon.com/)
1. [PayPay](https://paypay.ne.jp/)
1. [Peloton Interactive](https://www.onepeloton.com/)
1. [Pipefy](https://www.pipefy.com/)

View File

@@ -1 +1 @@
2.5.7
2.5.9

View File

@@ -206,7 +206,7 @@ var validatorsByGroup = map[string]settingValidator{
}
ssoProvider = "Dex"
} else if general.OIDCConfigRAW != "" {
if _, err := settings.UnmarshalOIDCConfig(general.OIDCConfigRAW); err != nil {
if err := settings.ValidateOIDCConfig(general.OIDCConfigRAW); err != nil {
return "", fmt.Errorf("invalid oidc.config: %v", err)
}
ssoProvider = "OIDC"

View File

@@ -1,6 +1,7 @@
package common
import (
"errors"
"os"
"path/filepath"
"strconv"
@@ -316,3 +317,8 @@ const (
SecurityMedium = 2 // Could indicate malicious events, but has a high likelihood of being user/system error (i.e. access denied)
SecurityLow = 1 // Unexceptional entries (i.e. successful access logs)
)
// Common error messages
const TokenVerificationError = "failed to verify the token"
var TokenVerificationErr = errors.New(TokenVerificationError)

View File

@@ -335,7 +335,7 @@ func (ctrl *ApplicationController) handleObjectUpdated(managedByApp map[string]b
}
if !ctrl.canProcessApp(obj) {
// Don't force refresh app if app belongs to a different controller shard
// Don't force refresh app if app belongs to a different controller shard or is outside the allowed namespaces.
continue
}
@@ -1719,6 +1719,13 @@ func (ctrl *ApplicationController) canProcessApp(obj interface{}) bool {
if !ok {
return false
}
// Only process given app if it exists in a watched namespace, or in the
// control plane's namespace.
if app.Namespace != ctrl.namespace && !glob.MatchStringInList(ctrl.applicationNamespaces, app.Namespace, false) {
return false
}
if ctrl.clusterFilter != nil {
cluster, err := ctrl.db.GetCluster(context.Background(), app.Spec.Destination.Server)
if err != nil {
@@ -1727,12 +1734,6 @@ func (ctrl *ApplicationController) canProcessApp(obj interface{}) bool {
return ctrl.clusterFilter(cluster)
}
// Only process given app if it exists in a watched namespace, or in the
// control plane's namespace.
if app.Namespace != ctrl.namespace && !glob.MatchStringInList(ctrl.applicationNamespaces, app.Namespace, false) {
return false
}
return true
}

View File

@@ -1373,3 +1373,31 @@ func TestToAppKey(t *testing.T) {
})
}
}
func Test_canProcessApp(t *testing.T) {
app := newFakeApp()
ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}})
ctrl.applicationNamespaces = []string{"good"}
t.Run("without cluster filter, good namespace", func(t *testing.T) {
app.Namespace = "good"
canProcess := ctrl.canProcessApp(app)
assert.True(t, canProcess)
})
t.Run("without cluster filter, bad namespace", func(t *testing.T) {
app.Namespace = "bad"
canProcess := ctrl.canProcessApp(app)
assert.False(t, canProcess)
})
t.Run("with cluster filter, good namespace", func(t *testing.T) {
app.Namespace = "good"
ctrl.clusterFilter = func(_ *argoappv1.Cluster) bool { return true }
canProcess := ctrl.canProcessApp(app)
assert.True(t, canProcess)
})
t.Run("with cluster filter, bad namespace", func(t *testing.T) {
app.Namespace = "bad"
ctrl.clusterFilter = func(_ *argoappv1.Cluster) bool { return true }
canProcess := ctrl.canProcessApp(app)
assert.False(t, canProcess)
})
}

View File

@@ -301,6 +301,19 @@ data:
issuer: https://dev-123456.oktapreview.com
clientID: aaaabbbbccccddddeee
clientSecret: $oidc.okta.clientSecret
# Optional list of allowed aud claims. If omitted or empty, defaults to the clientID value above (and the
# cliCientID, if that is also specified). If you specify a list and want the clientID to be allowed, you must
# explicitly include it in the list.
# Token verification will pass if any of the token's audiences matches any of the audiences in this list.
allowedAudiences:
- aaaabbbbccccddddeee
- qqqqwwwweeeerrrrttt
# Optional. If false, tokens without an audience will always fail validation. If true, tokens without an audience
# will always pass validation.
# Defaults to true for Argo CD < 2.6.0. Defaults to false for Argo CD >= 2.6.0.
skipAudienceCheckWhenTokenHasNoAudience: true
# Optional set of OIDC scopes to request. If omitted, defaults to: ["openid", "profile", "email", "groups"]
requestedScopes: ["openid", "profile", "email", "groups"]

View File

@@ -33,9 +33,11 @@ Otherwise it is assumed to be a plain **directory** application.
## Disable built-in tools
Optionally built-in config management tools might be disabled. In order to disable the tool add one of the following
keys to the `argocd-cm` ConfigMap: `kustomize.enable`, `helm.enable` or `jsonnet.enable`. Once the
tool is disabled Argo CD will assume the application target directory contains plain Kubernetes YAML manifests.
Built-in config management tools can be optionally disabled by setting one of the following
keys, in the `argocd-cm` ConfigMap, to `false`: `kustomize.enable`, `helm.enable` or `jsonnet.enable`. Once the
tool is disabled, Argo CD will assume the application target directory contains plain Kubernetes YAML manifests.
Disabling unused config management tools can be a helpful security enhancement. Vulnerabilities are sometimes limited to certain config management tools. Even if there is no vulnerability, an attacker may use a certain tool to take advantage of a misconfiguration in an Argo CD instance. Disabling unused config management tools limits the tools available to malicious actors.
## References

2
go.mod
View File

@@ -101,7 +101,7 @@ require (
require (
github.com/gfleury/go-bitbucket-v1 v0.0.0-20220301131131-8e7ed04b843e
github.com/stretchr/objx v0.3.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0
k8s.io/apiserver v0.24.2
)

View File

@@ -5,7 +5,7 @@ kind: Kustomization
images:
- name: quay.io/argoproj/argocd
newName: quay.io/argoproj/argocd
newTag: v2.5.7
newTag: v2.5.9
resources:
- ./application-controller
- ./dex

View File

@@ -9635,7 +9635,7 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -9893,7 +9893,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -9944,7 +9944,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /var/run/argocd/argocd-cmp-server
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -10151,7 +10151,7 @@ spec:
key: application.namespaces
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -12,4 +12,4 @@ resources:
images:
- name: quay.io/argoproj/argocd
newName: quay.io/argoproj/argocd
newTag: v2.5.7
newTag: v2.5.9

View File

@@ -11,7 +11,7 @@ patchesStrategicMerge:
images:
- name: quay.io/argoproj/argocd
newName: quay.io/argoproj/argocd
newTag: v2.5.7
newTag: v2.5.9
resources:
- ../../base/application-controller
- ../../base/applicationset-controller

View File

@@ -10836,7 +10836,7 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -10946,7 +10946,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -10999,7 +10999,7 @@ spec:
containers:
- command:
- argocd-notifications
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -11296,7 +11296,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -11347,7 +11347,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /var/run/argocd/argocd-cmp-server
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -11620,7 +11620,7 @@ spec:
key: application.namespaces
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -11855,7 +11855,7 @@ spec:
key: application.namespaces
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -1502,7 +1502,7 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -1612,7 +1612,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -1665,7 +1665,7 @@ spec:
containers:
- command:
- argocd-notifications
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -1962,7 +1962,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -2013,7 +2013,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /var/run/argocd/argocd-cmp-server
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -2286,7 +2286,7 @@ spec:
key: application.namespaces
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -2521,7 +2521,7 @@ spec:
key: application.namespaces
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -9955,7 +9955,7 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -10065,7 +10065,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -10118,7 +10118,7 @@ spec:
containers:
- command:
- argocd-notifications
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -10371,7 +10371,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -10422,7 +10422,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /var/run/argocd/argocd-cmp-server
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -10691,7 +10691,7 @@ spec:
key: application.namespaces
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -10924,7 +10924,7 @@ spec:
key: application.namespaces
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -621,7 +621,7 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -731,7 +731,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -784,7 +784,7 @@ spec:
containers:
- command:
- argocd-notifications
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -1037,7 +1037,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -1088,7 +1088,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /var/run/argocd/argocd-cmp-server
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -1357,7 +1357,7 @@ spec:
key: application.namespaces
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -1590,7 +1590,7 @@ spec:
key: application.namespaces
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v2.5.7
image: quay.io/argoproj/argocd:v2.5.9
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -1,4 +1,55 @@
health_status = {}
-- Can't use standard lib, math.huge equivalent
infinity = 2^1024-1
local function executor_range_api()
min_executor_instances = 0
max_executor_instances = infinity
if obj.spec.dynamicAllocation.maxExecutors then
max_executor_instances = obj.spec.dynamicAllocation.maxExecutors
end
if obj.spec.dynamicAllocation.minExecutors then
min_executor_instances = obj.spec.dynamicAllocation.minExecutors
end
return min_executor_instances, max_executor_instances
end
local function maybe_executor_range_spark_conf()
min_executor_instances = 0
max_executor_instances = infinity
if obj.spec.sparkConf["spark.streaming.dynamicAllocation.enabled"] ~= nil and
obj.spec.sparkConf["spark.streaming.dynamicAllocation.enabled"] == "true" then
if(obj.spec.sparkConf["spark.streaming.dynamicAllocation.maxExecutors"] ~= nil) then
max_executor_instances = tonumber(obj.spec.sparkConf["spark.streaming.dynamicAllocation.maxExecutors"])
end
if(obj.spec.sparkConf["spark.streaming.dynamicAllocation.minExecutors"] ~= nil) then
min_executor_instances = tonumber(obj.spec.sparkConf["spark.streaming.dynamicAllocation.minExecutors"])
end
return min_executor_instances, max_executor_instances
elseif obj.spec.sparkConf["spark.dynamicAllocation.enabled"] ~= nil and
obj.spec.sparkConf["spark.dynamicAllocation.enabled"] == "true" then
if(obj.spec.sparkConf["spark.dynamicAllocation.maxExecutors"] ~= nil) then
max_executor_instances = tonumber(obj.spec.sparkConf["spark.dynamicAllocation.maxExecutors"])
end
if(obj.spec.sparkConf["spark.dynamicAllocation.minExecutors"] ~= nil) then
min_executor_instances = tonumber(obj.spec.sparkConf["spark.dynamicAllocation.minExecutors"])
end
return min_executor_instances, max_executor_instances
else
return nil
end
end
local function maybe_executor_range()
if obj.spec["dynamicAllocation"] and obj.spec.dynamicAllocation.enabled then
return executor_range_api()
elseif obj.spec["sparkConf"] ~= nil then
return maybe_executor_range_spark_conf()
else
return nil
end
end
if obj.status ~= nil then
if obj.status.applicationState.state ~= nil then
if obj.status.applicationState.state == "" then
@@ -19,6 +70,13 @@ if obj.status ~= nil then
health_status.status = "Healthy"
health_status.message = "SparkApplication is Running"
return health_status
elseif maybe_executor_range() then
min_executor_instances, max_executor_instances = maybe_executor_range()
if count >= min_executor_instances and count <= max_executor_instances then
health_status.status = "Healthy"
health_status.message = "SparkApplication is Running"
return health_status
end
end
end
end
@@ -72,4 +130,4 @@ if obj.status ~= nil then
end
health_status.status = "Progressing"
health_status.message = "Waiting for Executor pods"
return health_status
return health_status

View File

@@ -11,3 +11,15 @@ tests:
status: Healthy
message: "SparkApplication is Running"
inputPath: testdata/healthy.yaml
- healthStatus:
status: Healthy
message: "SparkApplication is Running"
inputPath: testdata/healthy_dynamic_alloc.yaml
- healthStatus:
status: Healthy
message: "SparkApplication is Running"
inputPath: testdata/healthy_dynamic_alloc_dstream.yaml
- healthStatus:
status: Healthy
message: "SparkApplication is Running"
inputPath: testdata/healthy_dynamic_alloc_operator_api.yaml

View File

@@ -0,0 +1,37 @@
apiVersion: sparkoperator.k8s.io/v1beta2
kind: SparkApplication
metadata:
generation: 4
labels:
argocd.argoproj.io/instance: spark-job
name: spark-job-app
namespace: spark-cluster
resourceVersion: "31812990"
uid: bfee52b0-74ca-4465-8005-f6643097ed64
spec:
executor:
instances: 4
sparkConf:
spark.dynamicAllocation.enabled: 'true'
spark.dynamicAllocation.maxExecutors: '10'
spark.dynamicAllocation.minExecutors: '2'
status:
applicationState:
state: RUNNING
driverInfo:
podName: ingestion-datalake-news-app-driver
webUIAddress: 172.20.207.161:4040
webUIPort: 4040
webUIServiceName: ingestion-datalake-news-app-ui-svc
executionAttempts: 13
executorState:
ingestion-datalake-news-app-1591613851251-exec-1: RUNNING
ingestion-datalake-news-app-1591613851251-exec-2: RUNNING
ingestion-datalake-news-app-1591613851251-exec-4: RUNNING
ingestion-datalake-news-app-1591613851251-exec-5: RUNNING
ingestion-datalake-news-app-1591613851251-exec-6: RUNNING
lastSubmissionAttemptTime: "2020-06-08T10:57:32Z"
sparkApplicationId: spark-a5920b2a5aa04d22a737c60759b5bf82
submissionAttempts: 1
submissionID: 3e713ec8-9f6c-4e78-ac28-749797c846f0
terminationTime: null

View File

@@ -0,0 +1,35 @@
apiVersion: sparkoperator.k8s.io/v1beta2
kind: SparkApplication
metadata:
generation: 4
labels:
argocd.argoproj.io/instance: spark-job
name: spark-job-app
namespace: spark-cluster
resourceVersion: "31812990"
uid: bfee52b0-74ca-4465-8005-f6643097ed64
spec:
executor:
instances: 4
sparkConf:
spark.streaming.dynamicAllocation.enabled: 'true'
spark.streaming.dynamicAllocation.maxExecutors: '10'
spark.streaming.dynamicAllocation.minExecutors: '2'
status:
applicationState:
state: RUNNING
driverInfo:
podName: ingestion-datalake-news-app-driver
webUIAddress: 172.20.207.161:4040
webUIPort: 4040
webUIServiceName: ingestion-datalake-news-app-ui-svc
executionAttempts: 13
executorState:
ingestion-datalake-news-app-1591613851251-exec-1: RUNNING
ingestion-datalake-news-app-1591613851251-exec-4: RUNNING
ingestion-datalake-news-app-1591613851251-exec-6: RUNNING
lastSubmissionAttemptTime: "2020-06-08T10:57:32Z"
sparkApplicationId: spark-a5920b2a5aa04d22a737c60759b5bf82
submissionAttempts: 1
submissionID: 3e713ec8-9f6c-4e78-ac28-749797c846f0
terminationTime: null

View File

@@ -0,0 +1,38 @@
apiVersion: sparkoperator.k8s.io/v1beta2
kind: SparkApplication
metadata:
generation: 4
labels:
argocd.argoproj.io/instance: spark-job
name: spark-job-app
namespace: spark-cluster
resourceVersion: "31812990"
uid: bfee52b0-74ca-4465-8005-f6643097ed64
spec:
executor:
instances: 4
dynamicAllocation:
enabled: true
initialExecutors: 2
minExecutors: 2
maxExecutors: 10
status:
applicationState:
state: RUNNING
driverInfo:
podName: ingestion-datalake-news-app-driver
webUIAddress: 172.20.207.161:4040
webUIPort: 4040
webUIServiceName: ingestion-datalake-news-app-ui-svc
executionAttempts: 13
executorState:
ingestion-datalake-news-app-1591613851251-exec-1: RUNNING
ingestion-datalake-news-app-1591613851251-exec-2: RUNNING
ingestion-datalake-news-app-1591613851251-exec-4: RUNNING
ingestion-datalake-news-app-1591613851251-exec-5: RUNNING
ingestion-datalake-news-app-1591613851251-exec-6: RUNNING
lastSubmissionAttemptTime: "2020-06-08T10:57:32Z"
sparkApplicationId: spark-a5920b2a5aa04d22a737c60759b5bf82
submissionAttempts: 1
submissionID: 3e713ec8-9f6c-4e78-ac28-749797c846f0
terminationTime: null

View File

@@ -25,6 +25,7 @@ import (
"github.com/argoproj/argo-cd/v2/util/rbac"
"github.com/argoproj/argo-cd/v2/util/security"
sessionmgr "github.com/argoproj/argo-cd/v2/util/session"
"github.com/argoproj/argo-cd/v2/util/settings"
)
type terminalHandler struct {
@@ -95,6 +96,26 @@ func isValidContainerName(name string) bool {
return len(validationErrors) == 0
}
type GetSettingsFunc func() (*settings.ArgoCDSettings, error)
// WithFeatureFlagMiddleware is an HTTP middleware to verify if the terminal
// feature is enabled before invoking the main handler
func (s *terminalHandler) WithFeatureFlagMiddleware(getSettings GetSettingsFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
argocdSettings, err := getSettings()
if err != nil {
log.Errorf("error executing WithFeatureFlagMiddleware: error getting settings: %s", err)
http.Error(w, "Failed to get settings", http.StatusBadRequest)
return
}
if !argocdSettings.ExecEnabled {
w.WriteHeader(http.StatusNotFound)
return
}
s.ServeHTTP(w, r)
})
}
func (s *terminalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()

View File

@@ -861,40 +861,10 @@ func (a *ArgoCDServer) newHTTPServer(ctx context.Context, port int, grpcWebHandl
}
mux.Handle("/api/", handler)
terminalHandler := application.NewHandler(a.appLister, a.Namespace, a.ApplicationNamespaces, a.db, a.enf, a.Cache, appResourceTreeFn, a.settings.ExecShells)
mux.HandleFunc("/terminal", func(writer http.ResponseWriter, request *http.Request) {
argocdSettings, err := a.settingsMgr.GetSettings()
if err != nil {
http.Error(writer, fmt.Sprintf("Failed to get settings: %v", err), http.StatusBadRequest)
return
}
if !argocdSettings.ExecEnabled {
writer.WriteHeader(http.StatusNotFound)
return
}
if !a.DisableAuth {
ctx := request.Context()
cookies := request.Cookies()
tokenString, err := httputil.JoinCookies(common.AuthCookieName, cookies)
if err == nil && jwtutil.IsValid(tokenString) {
claims, _, err := a.sessionMgr.VerifyToken(tokenString)
if err != nil {
// nolint:staticcheck
ctx = context.WithValue(ctx, util_session.AuthErrorCtxKey, err)
} else if claims != nil {
// Add claims to the context to inspect for RBAC
// nolint:staticcheck
ctx = context.WithValue(ctx, "claims", claims)
}
request = request.WithContext(ctx)
} else {
writer.WriteHeader(http.StatusUnauthorized)
return
}
}
terminalHandler.ServeHTTP(writer, request)
})
terminal := application.NewHandler(a.appLister, a.Namespace, a.ApplicationNamespaces, a.db, a.enf, a.Cache, appResourceTreeFn, a.settings.ExecShells).
WithFeatureFlagMiddleware(a.settingsMgr.GetSettings)
th := util_session.WithAuthMiddleware(a.DisableAuth, a.sessionMgr, terminal)
mux.Handle("/terminal", th)
mustRegisterGWHandler(versionpkg.RegisterVersionServiceHandler, ctx, gwmux, conn)
mustRegisterGWHandler(clusterpkg.RegisterClusterServiceHandler, ctx, gwmux, conn)

View File

@@ -34,6 +34,7 @@ import (
appstatecache "github.com/argoproj/argo-cd/v2/util/cache/appstate"
"github.com/argoproj/argo-cd/v2/util/rbac"
settings_util "github.com/argoproj/argo-cd/v2/util/settings"
testutil "github.com/argoproj/argo-cd/v2/util/test"
)
func fakeServer() (*ArgoCDServer, func()) {
@@ -522,7 +523,7 @@ func dexMockHandler(t *testing.T, url string) func(http.ResponseWriter, *http.Re
}
}
func getTestServer(t *testing.T, anonymousEnabled bool, withFakeSSO bool) (argocd *ArgoCDServer, dexURL string) {
func getTestServer(t *testing.T, anonymousEnabled bool, withFakeSSO bool, useDexForSSO bool) (argocd *ArgoCDServer, oidcURL string) {
cm := test.NewFakeConfigMap()
if anonymousEnabled {
cm.Data["users.anonymous.enabled"] = "true"
@@ -533,9 +534,14 @@ func getTestServer(t *testing.T, anonymousEnabled bool, withFakeSSO bool) (argoc
ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dexMockHandler(t, ts.URL)(w, r)
})
oidcServer := ts
if !useDexForSSO {
oidcServer = testutil.GetOIDCTestServer(t)
}
if withFakeSSO {
cm.Data["url"] = ts.URL
cm.Data["dex.config"] = `
if useDexForSSO {
cm.Data["dex.config"] = `
connectors:
# OIDC
- type: OIDC
@@ -545,6 +551,19 @@ connectors:
issuer: https://auth.example.gom
clientID: test-client
clientSecret: $dex.oidc.clientSecret`
} else {
oidcConfig := settings_util.OIDCConfig{
Name: "Okta",
Issuer: oidcServer.URL,
ClientID: "argo-cd",
ClientSecret: "$oidc.okta.clientSecret",
}
oidcConfigString, err := yaml.Marshal(oidcConfig)
require.NoError(t, err)
cm.Data["oidc.config"] = string(oidcConfigString)
// Avoid bothering with certs for local tests.
cm.Data["oidc.tls.insecure.skip.verify"] = "true"
}
}
secret := test.NewFakeSecret()
kubeclientset := fake.NewSimpleClientset(cm, secret)
@@ -556,27 +575,32 @@ connectors:
AppClientset: appClientSet,
RepoClientset: mockRepoClient,
}
if withFakeSSO {
if withFakeSSO && useDexForSSO {
argoCDOpts.DexServerAddr = ts.URL
}
argocd = NewServer(context.Background(), argoCDOpts)
return argocd, ts.URL
return argocd, oidcServer.URL
}
func TestAuthenticate_3rd_party_JWTs(t *testing.T) {
// Marshaling single strings to strings is typical, so we test for this relatively common behavior.
jwt.MarshalSingleStringAsArray = false
type testData struct {
test string
anonymousEnabled bool
claims jwt.RegisteredClaims
expectedErrorContains string
expectedClaims interface{}
useDex bool
}
var tests = []testData{
// Dex
{
test: "anonymous disabled, no audience",
anonymousEnabled: false,
claims: jwt.RegisteredClaims{},
expectedErrorContains: "no audience found in the token",
claims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
expectedErrorContains: common.TokenVerificationError,
expectedClaims: nil,
},
{
@@ -589,31 +613,95 @@ func TestAuthenticate_3rd_party_JWTs(t *testing.T) {
{
test: "anonymous disabled, unexpired token, admin claim",
anonymousEnabled: false,
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
expectedErrorContains: "id token signed with unsupported algorithm",
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
expectedErrorContains: common.TokenVerificationError,
expectedClaims: nil,
},
{
test: "anonymous enabled, unexpired token, admin claim",
anonymousEnabled: true,
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
expectedErrorContains: "",
expectedClaims: "",
},
{
test: "anonymous disabled, expired token, admin claim",
anonymousEnabled: false,
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
expectedErrorContains: "token is expired",
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
expectedErrorContains: common.TokenVerificationError,
expectedClaims: jwt.RegisteredClaims{Issuer: "sso"},
},
{
test: "anonymous enabled, expired token, admin claim",
anonymousEnabled: true,
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"test-client"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
expectedErrorContains: "",
expectedClaims: "",
},
{
test: "anonymous disabled, unexpired token, admin claim, incorrect audience",
anonymousEnabled: false,
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"incorrect-audience"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
expectedErrorContains: common.TokenVerificationError,
expectedClaims: nil,
},
// External OIDC (not bundled Dex)
{
test: "external OIDC: anonymous disabled, no audience",
anonymousEnabled: false,
claims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
useDex: true,
expectedErrorContains: common.TokenVerificationError,
expectedClaims: nil,
},
{
test: "external OIDC: anonymous enabled, no audience",
anonymousEnabled: true,
claims: jwt.RegisteredClaims{},
useDex: true,
expectedErrorContains: "",
expectedClaims: "",
},
{
test: "external OIDC: anonymous disabled, unexpired token, admin claim",
anonymousEnabled: false,
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
useDex: true,
expectedErrorContains: common.TokenVerificationError,
expectedClaims: nil,
},
{
test: "external OIDC: anonymous enabled, unexpired token, admin claim",
anonymousEnabled: true,
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
useDex: true,
expectedErrorContains: "",
expectedClaims: "",
},
{
test: "external OIDC: anonymous disabled, expired token, admin claim",
anonymousEnabled: false,
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
useDex: true,
expectedErrorContains: common.TokenVerificationError,
expectedClaims: jwt.RegisteredClaims{Issuer: "sso"},
},
{
test: "external OIDC: anonymous enabled, expired token, admin claim",
anonymousEnabled: true,
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{common.ArgoCDClientAppID}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now())},
useDex: true,
expectedErrorContains: "",
expectedClaims: "",
},
{
test: "external OIDC: anonymous disabled, unexpired token, admin claim, incorrect audience",
anonymousEnabled: false,
claims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"incorrect-audience"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))},
useDex: true,
expectedErrorContains: common.TokenVerificationError,
expectedClaims: nil,
},
}
for _, testData := range tests {
@@ -625,8 +713,13 @@ func TestAuthenticate_3rd_party_JWTs(t *testing.T) {
// Must be declared here to avoid race.
ctx := context.Background() //nolint:ineffassign,staticcheck
argocd, dexURL := getTestServer(t, testDataCopy.anonymousEnabled, true)
testDataCopy.claims.Issuer = fmt.Sprintf("%s/api/dex", dexURL)
argocd, oidcURL := getTestServer(t, testDataCopy.anonymousEnabled, true, testDataCopy.useDex)
if testDataCopy.useDex {
testDataCopy.claims.Issuer = fmt.Sprintf("%s/api/dex", oidcURL)
} else {
testDataCopy.claims.Issuer = oidcURL
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, testDataCopy.claims)
tokenString, err := token.SignedString([]byte("key"))
require.NoError(t, err)
@@ -676,7 +769,7 @@ func TestAuthenticate_no_request_metadata(t *testing.T) {
t.Run(testDataCopy.test, func(t *testing.T) {
t.Parallel()
argocd, _ := getTestServer(t, testDataCopy.anonymousEnabled, true)
argocd, _ := getTestServer(t, testDataCopy.anonymousEnabled, true, true)
ctx := context.Background()
ctx, err := argocd.Authenticate(ctx)
@@ -722,7 +815,7 @@ func TestAuthenticate_no_SSO(t *testing.T) {
// Must be declared here to avoid race.
ctx := context.Background() //nolint:ineffassign,staticcheck
argocd, dexURL := getTestServer(t, testDataCopy.anonymousEnabled, false)
argocd, dexURL := getTestServer(t, testDataCopy.anonymousEnabled, false, true)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{Issuer: fmt.Sprintf("%s/api/dex", dexURL)})
tokenString, err := token.SignedString([]byte("key"))
require.NoError(t, err)
@@ -795,7 +888,7 @@ func TestAuthenticate_bad_request_metadata(t *testing.T) {
test: "anonymous disabled, bad auth header",
anonymousEnabled: false,
metadata: metadata.MD{"authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiJ9.TGGTTHuuGpEU8WgobXxkrBtW3NiR3dgw5LR-1DEW3BQ"}},
expectedErrorMessage: "no audience found in the token",
expectedErrorMessage: common.TokenVerificationError,
expectedClaims: nil,
},
{
@@ -809,7 +902,7 @@ func TestAuthenticate_bad_request_metadata(t *testing.T) {
test: "anonymous disabled, bad auth cookie",
anonymousEnabled: false,
metadata: metadata.MD{"grpcgateway-cookie": []string{"argocd.token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiJ9.TGGTTHuuGpEU8WgobXxkrBtW3NiR3dgw5LR-1DEW3BQ"}},
expectedErrorMessage: "no audience found in the token",
expectedErrorMessage: common.TokenVerificationError,
expectedClaims: nil,
},
{
@@ -830,7 +923,7 @@ func TestAuthenticate_bad_request_metadata(t *testing.T) {
// Must be declared here to avoid race.
ctx := context.Background() //nolint:ineffassign,staticcheck
argocd, _ := getTestServer(t, testDataCopy.anonymousEnabled, true)
argocd, _ := getTestServer(t, testDataCopy.anonymousEnabled, true, true)
ctx = metadata.NewIncomingContext(context.Background(), testDataCopy.metadata)
ctx, err := argocd.Authenticate(ctx)

View File

@@ -46,7 +46,7 @@ export const ApplicationResourceList = ({
<Consumer>
{ctx => (
<span className='application-details__external_link'>
<a href={ctx.baseHref + 'applications/' + res.name} title='Open application'>
<a href={ctx.baseHref + 'applications/' + res.namespace + '/' + res.name} title='Open application'>
<i className='fa fa-external-link-alt' />
</a>
</span>

View File

@@ -433,7 +433,7 @@ function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: R
{appNode && !rootNode && (
<Consumer>
{ctx => (
<a href={ctx.baseHref + 'applications/' + node.name} title='Open application'>
<a href={ctx.baseHref + 'applications/' + node.namespace + '/' + node.name} title='Open application'>
<i className='fa fa-external-link-alt' />
</a>
)}
@@ -652,7 +652,7 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod
{appNode && !rootNode && (
<Consumer>
{ctx => (
<a href={ctx.baseHref + 'applications/' + node.name} title='Open application'>
<a href={ctx.baseHref + 'applications/' + node.namespace + '/' + node.name} title='Open application'>
<i className='fa fa-external-link-alt' />
</a>
)}

View File

@@ -288,10 +288,11 @@ export class ApplicationsService {
.then(res => JSON.parse(res.manifest) as models.State);
}
public getResourceActions(name: string, appNamspace: string, resource: models.ResourceNode): Promise<models.ResourceAction[]> {
public getResourceActions(name: string, appNamespace: string, resource: models.ResourceNode): Promise<models.ResourceAction[]> {
return requests
.get(`/applications/${name}/resource/actions`)
.query({
appNamespace,
namespace: resource.namespace,
resourceName: resource.name,
version: resource.version,
@@ -301,10 +302,11 @@ export class ApplicationsService {
.then(res => (res.body.actions as models.ResourceAction[]) || []);
}
public runResourceAction(name: string, appNamspace: string, resource: models.ResourceNode, action: string): Promise<models.ResourceAction[]> {
public runResourceAction(name: string, appNamespace: string, resource: models.ResourceNode, action: string): Promise<models.ResourceAction[]> {
return requests
.post(`/applications/${name}/resource/actions`)
.query({
appNamespace,
namespace: resource.namespace,
resourceName: resource.name,
version: resource.version,

View File

@@ -353,9 +353,12 @@ func (a *ClientApp) HandleCallback(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no id_token in token response", http.StatusInternalServerError)
return
}
idToken, err := a.provider.Verify(a.clientID, idTokenRAW)
idToken, err := a.provider.Verify(idTokenRAW, a.settings)
if err != nil {
http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusInternalServerError)
log.Warnf("Failed to verify token: %s", err)
http.Error(w, common.TokenVerificationError, http.StatusInternalServerError)
return
}
path := "/"

View File

@@ -90,7 +90,7 @@ func (p *fakeProvider) ParseConfig() (*OIDCConfiguration, error) {
return nil, nil
}
func (p *fakeProvider) Verify(_, _ string) (*gooidc.IDToken, error) {
func (p *fakeProvider) Verify(_ string, _ *settings.ArgoCDSettings) (*gooidc.IDToken, error) {
return nil, nil
}

View File

@@ -2,6 +2,7 @@ package oidc
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
@@ -9,8 +10,13 @@ import (
gooidc "github.com/coreos/go-oidc"
log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"github.com/argoproj/argo-cd/v2/util/security"
"github.com/argoproj/argo-cd/v2/util/settings"
)
var ErrTokenExpired = errors.New("token is expired")
// Provider is a wrapper around go-oidc provider to also provide the following features:
// 1. lazy initialization/querying of the provider
// 2. automatic detection of change in signing keys
@@ -23,7 +29,7 @@ type Provider interface {
ParseConfig() (*OIDCConfiguration, error)
Verify(clientID, tokenString string) (*gooidc.IDToken, error)
Verify(tokenString string, argoSettings *settings.ArgoCDSettings) (*gooidc.IDToken, error)
}
type providerImpl struct {
@@ -69,13 +75,70 @@ func (p *providerImpl) newGoOIDCProvider() (*gooidc.Provider, error) {
return prov, nil
}
func (p *providerImpl) Verify(clientID, tokenString string) (*gooidc.IDToken, error) {
func (p *providerImpl) Verify(tokenString string, argoSettings *settings.ArgoCDSettings) (*gooidc.IDToken, error) {
// According to the JWT spec, the aud claim is optional. The spec also says (emphasis mine):
//
// If the principal processing the claim does not identify itself with a value in the "aud" claim _when this
// claim is present_, then the JWT MUST be rejected.
//
// - https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3
//
// If the claim is not present, we can skip the audience claim check (called the "ClientID check" in go-oidc's
// terminology).
//
// The OIDC spec says that the aud claim is required (https://openid.net/specs/openid-connect-core-1_0.html#IDToken).
// But we cannot assume that all OIDC providers will follow the spec. For Argo CD <2.6.0, we will default to
// allowing the aud claim to be optional. In Argo CD >=2.6.0, we will default to requiring the aud claim to be
// present and give users the skipAudienceCheckWhenTokenHasNoAudience setting to revert the behavior if necessary.
//
// At this point, we have not verified that the token has not been altered. All code paths below MUST VERIFY
// THE TOKEN SIGNATURE to confirm that an attacker did not maliciously remove the "aud" claim.
unverifiedHasAudClaim, err := security.UnverifiedHasAudClaim(tokenString)
if err != nil {
return nil, fmt.Errorf("failed to determine whether the token has an aud claim: %w", err)
}
var idToken *gooidc.IDToken
if !unverifiedHasAudClaim {
idToken, err = p.verify("", tokenString, argoSettings.SkipAudienceCheckWhenTokenHasNoAudience())
} else {
allowedAudiences := argoSettings.OAuth2AllowedAudiences()
if len(allowedAudiences) == 0 {
return nil, errors.New("token has an audience claim, but no allowed audiences are configured")
}
// Token must be verified for at least one allowed audience
for _, aud := range allowedAudiences {
idToken, err = p.verify(aud, tokenString, false)
if err != nil && strings.HasPrefix(err.Error(), "oidc: token is expired") {
// If the token is expired, we won't bother checking other audiences. It's important to return a
// ErrTokenExpired instead of an error related to an incorrect audience, because the caller may
// have specific behavior to handle expired tokens.
break
}
if err == nil {
break
}
}
}
if err != nil {
if strings.HasPrefix(err.Error(), "oidc: token is expired") {
return nil, ErrTokenExpired
}
return nil, fmt.Errorf("failed to verify token: %w", err)
}
return idToken, nil
}
func (p *providerImpl) verify(clientID, tokenString string, skipClientIDCheck bool) (*gooidc.IDToken, error) {
ctx := context.Background()
prov, err := p.provider()
if err != nil {
return nil, err
}
verifier := prov.Verifier(&gooidc.Config{ClientID: clientID})
config := &gooidc.Config{ClientID: clientID, SkipClientIDCheck: skipClientIDCheck}
verifier := prov.Verifier(config)
idToken, err := verifier.Verify(ctx, tokenString)
if err != nil {
// HACK: if we failed token verification, it's possible the reason was because dex
@@ -93,7 +156,7 @@ func (p *providerImpl) Verify(clientID, tokenString string) (*gooidc.IDToken, er
// return original error if we fail to re-initialize OIDC
return nil, err
}
verifier = newProvider.Verifier(&gooidc.Config{ClientID: clientID})
verifier = newProvider.Verifier(config)
idToken, err = verifier.Verify(ctx, tokenString)
if err != nil {
return nil, err

80
util/security/jwt.go Normal file
View File

@@ -0,0 +1,80 @@
package security
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
// parseJWT parses a jwt and returns it as json bytes.
//
// This function DOES NOT VERIFY THE TOKEN. You still have to verify the token to confirm that the token holder has not
// altered the claims.
//
// This code is copied almost verbatim from go-oidc (https://github.com/coreos/go-oidc).
func parseJWT(p string) ([]byte, error) {
parts := strings.Split(p, ".")
if len(parts) < 2 {
return nil, fmt.Errorf("malformed jwt, expected 3 parts got %d", len(parts))
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("malformed jwt payload: %v", err)
}
return payload, nil
}
type audience []string
// UnmarshalJSON allows us to unmarshal either a single audience or a list of audiences.
// Taken from: https://github.com/coreos/go-oidc/blob/a8ceb9a2043fca2e43518633920db746808b1138/oidc/oidc.go#L475
func (a *audience) UnmarshalJSON(b []byte) error {
var s string
if json.Unmarshal(b, &s) == nil {
*a = audience{s}
return nil
}
var auds []string
if err := json.Unmarshal(b, &auds); err != nil {
return err
}
*a = auds
return nil
}
// jwtWithOnlyAudClaim represents a jwt where only the "aud" claim is present. This struct allows us to unmarshal a jwt
// and be confident that the only information retrieved from that jwt is the "aud" claim.
type jwtWithOnlyAudClaim struct {
Aud audience `json:"aud"`
}
// getUnverifiedAudClaim gets the "aud" claim from a jwt.
//
// This function DOES NOT VERIFY THE TOKEN. You still have to verify the token to confirm that the token holder has not
// altered the "aud" claim.
//
// This code is copied almost verbatim from go-oidc (https://github.com/coreos/go-oidc).
func getUnverifiedAudClaim(rawIDToken string) ([]string, error) {
payload, err := parseJWT(rawIDToken)
if err != nil {
return nil, fmt.Errorf("malformed jwt: %v", err)
}
var token jwtWithOnlyAudClaim
if err = json.Unmarshal(payload, &token); err != nil {
return nil, fmt.Errorf("failed to unmarshal claims: %v", err)
}
return token.Aud, nil
}
// UnverifiedHasAudClaim returns whether the "aud" claim is present in the given JWT.
//
// This function DOES NOT VERIFY THE TOKEN. You still have to verify the token to confirm that the token holder has not
// altered the "aud" claim.
func UnverifiedHasAudClaim(rawIDToken string) (bool, error) {
aud, err := getUnverifiedAudClaim(rawIDToken)
if err != nil {
return false, fmt.Errorf("failed to determine whether token had an audience claim: %w", err)
}
return aud != nil, nil
}

56
util/security/jwt_test.go Normal file
View File

@@ -0,0 +1,56 @@
package security
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
utiltest "github.com/argoproj/argo-cd/v2/util/test"
)
func Test_UnverifiedHasAudClaim(t *testing.T) {
tokenForAud := func(t *testing.T, aud jwt.ClaimStrings) string {
claims := jwt.RegisteredClaims{Audience: aud, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
return tokenString
}
testCases := []struct {
name string
aud jwt.ClaimStrings
expectedHasAud bool
}{
{
name: "no audience",
aud: jwt.ClaimStrings{},
expectedHasAud: false,
},
{
name: "one empty audience",
aud: jwt.ClaimStrings{""},
expectedHasAud: true,
},
{
name: "one non-empty audience",
aud: jwt.ClaimStrings{"test"},
expectedHasAud: true,
},
}
for _, testCase := range testCases {
testCaseCopy := testCase
t.Run(testCaseCopy.name, func(t *testing.T) {
t.Parallel()
out, err := UnverifiedHasAudClaim(tokenForAud(t, testCaseCopy.aud))
require.NoError(t, err)
assert.Equal(t, testCaseCopy.expectedHasAud, out)
})
}
}

View File

@@ -12,7 +12,6 @@ import (
"strings"
"time"
oidc "github.com/coreos/go-oidc"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
@@ -419,7 +418,7 @@ func (mgr *SessionManager) VerifyUsernamePassword(username string, password stri
// introduces random delay to protect from timing-based user enumeration attack
delayNanoseconds := verificationDelayNoiseMin.Nanoseconds() +
int64(rand.Intn(int(verificationDelayNoiseMax.Nanoseconds()-verificationDelayNoiseMin.Nanoseconds())))
// take into account amount of time spent since the request start
// take into account amount of time spent since the request start
delayNanoseconds = delayNanoseconds - time.Since(start).Nanoseconds()
if delayNanoseconds > 0 {
mgr.sleep(time.Duration(delayNanoseconds))
@@ -463,6 +462,47 @@ func (mgr *SessionManager) VerifyUsernamePassword(username string, password stri
return nil
}
// AuthMiddlewareFunc returns a function that can be used as an
// authentication middleware for HTTP requests.
func (mgr *SessionManager) AuthMiddlewareFunc(disabled bool) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return WithAuthMiddleware(disabled, mgr, h)
}
}
// TokenVerifier defines the contract to invoke token
// verification logic
type TokenVerifier interface {
VerifyToken(token string) (jwt.Claims, string, error)
}
// WithAuthMiddleware is an HTTP middleware used to ensure incoming
// requests are authenticated before invoking the target handler. If
// disabled is true, it will just invoke the next handler in the chain.
func WithAuthMiddleware(disabled bool, authn TokenVerifier, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !disabled {
cookies := r.Cookies()
tokenString, err := httputil.JoinCookies(common.AuthCookieName, cookies)
if err != nil {
http.Error(w, "Auth cookie not found", http.StatusBadRequest)
return
}
claims, _, err := authn.VerifyToken(tokenString)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
ctx := r.Context()
// Add claims to the context to inspect for RBAC
// nolint:staticcheck
ctx = context.WithValue(ctx, "claims", claims)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
// VerifyToken verifies if a token is correct. Tokens can be issued either from us or by an IDP.
// We choose how to verify based on the issuer.
func (mgr *SessionManager) VerifyToken(tokenString string) (jwt.Claims, string, error) {
@@ -483,31 +523,28 @@ func (mgr *SessionManager) VerifyToken(tokenString string) (jwt.Claims, string,
return nil, "", err
}
// Token must be verified for at least one audience
// TODO(jannfis): Is this the right way? Shouldn't we know our audience and only validate for the correct one?
var idToken *oidc.IDToken
for _, aud := range claims.Audience {
idToken, err = prov.Verify(aud, tokenString)
if err == nil {
break
}
argoSettings, err := mgr.settingsMgr.GetSettings()
if err != nil {
return nil, "", fmt.Errorf("cannot access settings while verifying the token: %w", err)
}
if argoSettings == nil {
return nil, "", fmt.Errorf("settings are not available while verifying the token")
}
idToken, err := prov.Verify(tokenString, argoSettings)
// The token verification has failed. If the token has expired, we will
// return a dummy claims only containing a value for the issuer, so the
// UI can handle expired tokens appropriately.
if err != nil {
if strings.HasPrefix(err.Error(), "oidc: token is expired") {
log.Warnf("Failed to verify token: %s", err)
if errors.Is(err, oidcutil.ErrTokenExpired) {
claims = jwt.RegisteredClaims{
Issuer: "sso",
}
return claims, "", err
return claims, "", common.TokenVerificationErr
}
return nil, "", err
}
if idToken == nil {
return nil, "", fmt.Errorf("no audience found in the token")
return nil, "", common.TokenVerificationErr
}
var claims jwt.MapClaims

View File

@@ -3,8 +3,12 @@ package session
import (
"context"
"encoding/pem"
stderrors "errors"
"fmt"
"io"
"math"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
@@ -221,6 +225,136 @@ func TestSessionManager_ProjectToken(t *testing.T) {
})
}
type claimsMock struct {
err error
}
func (cm *claimsMock) Valid() error {
return cm.err
}
type tokenVerifierMock struct {
claims *claimsMock
err error
}
func (tm *tokenVerifierMock) VerifyToken(token string) (jwt.Claims, string, error) {
if tm.claims == nil {
return nil, "", tm.err
}
return tm.claims, "", tm.err
}
func strPointer(str string) *string {
return &str
}
func TestSessionManager_WithAuthMiddleware(t *testing.T) {
handlerFunc := func() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
t.Helper()
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/text")
_, err := w.Write([]byte("Ok"))
if err != nil {
t.Fatalf("error writing response: %s", err)
}
}
}
type testCase struct {
name string
authDisabled bool
cookieHeader bool
verifiedClaims *claimsMock
verifyTokenErr error
expectedStatusCode int
expectedResponseBody *string
}
cases := []testCase{
{
name: "will authenticate successfully",
authDisabled: false,
cookieHeader: true,
verifiedClaims: &claimsMock{},
verifyTokenErr: nil,
expectedStatusCode: 200,
expectedResponseBody: strPointer("Ok"),
},
{
name: "will be noop if auth is disabled",
authDisabled: true,
cookieHeader: false,
verifiedClaims: nil,
verifyTokenErr: nil,
expectedStatusCode: 200,
expectedResponseBody: strPointer("Ok"),
},
{
name: "will return 400 if no cookie header",
authDisabled: false,
cookieHeader: false,
verifiedClaims: &claimsMock{},
verifyTokenErr: nil,
expectedStatusCode: 400,
expectedResponseBody: nil,
},
{
name: "will return 401 verify token fails",
authDisabled: false,
cookieHeader: true,
verifiedClaims: &claimsMock{},
verifyTokenErr: stderrors.New("token error"),
expectedStatusCode: 401,
expectedResponseBody: nil,
},
{
name: "will return 200 if claims are nil",
authDisabled: false,
cookieHeader: true,
verifiedClaims: nil,
verifyTokenErr: nil,
expectedStatusCode: 200,
expectedResponseBody: strPointer("Ok"),
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// given
mux := http.NewServeMux()
mux.HandleFunc("/", handlerFunc())
tm := &tokenVerifierMock{
claims: tc.verifiedClaims,
err: tc.verifyTokenErr,
}
ts := httptest.NewServer(WithAuthMiddleware(tc.authDisabled, tm, mux))
defer ts.Close()
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
if err != nil {
t.Fatalf("error creating request: %s", err)
}
if tc.cookieHeader {
req.Header.Add("Cookie", "argocd.token=123456")
}
// when
resp, err := http.DefaultClient.Do(req)
// then
assert.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, tc.expectedStatusCode, resp.StatusCode)
if tc.expectedResponseBody != nil {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
actual := strings.TrimSuffix(string(body), "\n")
assert.Contains(t, actual, *tc.expectedResponseBody)
}
})
}
}
var loggedOutContext = context.Background()
// nolint:staticcheck
@@ -588,9 +722,7 @@ rootCA: |
_, _, err = mgr.VerifyToken(tokenString)
require.Error(t, err)
if !strings.Contains(err.Error(), "certificate signed by unknown authority") && !strings.Contains(err.Error(), "certificate is not trusted") {
t.Fatal("did not receive expected certificate verification failure error")
}
assert.ErrorIs(t, err, common.TokenVerificationErr)
})
t.Run("OIDC provider is external, TLS is configured", func(t *testing.T) {
@@ -625,9 +757,7 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL),
_, _, err = mgr.VerifyToken(tokenString)
require.Error(t, err)
if !strings.Contains(err.Error(), "certificate signed by unknown authority") && !strings.Contains(err.Error(), "certificate is not trusted") {
t.Fatal("did not receive expected certificate verification failure error")
}
assert.ErrorIs(t, err, common.TokenVerificationErr)
})
t.Run("OIDC provider is Dex, TLS is configured", func(t *testing.T) {
@@ -662,9 +792,7 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL),
_, _, err = mgr.VerifyToken(tokenString)
require.Error(t, err)
if !strings.Contains(err.Error(), "certificate signed by unknown authority") && !strings.Contains(err.Error(), "certificate is not trusted") {
t.Fatal("did not receive expected certificate verification failure error")
}
assert.ErrorIs(t, err, common.TokenVerificationErr)
})
t.Run("OIDC provider is external, TLS is configured, OIDCTLSInsecureSkipVerify is true", func(t *testing.T) {
@@ -732,4 +860,295 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL),
assert.NotContains(t, err.Error(), "certificate is not trusted")
assert.NotContains(t, err.Error(), "certificate signed by unknown authority")
})
t.Run("OIDC provider is external, audience is not specified", func(t *testing.T) {
config := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]`, oidcTestServer.URL),
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
}
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
secretConfig := map[string][]byte{
"tls.crt": utiltest.Cert,
"tls.key": utiltest.PrivateKey,
}
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = oidcTestServer.URL
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
_, _, err = mgr.VerifyToken(tokenString)
require.NoError(t, err)
})
t.Run("OIDC provider is external, audience is not specified but is required", func(t *testing.T) {
config := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]
skipAudienceCheckWhenTokenHasNoAudience: false`, oidcTestServer.URL),
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
}
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
secretConfig := map[string][]byte{
"tls.crt": utiltest.Cert,
"tls.key": utiltest.PrivateKey,
}
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = oidcTestServer.URL
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
_, _, err = mgr.VerifyToken(tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.TokenVerificationErr)
})
t.Run("OIDC provider is external, audience is client ID, no allowed list specified", func(t *testing.T) {
config := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]`, oidcTestServer.URL),
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
}
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
secretConfig := map[string][]byte{
"tls.crt": utiltest.Cert,
"tls.key": utiltest.PrivateKey,
}
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"xxx"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = oidcTestServer.URL
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
_, _, err = mgr.VerifyToken(tokenString)
require.NoError(t, err)
})
t.Run("OIDC provider is external, audience is in allowed list", func(t *testing.T) {
config := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]
allowedAudiences:
- something`, oidcTestServer.URL),
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
}
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
secretConfig := map[string][]byte{
"tls.crt": utiltest.Cert,
"tls.key": utiltest.PrivateKey,
}
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"something"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = oidcTestServer.URL
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
_, _, err = mgr.VerifyToken(tokenString)
assert.NoError(t, err)
})
t.Run("OIDC provider is external, audience is not in allowed list", func(t *testing.T) {
config := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]
allowedAudiences:
- something-else`, oidcTestServer.URL),
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
}
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
secretConfig := map[string][]byte{
"tls.crt": utiltest.Cert,
"tls.key": utiltest.PrivateKey,
}
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"something"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = oidcTestServer.URL
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
_, _, err = mgr.VerifyToken(tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.TokenVerificationErr)
})
t.Run("OIDC provider is external, audience is not client ID, and there is no allow list", func(t *testing.T) {
config := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]`, oidcTestServer.URL),
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
}
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
secretConfig := map[string][]byte{
"tls.crt": utiltest.Cert,
"tls.key": utiltest.PrivateKey,
}
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"something"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = oidcTestServer.URL
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
_, _, err = mgr.VerifyToken(tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.TokenVerificationErr)
})
t.Run("OIDC provider is external, audience is specified, but allow list is empty", func(t *testing.T) {
config := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]
allowedAudiences: []`, oidcTestServer.URL),
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
}
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
secretConfig := map[string][]byte{
"tls.crt": utiltest.Cert,
"tls.key": utiltest.PrivateKey,
}
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"something"}, Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = oidcTestServer.URL
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
_, _, err = mgr.VerifyToken(tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.TokenVerificationErr)
})
t.Run("OIDC provider is external, audience is not specified, token is signed with the wrong key", func(t *testing.T) {
config := map[string]string{
"url": "",
"oidc.config": fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]`, oidcTestServer.URL),
"oidc.tls.insecure.skip.verify": "true", // This isn't what we're testing.
}
// This is not actually used in the test. The test only calls the OIDC test server. But a valid cert/key pair
// must be set to test VerifyToken's behavior when Argo CD is configured with TLS enabled.
secretConfig := map[string][]byte{
"tls.crt": utiltest.Cert,
"tls.key": utiltest.PrivateKey,
}
settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClientWithConfig(config, secretConfig), "argocd")
mgr := NewSessionManager(settingsMgr, getProjLister(), "", nil, NewUserStateStorage(nil))
mgr.verificationDelayNoiseEnabled = false
claims := jwt.RegisteredClaims{Subject: "admin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24))}
claims.Issuer = oidcTestServer.URL
token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims)
key, err := jwt.ParseRSAPrivateKeyFromPEM(utiltest.PrivateKey2)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
_, _, err = mgr.VerifyToken(tokenString)
require.Error(t, err)
assert.ErrorIs(t, err, common.TokenVerificationErr)
})
}

View File

@@ -132,6 +132,33 @@ type Help struct {
BinaryURLs map[string]string `json:"binaryUrl,omitempty"`
}
// oidcConfig is the same as the public OIDCConfig, except the public one excludes the AllowedAudiences and the
// SkipAudienceCheckWhenTokenHasNoAudience fields.
// AllowedAudiences should be accessed via ArgoCDSettings.OAuth2AllowedAudiences.
// SkipAudienceCheckWhenTokenHasNoAudience should be accessed via ArgoCDSettings.SkipAudienceCheckWhenTokenHasNoAudience.
type oidcConfig struct {
OIDCConfig
AllowedAudiences []string `json:"allowedAudiences,omitempty"`
SkipAudienceCheckWhenTokenHasNoAudience *bool `json:"skipAudienceCheckWhenTokenHasNoAudience,omitempty"`
}
func (o *oidcConfig) toExported() *OIDCConfig {
if o == nil {
return nil
}
return &OIDCConfig{
Name: o.Name,
Issuer: o.Issuer,
ClientID: o.ClientID,
ClientSecret: o.ClientSecret,
CLIClientID: o.CLIClientID,
RequestedScopes: o.RequestedScopes,
RequestedIDTokenClaims: o.RequestedIDTokenClaims,
LogoutURL: o.LogoutURL,
RootCA: o.RootCA,
}
}
type OIDCConfig struct {
Name string `json:"name,omitempty"`
Issuer string `json:"issuer,omitempty"`
@@ -1595,24 +1622,37 @@ func UnmarshalDexConfig(config string) (map[string]interface{}, error) {
return dexCfg, err
}
func (a *ArgoCDSettings) OIDCConfig() *OIDCConfig {
func (a *ArgoCDSettings) oidcConfig() *oidcConfig {
if a.OIDCConfigRAW == "" {
return nil
}
oidcConfig, err := UnmarshalOIDCConfig(a.OIDCConfigRAW)
config, err := unmarshalOIDCConfig(a.OIDCConfigRAW)
if err != nil {
log.Warnf("invalid oidc config: %v", err)
return nil
}
oidcConfig.ClientSecret = ReplaceStringSecret(oidcConfig.ClientSecret, a.Secrets)
oidcConfig.ClientID = ReplaceStringSecret(oidcConfig.ClientID, a.Secrets)
return &oidcConfig
config.ClientSecret = ReplaceStringSecret(config.ClientSecret, a.Secrets)
config.ClientID = ReplaceStringSecret(config.ClientID, a.Secrets)
return &config
}
func UnmarshalOIDCConfig(config string) (OIDCConfig, error) {
var oidcConfig OIDCConfig
err := yaml.Unmarshal([]byte(config), &oidcConfig)
return oidcConfig, err
func (a *ArgoCDSettings) OIDCConfig() *OIDCConfig {
config := a.oidcConfig()
if config == nil {
return nil
}
return config.toExported()
}
func unmarshalOIDCConfig(configStr string) (oidcConfig, error) {
var config oidcConfig
err := yaml.Unmarshal([]byte(configStr), &config)
return config, err
}
func ValidateOIDCConfig(configStr string) error {
_, err := unmarshalOIDCConfig(configStr)
return err
}
// TLSConfig returns a tls.Config with the configured certificates
@@ -1651,6 +1691,37 @@ func (a *ArgoCDSettings) OAuth2ClientID() string {
return ""
}
// OAuth2AllowedAudiences returns a list of audiences that are allowed for the OAuth2 client. If the user has not
// explicitly configured the list of audiences (or has configured an empty list), then the OAuth2 client ID is returned
// as the only allowed audience. When using the bundled Dex, that client ID is always "argo-cd".
func (a *ArgoCDSettings) OAuth2AllowedAudiences() []string {
if config := a.oidcConfig(); config != nil {
if len(config.AllowedAudiences) == 0 {
allowedAudiences := []string{config.ClientID}
if config.CLIClientID != "" {
allowedAudiences = append(allowedAudiences, config.CLIClientID)
}
return allowedAudiences
}
return config.AllowedAudiences
}
if a.DexConfig != "" {
return []string{common.ArgoCDClientAppID, common.ArgoCDCLIClientAppID}
}
return nil
}
func (a *ArgoCDSettings) SkipAudienceCheckWhenTokenHasNoAudience() bool {
if config := a.oidcConfig(); config != nil {
if config.SkipAudienceCheckWhenTokenHasNoAudience != nil {
return *config.SkipAudienceCheckWhenTokenHasNoAudience
}
return true
}
// When using the bundled Dex, the audience check is required. Dex will always send JWTs with an audience.
return false
}
func (a *ArgoCDSettings) OAuth2ClientSecret() string {
if oidcConfig := a.OIDCConfig(); oidcConfig != nil {
return oidcConfig.ClientSecret

View File

@@ -1343,3 +1343,68 @@ rootCA: "invalid"`},
})
}
}
func Test_OAuth2AllowedAudiences(t *testing.T) {
testCases := []struct {
name string
settings *ArgoCDSettings
expected []string
}{
{
name: "Empty",
settings: &ArgoCDSettings{},
expected: []string{},
},
{
name: "OIDC configured, no audiences specified, clientID used",
settings: &ArgoCDSettings{OIDCConfigRAW: `name: Test
issuer: aaa
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]`},
expected: []string{"xxx"},
},
{
name: "OIDC configured, no audiences specified, clientID and cliClientID used",
settings: &ArgoCDSettings{OIDCConfigRAW: `name: Test
issuer: aaa
clientID: xxx
cliClientID: cli-xxx
clientSecret: yyy
requestedScopes: ["oidc"]`},
expected: []string{"xxx", "cli-xxx"},
},
{
name: "OIDC configured, audiences specified",
settings: &ArgoCDSettings{OIDCConfigRAW: `name: Test
issuer: aaa
clientID: xxx
clientSecret: yyy
requestedScopes: ["oidc"]
allowedAudiences: ["aud1", "aud2"]`},
expected: []string{"aud1", "aud2"},
},
{
name: "Dex configured",
settings: &ArgoCDSettings{DexConfig: `connectors:
- type: github
id: github
name: GitHub
config:
clientID: aabbccddeeff00112233
clientSecret: $dex.github.clientSecret
orgs:
- name: your-github-org
`},
expected: []string{common.ArgoCDClientAppID, common.ArgoCDCLIClientAppID},
},
}
for _, tc := range testCases {
tcc := tc
t.Run(tcc.name, func(t *testing.T) {
t.Parallel()
assert.ElementsMatch(t, tcc.expected, tcc.settings.OAuth2AllowedAudiences())
})
}
}

View File

@@ -1,18 +1,22 @@
package test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/golang-jwt/jwt/v4"
"github.com/stretchr/testify/require"
"gopkg.in/square/go-jose.v2"
)
// Cert is a certificate for tests. It was generated like this:
// opts := tls.CertOptions{Hosts: []string{"localhost"}, Organization: "Acme"}
// certBytes, privKey, err := tls.generatePEM(opts)
//
// opts := tls.CertOptions{Hosts: []string{"localhost"}, Organization: "Acme"}
// certBytes, privKey, err := tls.generatePEM(opts)
var Cert = []byte(`-----BEGIN CERTIFICATE-----
MIIC8zCCAdugAwIBAgIQCSoocl6e/FR4mQy1wX6NbjANBgkqhkiG9w0BAQsFADAP
MQ0wCwYDVQQKEwRBY21lMB4XDTIyMDYyMjE3Mjk1MloXDTIzMDYyMjE3Mjk1Mlow
@@ -61,6 +65,48 @@ SDpz4h+Bov5qTKkzcxuu1QWtA4M0K8Iy6IYLwb83DZEm1OsAf4i0pODz21PY/I+O
VHzjB10oYgaInHZgMUdyb6F571UdiYSB6a/IlZ3ngj5touy3VIM=
-----END RSA PRIVATE KEY-----`)
// PrivateKey2 is a second RSA key used only for tests. You can use it to see if signing a JWT with a different key
// fails validation (as it should).
var PrivateKey2 = []byte(`-----BEGIN RSA PRIVATE KEY-----
MIIG4gIBAAKCAYEAqGvlMTqPxJ844hNAneTzh9lPlYx0swai2RONOGLF0/0I9Ej5
TIgVvGykcoH3e39VGAUFd8qbLKneX3nPMjhe1+0dmLPhEGffO2ZEkhMBM0x6bhYX
XIsCXly5unN/Boosibvsd9isItsnC+4m3ELyREj1gTsCqIoZxFEq2iCPhfS7uPlQ
z8G0q0FJohNOJEXYzH96Z4xuI3zudux5PPiHNsCzoUs/X0ogda14zaolvvZPYaqg
g5zmZz6dHWnnKogsp0+Q1V3Nz1/GTCs6IDURSX+EPxst5qcin92Ft6TLOb0pu/dQ
BW90AGspoelB54iElwbmib58KBzLC8U0FZIfcuN/vOfEnv7ON4RAS/R6wKRMdPEy
Fm+Lr65QntaW2AVdxFM7EZfWLFOv741fMT3a1/l3Wou+nalxe7M+epFcn67XrkIi
fLnvg/rOUESNHmfuFIa9CAJdekM1WxCFBq6/rAxmHdnbEX3SCl0h1SrzaF336JQc
PMSNGiNjra5xO8CxAgMBAAECggGAfvBLXy5HO6fSJLrkAd2VG3fTfuDM+D3xMXGG
B9CSUDOvswbpNyB+WXT9AP0p/V+8UA1A0MfY6vHhE87oNm68NTyXCQfSgx3253su
BXbjebmTsTNfSjXPhDWZGomAXPp5lRoZoT6ihubsaBaIHY0rsgHXYB6M42CrCQcw
KBVQd2M8ta7blKrntAfSKqEoTTiDraYLKM50GLVJukKDIkwjBUZ6XQAs9HIXQvqL
SV+LcYGN1QvYTTpNgdV0b73pKGpXG8AvuwXrYFKTZeNMxPnbXd5NHLE6efuOHfeb
gYoDFy7NLSJa7DdpJIYMf0yMZQVOwdcKXiK2st+e0mUS0WHNhGKQAVc3wd+gzgtS
+s/hJk/ya/4CJwXahtbn5zhNDdbgMSt+m2LVRCIGd+JL14cd1bPySD9QL3EU7+9P
nt4S9wvu2lqa6VSK2I9tsjIgm7I7T5SUI3m+DnrpTzlpDCOqFccsSIlY5I+BD9ES
7bT57cRkyeWh5w43UQeSFhul5T0tAoHBAN7BjlT22hynPNPshNtJIj+YbAX9+MV9
FIjyPa1Say/PSXf9SvRWaTDuRWnFy4B9c12p6zwtbFjewn6OBCope26mmjVtii6t
4ABhA/v17nPUjLMQQZGIE0pHGKMpspmd3hqZcNomTtdTNy9X7NBCigJNeZR17TFm
3F2qh9oNJVbAgO54PbmFiWk0vMr0x6PWA0p3Ns/qPdu7s7EFonyOHs7f3E9MCYEd
3rp5IOJ5rzFR0acYbYhsOX4zRgMRrMYb5wKBwQDBjnlFzZVF56eK5iseEZrnay5p
CsLqxDGKr8wHFHQ8G9hGLTGOsaPd3RvAD2A7rQSNNHj2S2gv8I8DBOXzFhifE31q
Cy7Zh0HjAt6Tx5yL/lKAPMbDC3trUdITJugepR72t27UmLY0ZAX0SS8ZCRg+3dAS
Vdp3zkfOhlg3w92eQSdnU+hmr44AJL1cU+CLN7pCZgkaXzuULfs/+tPVVyeOHZX7
iA2fJ2ITRzO9XjclQ49itRJWqWcq22JqsgQ6a6cCgcBn9blxmcttd/eBiG7w0I71
UzOHEGKb+KYuy69RRpfTtlA5ebMTmYh6V5l5peA11VaULgslCKX6S+xFmA4Fh1qd
548sxDSrWGakhqKPYtWopVgM8ddIDlPCZK/w5jL+UpknnNj4VsyQ3btxkv1orMUw
EexeBzNtzO2noUDJ2TzF4g3KPb/A57ubqAs8RUUvB2B9zml8W3wHIvDX+yM8Mi/a
qMtvDrOY2NHsAUABsny67c6Ex3fHJYsnhNJ1+DfENZ0CgcBuewR983rhC/l2Lyst
Xp8suOEk1B+uIY6luvKal/JA3SP16pX+/Sar3SmZ1yz24ytV7j2dWC2AL69x6bnX
pyUmp9lOTlPPloTlLx4c/DM/NUuiJw7NBiDMgUeH5w1XcKjb6pg4gXJ/NRiw95UK
lUZhm/rIfHjXKceS+twf+IznaAk10Y82Db7gFhiAOuBQlt6aR+OqSfGYAycGvgVs
IPNTC1Aw4tfjoHc6ycmerciMXKPbk7+D9+4LaG4kuLfxIMECgcANm3mBWWJCFH3h
s2PXArzk1G9RKEmfUpfhVkeMhtD2/TMG3NPvrGpmjmPx5rf1DUxOUMJyu+B1VdZg
u0GOSkEiOfI3DxNs0GwzsL9/EYoelgGj7uc6IV9awhbzRPwro5nceGJspnWqXIVp
rawN1NFkKr5MCxl5Q4veocU94ThOlFdYgreyVX6s40ZL1eF0RvAQ+e0oFT7SfCHu
B3XwyYtAFsaO5r7oEc1Bv6oNSbE+FNJzRdjkWEIhdLVKlepil/w=
-----END RSA PRIVATE KEY-----`)
func dexMockHandler(t *testing.T, url string) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -123,6 +169,20 @@ func oidcMockHandler(t *testing.T, url string) func(http.ResponseWriter, *http.R
"claims_supported": ["sub", "aud", "exp"]
}`, url))
require.NoError(t, err)
case "/keys":
pubKey, err := jwt.ParseRSAPublicKeyFromPEM(Cert)
require.NoError(t, err)
jwks := jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{
{
Key: pubKey,
},
},
}
out, err := json.Marshal(jwks)
require.NoError(t, err)
_, err = io.WriteString(w, string(out))
require.NoError(t, err)
default:
w.WriteHeader(404)
}
@@ -137,4 +197,4 @@ func GetOIDCTestServer(t *testing.T) *httptest.Server {
oidcMockHandler(t, ts.URL)(w, r)
})
return ts
}
}