Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions api/v1beta3/auth_config_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@ type JwtAuthenticationSpec struct {
// +optional
IssuerUrl string `json:"issuerUrl,omitempty"`

// The expected value of the "iss" (issuer) claim of the JWT.
// If set, Authorino rejects, at the authentication phase, any token whose "iss" claim does not equal this value.
// If omitted, the issuer claim is not verified — it can still be checked via an authorization rule (CEL, pattern-matching or OPA).
// Applies to both issuerUrl and jwksUrl.
// +optional
Issuer string `json:"issuer,omitempty"`

// Decides how long the OIDC configuration will be cached.
// If omitted or set to zero, Authorino will never refresh the OIDC configuration.
// This configuration does not affect the caching of JSON Web Keys (JWK), which is always updated whenever the kid of a JWT does not match any of the cached JWKs (https://openid.net/specs/openid-connect-core-1_0.html#RotateSigKeys)
Expand Down
7 changes: 5 additions & 2 deletions controllers/auth_config_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,12 +381,15 @@ func (r *AuthConfigReconciler) translateAuthConfig(ctx context.Context, authConf
case api.JwtAuthentication:
var jwtVerifier identity_evaluators.JWTVerifier
if identity.Jwt.IssuerUrl != "" {
jwtVerifier = identity_evaluators.NewOIDCProviderVerifier(ctx, identity.Jwt.IssuerUrl, identity.Jwt.TTL, identity.Jwt.Timeout)
jwtVerifier = identity_evaluators.NewOIDCProviderVerifier(ctx, identity.Jwt.IssuerUrl, identity.Jwt.Issuer, identity.Jwt.TTL, identity.Jwt.Timeout)
} else if identity.Jwt.JwksUrl != "" {
jwtVerifier = identity_evaluators.NewJwksVerifier(ctx, identity.Jwt.JwksUrl, identity.Jwt.Timeout)
jwtVerifier = identity_evaluators.NewJwksVerifier(ctx, identity.Jwt.JwksUrl, identity.Jwt.Issuer, identity.Jwt.Timeout)
} else {
return nil, fmt.Errorf("missing issuerUrl or jwksUrl for JWT authentication method") // should never happen if properly validated at the API level
}
if identity.Jwt.Issuer == "" {
log.FromContext(ctxWithLogger).Info("JWT authentication does not verify the token issuer (iss) claim; set issuer to enforce it, or verify it via an authorization rule", "authentication", identityCfgName)
}
translatedIdentity.JWTAuthentication = identity_evaluators.NewJWTAuthentication(jwtVerifier, authCred)

// apiKey
Expand Down
51 changes: 51 additions & 0 deletions controllers/auth_config_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"context"
"fmt"
"os"
"strings"
"testing"

"github.com/go-logr/logr/funcr"
api "github.com/kuadrant/authorino/api/v1beta3"
"github.com/kuadrant/authorino/pkg/evaluators"
"github.com/kuadrant/authorino/pkg/httptest"
Expand Down Expand Up @@ -382,6 +384,55 @@ func TestEmptyAuthConfigIdentitiesDefaultsToAnonymousAccess(t *testing.T) {
assert.Equal(t, len(config.IdentityConfigs), 1)
}

func translateJwtAuthConfig(t *testing.T, issuer string) []string {
t.Helper()

var logs []string
recorder := funcr.New(func(prefix, args string) {
logs = append(logs, args)
}, funcr.Options{})

r := &AuthConfigReconciler{}
_, err := r.translateAuthConfig(log.IntoContext(context.TODO(), recorder), &api.AuthConfig{
Spec: api.AuthConfigSpec{
Hosts: []string{"app.com"},
Authentication: map[string]api.AuthenticationSpec{
"keycloak": {
AuthenticationMethodSpec: api.AuthenticationMethodSpec{
Jwt: &api.JwtAuthenticationSpec{
IssuerUrl: "http://127.0.0.1:9001/auth/realms/demo",
Issuer: issuer,
},
},
},
},
},
})
assert.NilError(t, err)
return logs
}

func logsContain(logs []string, substr string) bool {
for _, l := range logs {
if strings.Contains(l, substr) {
return true
}
}
return false
}

const unsafeIssuerDefaultLogMsg = "does not verify the token issuer (iss) claim"

func TestJwtIssuerUnsetLogsWarning(t *testing.T) {
logs := translateJwtAuthConfig(t, "")
assert.Check(t, logsContain(logs, unsafeIssuerDefaultLogMsg), "expected an INFO log warning about the unverified issuer default")
}

func TestJwtIssuerSetDoesNotLogWarning(t *testing.T) {
logs := translateJwtAuthConfig(t, "http://127.0.0.1:9001/auth/realms/demo")
assert.Check(t, !logsContain(logs, unsafeIssuerDefaultLogMsg), "did not expect the issuer warning when issuer is set")
}

func TestBootstrapIndex(t *testing.T) {
mockController := gomock.NewController(t)
defer mockController.Finish()
Expand Down
2 changes: 2 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ The `kid` claim stated in the JWT header must match one of the keys cached by Au

After veryfing and validating a JWT, the decoded payload of the JWT is appended to the authorization JSON as the resolved identity object.

By default, Authorino does **not** verify the `iss` (issuer) claim of the JWT; the issuer can be enforced with an authorization rule (CEL, pattern-matching or OPA) using the resolved identity. Set `authentication.jwt.issuer` to the expected issuer to have Authorino reject, already at the authentication phase, any token whose `iss` claim does not equal that value. This is recommended whenever distinct issuers may share signing keys (e.g. multi-tenant identity providers, or Festival Wristbands issued by more than one `AuthConfig` backed by the same signing-key Secret). The `issuer` field applies to both `issuerUrl` and `jwksUrl`, and it usually matches `issuerUrl` — but it may differ when the OpenID Connect discovery endpoint is reached at a different URL than the issuer stamped into the tokens (e.g. cluster-internal discovery vs external issuer).

_Important!_ Authorino does **not** implement [OAuth2 grants](https://datatracker.ietf.org/doc/html/rfc6749#section-4) nor [OIDC authentication flows](https://openid.net/specs/openid-connect-core-1_0.html#Authentication). As a common recommendation of good practice, obtaining and refreshing access tokens is for clients to negotiate directly with the auth servers and token issuers. Authorino will only validate those tokens using the parameters provided by the trusted issuer authorities.

For an excellent summary of the underlying concepts and standards that relate OpenID Connect and JSON Object Signing and Encryption (JOSE), see this [article](https://access.redhat.com/blogs/766093/posts/1976593) by Jan Rusnacko. For official specification and RFCs, see [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html), [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html), [JSON Web Token (JWT) (RFC7519)](https://datatracker.ietf.org/doc/html/rfc7519), and [JSON Object Signing and Encryption (JOSE)](http://www.iana.org/assignments/jose/jose.xhtml).
Expand Down
8 changes: 8 additions & 0 deletions install/crd/authorino.kuadrant.io_authconfigs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,14 @@ spec:
jwt:
description: Authentication based on JWT tokens.
properties:
issuer:
description: |-
The expected value of the "iss" (issuer) claim of the JWT.
If set, Authorino rejects, at the authentication phase, any token whose "iss" claim does not equal this value.
If omitted, the issuer claim is not verified — it can still be checked via an authorization rule (CEL, pattern-matching or OPA).
Applies to both issuerUrl and jwksUrl. It usually matches issuerUrl, but may differ when the OpenID Connect discovery
endpoint is reached at a different URL than the issuer stamped into the tokens (e.g. cluster-internal discovery vs external issuer).
type: string
issuerUrl:
description: |-
URL of the OpenID Connect (OIDC) token issuer endpoint.
Expand Down
8 changes: 8 additions & 0 deletions install/manifests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,14 @@ spec:
jwt:
description: Authentication based on JWT tokens.
properties:
issuer:
description: |-
The expected value of the "iss" (issuer) claim of the JWT.
If set, Authorino rejects, at the authentication phase, any token whose "iss" claim does not equal this value.
If omitted, the issuer claim is not verified — it can still be checked via an authorization rule (CEL, pattern-matching or OPA).
Applies to both issuerUrl and jwksUrl. It usually matches issuerUrl, but may differ when the OpenID Connect discovery
endpoint is reached at a different URL than the issuer stamped into the tokens (e.g. cluster-internal discovery vs external issuer).
type: string
issuerUrl:
description: |-
URL of the OpenID Connect (OIDC) token issuer endpoint.
Expand Down
48 changes: 33 additions & 15 deletions pkg/evaluators/identity/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,17 @@ const (
msg_oidcProviderVerifierConfigRefreshSuccess = "openid connect configuration updated"
msg_oidcProviderVerifierConfigRefreshError = "failed to discovery openid connect configuration"
msg_oidcProviderVerifierConfigRefreshDisabled = "auto-refresh of openid connect configuration disabled"
msg_jwksVerifierFailedToCreate = "failed to create JWKS verifier"
msg_jwtVerifierDoesNotStoreOpenIdConfig = "rule does not store openid configuration"
)

var tokenVerifierConfig = &oidc.Config{SkipClientIDCheck: true, SkipIssuerCheck: true}
// oidcConfig returns the go-oidc verifier config shared by both JWT verifier flavors.
// SkipClientIDCheck is always on: Authorino is not an OAuth2 client and has no audience of
// its own to match. The issuer check is enabled only when an expected issuer is configured —
// an empty issuer means "do not verify the iss claim" (the default, backwards compatible),
// a non-empty issuer means "reject any token whose iss does not equal it".
func oidcConfig(issuer string) *oidc.Config {
return &oidc.Config{SkipClientIDCheck: true, SkipIssuerCheck: issuer == ""}
}

type JWTAuthentication struct {
auth.AuthCredentials
Expand Down Expand Up @@ -93,16 +99,20 @@ type JWTVerifier interface {

type oidcProviderVerifier struct {
issuerUrl string
issuer string
config *oidc.Config
timeout *int

mu sync.RWMutex
provider *oidc.Provider
refresher workers.Worker
}

func NewOIDCProviderVerifier(ctx gocontext.Context, issuerUrl string, ttl int, timeout *int) JWTVerifier {
func NewOIDCProviderVerifier(ctx gocontext.Context, issuerUrl string, issuer string, ttl int, timeout *int) JWTVerifier {
v := &oidcProviderVerifier{
issuerUrl: issuerUrl,
issuer: issuer,
config: oidcConfig(issuer),
timeout: timeout,
}
ctxWithLogger := log.IntoContext(ctx, log.FromContext(ctx).WithName("jwt"))
Expand All @@ -117,10 +127,11 @@ func (v *oidcProviderVerifier) Verify(ctx gocontext.Context, rawIDToken string)
return nil, errors.New(msg_oidcProviderVerifierConfigMissingError)
}

v.mu.RLock()
defer v.mu.RUnlock()

idToken, err := provider.Verifier(tokenVerifierConfig).Verify(ctx, rawIDToken)
// No lock is held across Verify on purpose: getOpenIdProvider already returned a stable
// provider snapshot under its own lock, the go-oidc Provider is immutable, and v.config is
// set once at construction. Holding a read lock across the crypto checks and lazy JWKS fetch
// would needlessly block the background refresher's write lock in getOpenIdProvider.
idToken, err := provider.Verifier(v.config).Verify(ctx, rawIDToken)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -176,6 +187,15 @@ func (v *oidcProviderVerifier) getOpenIdProvider(ctx gocontext.Context, force bo
httpClient := httputil.NewClientWithTracing(ctx, v.timeout)
discoveryCtx := oidc.ClientContext(gocontext.Background(), httpClient)

// When an expected issuer is configured that differs from the discovery URL, pin it so
// discovery and JWKS are still fetched from issuerUrl while the verifier enforces the
// token's iss against the configured issuer. This supports setups where the OpenID
// Connect discovery endpoint is reached at a different URL than the issuer stamped into
// tokens (e.g. cluster-internal discovery vs external issuer).
if v.issuer != "" && v.issuer != v.issuerUrl {
discoveryCtx = oidc.InsecureIssuerURLContext(discoveryCtx, v.issuer)
}

if provider, err := oidc.NewProvider(discoveryCtx, v.issuerUrl); err != nil {
log.FromContext(ctx).Error(err, msg_oidcProviderVerifierConfigRefreshError, "issuerUrl", v.issuerUrl)
} else {
Expand All @@ -200,27 +220,25 @@ func (v *oidcProviderVerifier) setupOpenIdProviderRefresh(ctx gocontext.Context,
}

type jwksVerifier struct {
jwks oidc.KeySet
verifier *oidc.IDTokenVerifier
}

func NewJwksVerifier(ctx gocontext.Context, jwksUrl string, timeout *int) JWTVerifier {
func NewJwksVerifier(ctx gocontext.Context, jwksUrl string, issuer string, timeout *int) JWTVerifier {
// Create HTTP client with timeout and trace propagation.
// Use Background context for request lifecycle (to avoid cancellation from reconciliation),
// but propagate trace context from caller's ctx for observability.
httpClient := httputil.NewClientWithTracing(ctx, timeout)
jwkCtx := oidc.ClientContext(gocontext.Background(), httpClient)

// The remote key set self-refreshes on key rotation, and issuer and config are fixed for the
// lifetime of the verifier, so it can be built once here and reused for every request.
return &jwksVerifier{
jwks: oidc.NewRemoteKeySet(jwkCtx, jwksUrl),
verifier: oidc.NewVerifier(issuer, oidc.NewRemoteKeySet(jwkCtx, jwksUrl), oidcConfig(issuer)),
}
}

func (v *jwksVerifier) Verify(ctx gocontext.Context, rawIDToken string) (*oidc.IDToken, error) {
verifier := oidc.NewVerifier("", v.jwks, tokenVerifierConfig)
if verifier == nil {
return nil, errors.New(msg_jwksVerifierFailedToCreate)
}
return verifier.Verify(ctx, rawIDToken)
return v.verifier.Verify(ctx, rawIDToken)
}

// impl: auth.AuthConfigCleaner
Expand Down
Loading
Loading