Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add generic JWT auth #20928

Open
wants to merge 25 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f9a6228
feat: Add JWT support to ArgoCD's OIDC authentication
wrmedford Nov 25, 2024
d62d916
(fix) Standardize on jwt/v4
wrmedford Nov 25, 2024
678805a
(feat) contextually call VerifyJWT if a JWKS url is set.
wrmedford Nov 25, 2024
8106163
(fix) deps bump.
wrmedford Nov 25, 2024
0180e81
(chore) lint fix.
wrmedford Nov 25, 2024
a168c83
(fix) generate JWT token during unit tests
wrmedford Nov 25, 2024
a180a8e
(chore) bump deps
wrmedford Nov 25, 2024
511162d
(fix) return public key instead of parsed token.
wrmedford Nov 25, 2024
0759945
(feat) add JWKS caching
wrmedford Nov 25, 2024
e0772d7
(chore) fix lint
wrmedford Nov 25, 2024
a463c2b
Merge branch 'master' into master
wrmedford Nov 25, 2024
5da2fb3
Merge branch 'master' into master
wrmedford Nov 28, 2024
933b0d3
Merge branch 'master' into master
wrmedford Dec 5, 2024
2b3ac11
feat: Add JWT audience validation with support for single and multipl…
wrmedford Dec 5, 2024
933539f
(fix) Update settings to allow audience configuration.
wrmedford Dec 5, 2024
f22a0d2
Merge branch 'master' into master
wrmedford Dec 5, 2024
c27f45b
Merge branch 'argoproj:master' into master
wrmedford Dec 21, 2024
2251194
(docs) add docs for external JWT auth
wrmedford Dec 21, 2024
5cd320f
Merge branch 'master' into master
wrmedford Dec 22, 2024
47329f5
Log cacheTTL
wrmedford Dec 27, 2024
e0dfd48
Whitespace
wrmedford Dec 27, 2024
ac97c7c
(lint) remove whitespace due to fmt complaining
wrmedford Dec 27, 2024
1e36bf6
Merge branch 'master' into master
wrmedford Dec 31, 2024
c006bd3
(fix) make audience and alg errors clearer for end users
wrmedford Dec 31, 2024
989a45d
(lint) use correct format for non-formatted errors
wrmedford Dec 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/operator-manual/user-management/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,59 @@ Add a `rootCA` to your `oidc.config` which contains the PEM encoded root certifi
-----END CERTIFICATE-----
```

## External JWT Authentication

Argo CD can be configured to verify JSON Web Tokens (JWTs) issued by an external authentication provider. This allows you to integrate Argo CD with existing authentication systems that issue JWTs.

To configure external JWT authentication, add the JWT configuration to the `argocd-cm` ConfigMap:

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
labels:
app.kubernetes.io/name: argocd-cm
app.kubernetes.io/part-of: argocd
wrmedford marked this conversation as resolved.
Show resolved Hide resolved
data:
jwt.config: |
# The HTTP header name to extract JWT from
headerName: "X-Auth-Token"
# The JWT claim to use for the user's email
emailClaim: "email"
# The JWT claim to use for the username
usernameClaim: "preferred_username"
# The URL to fetch the JWKS from
jwkSetURL: "https://auth.example.com/.well-known/jwks.json"
# Optional: How long to cache the JWKS
cacheTTL: "1h"
# Optional: Expected audience for the JWT
audience: "https://argocd.example.com"
```

The following configuration options are available:

* `headerName`: The HTTP header name to extract the JWT from (required)
* `emailClaim`: The JWT claim to use for the user's email (required)
* `usernameClaim`: The JWT claim to use for the username (required)
* `jwkSetURL`: The URL to fetch the JSON Web Key Set (JWKS) from (required)
* `cacheTTL`: How long to cache the JWKS before refetching (optional, default: 5m)
* `audience`: Expected audience value in the JWT claims (optional)

When JWT authentication is configured, Argo CD will:

1. Extract the JWT from the specified HTTP header
2. Verify the JWT signature using the public keys from the JWKS endpoint
3. Validate the token expiry and audience (if configured)
4. Extract the username and email from the specified claims
5. Use these values to identify the user within Argo CD

The external authentication provider must:

1. Issue valid JWTs signed with RSA
2. Expose a JWKS endpoint that provides the public keys
3. Include the configured username and email claims in the JWT payload

## SSO Further Reading

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ require (
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/square/go-jose.v2 v2.6.0
gopkg.in/warnings.v0 v0.1.2 // indirect
k8s.io/cli-runtime v0.31.0 // indirect
k8s.io/component-base v0.31.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,8 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/retry.v1 v1.0.3 h1:a9CArYczAVv6Qs6VGoLMio99GEs7kY9UzSF9+LD+iGs=
gopkg.in/retry.v1 v1.0.3/go.mod h1:FJkXmWiMaAo7xB+xhvDF59zhfjDWyzmyAxiT4dB688g=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
Expand Down
4 changes: 4 additions & 0 deletions util/oidc/oidc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ func (p *fakeProvider) Verify(_ string, _ *settings.ArgoCDSettings) (*gooidc.IDT
return nil, nil
}

func (p *fakeProvider) VerifyJWT(_ string, _ *settings.ArgoCDSettings) (*jwt.Token, error) {
return nil, nil
}

func TestHandleCallback(t *testing.T) {
app := ClientApp{provider: &fakeProvider{}, settings: &settings.ArgoCDSettings{}}

Expand Down
137 changes: 135 additions & 2 deletions util/oidc/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@ package oidc

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"

gooidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v4"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use jwt v5.

log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"gopkg.in/square/go-jose.v2"

"github.com/argoproj/argo-cd/v2/util/security"
"github.com/argoproj/argo-cd/v2/util/settings"
Expand All @@ -28,21 +33,29 @@ type Provider interface {
ParseConfig() (*OIDCConfiguration, error)

Verify(tokenString string, argoSettings *settings.ArgoCDSettings) (*gooidc.IDToken, error)

VerifyJWT(tokenString string, argoSettings *settings.ArgoCDSettings) (*jwt.Token, error)
}

type providerImpl struct {
issuerURL string
client *http.Client
goOIDCProvider *gooidc.Provider

jwksCache *jose.JSONWebKeySet
jwksExpiry time.Time
jwksCacheMux sync.Mutex
defaultCacheTTL time.Duration
}

var _ Provider = &providerImpl{}

// NewOIDCProvider initializes an OIDC provider
func NewOIDCProvider(issuerURL string, client *http.Client) Provider {
return &providerImpl{
issuerURL: issuerURL,
client: client,
issuerURL: issuerURL,
client: client,
defaultCacheTTL: 5 * time.Minute,
}
}

Expand Down Expand Up @@ -149,6 +162,126 @@ func (p *providerImpl) Verify(tokenString string, argoSettings *settings.ArgoCDS
return idToken, nil
}

// VerifyJWT verifies a JWT token using the configured JWK Set URL
func (p *providerImpl) VerifyJWT(tokenString string, argoSettings *settings.ArgoCDSettings) (*jwt.Token, error) {
if argoSettings.JWTConfig == nil || argoSettings.JWTConfig.JWKSetURL == "" {
return nil, errors.New("JWT configuration not found")
}

cacheTTL := p.defaultCacheTTL
if argoSettings.JWTConfig.CacheTTL != "" {
ttl, err := time.ParseDuration(argoSettings.JWTConfig.CacheTTL)
if err != nil {
log.Warnf("Invalid JWT cache TTL %q, using default (%d)", argoSettings.JWTConfig.CacheTTL, cacheTTL)
} else {
cacheTTL = ttl
}
}

jwks, err := p.getJWKS(argoSettings.JWTConfig.JWKSetURL, cacheTTL)
if err != nil {
return nil, fmt.Errorf("failed to get JWKS: %w", err)
}

token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}

kid, ok := token.Header["kid"].(string)
if !ok {
return nil, errors.New("kid header not found in token")
}

var key *jose.JSONWebKey
for _, k := range jwks.Keys {
if k.KeyID == kid {
key = &k
break
}
}
if key == nil {
return nil, fmt.Errorf("no key found for kid %q", kid)
}

if key.Algorithm != "" && key.Algorithm != token.Header["alg"] {
return nil, fmt.Errorf("algorithm mismatch for kid %q: expected %v, got %v. JWT issuer may be misconfigured/broken", kid, key.Algorithm, token.Header["alg"])
}

return key.Key, nil
})
if err != nil {
wrmedford marked this conversation as resolved.
Show resolved Hide resolved
return nil, fmt.Errorf("failed to parse/verify JWT: %w", err)
}

claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("invalid token claims")
}

if argoSettings.JWTConfig.EmailClaim != "" {
if _, ok := claims[argoSettings.JWTConfig.EmailClaim]; !ok {
return nil, fmt.Errorf("required email claim %q not found", argoSettings.JWTConfig.EmailClaim)
}
}

if argoSettings.JWTConfig.UsernameClaim != "" {
if _, ok := claims[argoSettings.JWTConfig.UsernameClaim]; !ok {
return nil, fmt.Errorf("required username claim %q not found", argoSettings.JWTConfig.UsernameClaim)
}
}

// Verify audience if configured
if aud, ok := claims["aud"].(string); ok {
if argoSettings.JWTConfig.Audience != "" && aud != argoSettings.JWTConfig.Audience {
return nil, fmt.Errorf("invalid audience claim, expected %q, got %q. Perhaps someone is trying to use a token from a different issuer", argoSettings.JWTConfig.Audience, aud)
}
} else if audList, ok := claims["aud"].([]interface{}); ok {
if argoSettings.JWTConfig.Audience != "" {
validAud := false
for _, a := range audList {
if a.(string) == argoSettings.JWTConfig.Audience {
validAud = true
break
}
}
if !validAud {
return nil, fmt.Errorf("invalid audience claim, expected aud %q not found in %v. Perhaps someone is trying to use a token from a different issuer", argoSettings.JWTConfig.Audience, audList)
}
}
} else if argoSettings.JWTConfig.Audience != "" {
return nil, errors.New("audience claim not found or invalid type")
}

return token, nil
}

func (p *providerImpl) getJWKS(jwksURL string, cacheTTL time.Duration) (*jose.JSONWebKeySet, error) {
p.jwksCacheMux.Lock()
defer p.jwksCacheMux.Unlock()

if p.jwksCache != nil && time.Now().Before(p.jwksExpiry) {
return p.jwksCache, nil
}

resp, err := http.Get(jwksURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
}
defer resp.Body.Close()

var jwks jose.JSONWebKeySet
err = json.NewDecoder(resp.Body).Decode(&jwks)
if err != nil {
return nil, fmt.Errorf("failed to decode JWKS: %w", err)
}

p.jwksCache = &jwks
p.jwksExpiry = time.Now().Add(cacheTTL)

return &jwks, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit hesitant of having our own implementation of verification. Is it possible to use some libraries for that? E.g. would this https://pkg.go.dev/gopkg.in/square/go-jose.v2/jwt#Claims.Validate be useful?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea that's fair. I'll swap that out.


func (p *providerImpl) verify(clientID, tokenString string, skipClientIDCheck bool) (*gooidc.IDToken, error) {
ctx := context.Background()
prov, err := p.provider()
Expand Down
Loading
Loading