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
19 changes: 19 additions & 0 deletions changelog/unreleased/feature-oidc-audiences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Enhancement: Optional OIDC access token audience validation

The proxy can now restrict OIDC access tokens to configured audiences using
PROXY_OIDC_AUDIENCES or oidc.audiences in proxy.yaml. For example, setting
PROXY_OIDC_AUDIENCES=opencloud,opencloud-api requires at least one of these values
in the access token's aud claim. Matching is exact and case-sensitive, and both
string and array claims are supported.

The list defaults to empty to preserve existing deployments. When OIDC is active
and audience validation is disabled, the proxy emits one startup warning.
Enabling validation is recommended for production, especially when an identity
provider serves multiple applications. Administrators must configure the selected
audience in their identity provider's access tokens before enabling the check.

Configured audiences require JWT verification. Missing, empty, malformed or
nonmatching token audiences are rejected, including when Userinfo is already
cached. Changing the configuration requires restarting the proxy.

https://github.com/opencloud-eu/opencloud/issues/3456
240 changes: 240 additions & 0 deletions pkg/oidc/access_token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package oidc_test

import (
"context"
"encoding/json"
"testing"
"time"

"github.com/golang-jwt/jwt/v5"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/oidc"
"github.com/opencloud-eu/opencloud/services/proxy/pkg/config"
"github.com/stretchr/testify/require"
)

func TestAccessTokenAudiences(t *testing.T) {
key := newRSAKey(t)
tests := []struct {
name string
audiences []string
aud any
missing bool
wantErr error
}{
{name: "disabled missing", missing: true},
{name: "disabled foreign", aud: "immich"},
{name: "disabled null", aud: nil},
{name: "disabled empty array", aud: []string{}},
{name: "explicitly empty configuration", audiences: []string{}, aud: "immich"},
{name: "single audience", audiences: []string{"opencloud"}, aud: "opencloud"},
{name: "array first match", audiences: []string{"opencloud"}, aud: []string{"opencloud", "immich"}},
{name: "array last match", audiences: []string{"opencloud"}, aud: []string{"immich", "opencloud"}},
{name: "any allowed audience", audiences: []string{"opencloud", "opencloud-api"}, aud: "opencloud-api"},
{name: "any allowed audience in array", audiences: []string{"opencloud", "opencloud-api"}, aud: []string{"immich", "opencloud-api"}},
{name: "duplicate audiences", audiences: []string{"opencloud", "opencloud"}, aud: []string{"opencloud", "opencloud"}},
{name: "URI audience", audiences: []string{"https://cloud.example/api"}, aud: "https://cloud.example/api"},
{name: "foreign", audiences: []string{"opencloud"}, aud: "immich", wantErr: jwt.ErrTokenInvalidAudience},
{name: "foreign array", audiences: []string{"opencloud"}, aud: []string{"immich", "account"}, wantErr: jwt.ErrTokenInvalidAudience},
{name: "case sensitive", audiences: []string{"opencloud"}, aud: "OpenCloud", wantErr: jwt.ErrTokenInvalidAudience},
{name: "exact match", audiences: []string{"opencloud"}, aud: "opencloud-api", wantErr: jwt.ErrTokenInvalidAudience},
{name: "no token normalization", audiences: []string{"opencloud"}, aud: " opencloud ", wantErr: jwt.ErrTokenInvalidAudience},
{name: "no wildcard matching", audiences: []string{"*"}, aud: "opencloud", wantErr: jwt.ErrTokenInvalidAudience},
{name: "missing", audiences: []string{"opencloud"}, missing: true, wantErr: jwt.ErrTokenRequiredClaimMissing},
{name: "null", audiences: []string{"opencloud"}, aud: nil, wantErr: jwt.ErrTokenRequiredClaimMissing},
{name: "empty string", audiences: []string{"opencloud"}, aud: "", wantErr: jwt.ErrTokenRequiredClaimMissing},
{name: "empty array", audiences: []string{"opencloud"}, aud: []string{}, wantErr: jwt.ErrTokenRequiredClaimMissing},
{name: "array empty string", audiences: []string{"opencloud"}, aud: []string{""}, wantErr: jwt.ErrTokenRequiredClaimMissing},
{name: "number", audiences: []string{"opencloud"}, aud: 123, wantErr: jwt.ErrTokenMalformed},
{name: "object", audiences: []string{"opencloud"}, aud: map[string]string{"aud": "opencloud"}, wantErr: jwt.ErrTokenMalformed},
{name: "mixed array", audiences: []string{"opencloud"}, aud: []any{"opencloud", 123}, wantErr: jwt.ErrTokenMalformed},
{name: "null array entry", audiences: []string{"opencloud"}, aud: []any{"opencloud", nil}, wantErr: jwt.ErrTokenMalformed},
{name: "disabled still rejects invalid type", aud: 123, wantErr: jwt.ErrTokenMalformed},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
claims := jwt.MapClaims{
"iss": "https://issuer.example",
"sub": "alice",
"sid": "session",
"exp": time.Now().Add(time.Hour).Unix(),
}
if !tt.missing {
claims["aud"] = tt.aud
}
client := newAccessTokenTestClient(key, tt.audiences, &oidc.ProviderMetadata{})
registered, all, err := client.VerifyAccessToken(context.Background(), signAccessToken(t, key, claims))
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
require.Empty(t, all, "unverified claims must not be returned")
return
}
require.NoError(t, err)
require.Equal(t, "alice", registered.Subject)
require.Equal(t, "session", registered.SessionID)
require.Equal(t, "alice", all["sub"])
})
}
}

func TestAccessTokenValidationWithAudiences(t *testing.T) {
key, otherKey := newRSAKey(t), newRSAKey(t)
tests := []struct {
name string
issuer string
provider *oidc.ProviderMetadata
signingKey *signingKey
exp time.Time
nbf time.Time
wantErr error
}{
{name: "invalid signature", signingKey: otherKey, wantErr: jwt.ErrTokenSignatureInvalid},
{name: "invalid issuer", issuer: "https://other.example", wantErr: jwt.ErrTokenInvalidIssuer},
{name: "expired", exp: time.Now().Add(-time.Hour), wantErr: jwt.ErrTokenExpired},
{name: "not yet valid", nbf: time.Now().Add(time.Hour), wantErr: jwt.ErrTokenNotValidYet},
{name: "AD FS access token issuer", issuer: "https://adfs.example", provider: &oidc.ProviderMetadata{AccessTokenIssuer: "https://adfs.example"}},
{name: "AD FS rejects discovery issuer", provider: &oidc.ProviderMetadata{AccessTokenIssuer: "https://adfs.example"}, wantErr: jwt.ErrTokenInvalidIssuer},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.issuer == "" {
tt.issuer = "https://issuer.example"
}
if tt.provider == nil {
tt.provider = &oidc.ProviderMetadata{}
}
if tt.signingKey == nil {
tt.signingKey = key
}
if tt.exp.IsZero() {
tt.exp = time.Now().Add(time.Hour)
}
claims := jwt.MapClaims{"iss": tt.issuer, "sub": "alice", "aud": "opencloud", "exp": tt.exp.Unix()}
if !tt.nbf.IsZero() {
claims["nbf"] = tt.nbf.Unix()
}
client := newAccessTokenTestClient(key, []string{"opencloud"}, tt.provider)
_, _, err := client.VerifyAccessToken(context.Background(), signAccessToken(t, tt.signingKey, claims))
require.ErrorIs(t, err, tt.wantErr)
})
}
}

func TestAccessTokenAudienceConfiguration(t *testing.T) {
for _, method := range []string{config.AccessTokenVerificationNone, ""} {
t.Run("incompatible method "+method, func(t *testing.T) {
// No HTTP client is supplied: invalid configuration must fail before discovery.
client := oidc.NewOIDCClient(
oidc.WithAccessTokenVerifyMethod(method),
oidc.WithAccessTokenAudiences([]string{"opencloud"}),
)
_, _, err := client.VerifyAccessToken(context.Background(), "opaque-token")
require.ErrorContains(t, err, "requires the jwt verification method")
})
}
for _, audiences := range [][]string{{""}, {" \t"}, {"opencloud", ""}} {
client := oidc.NewOIDCClient(
oidc.WithAccessTokenVerifyMethod(config.AccessTokenVerificationJWT),
oidc.WithAccessTokenAudiences(audiences),
)
_, _, err := client.VerifyAccessToken(context.Background(), "token")
require.ErrorContains(t, err, "empty or whitespace-only")
}
t.Run("none remains compatible when disabled", func(t *testing.T) {
client := oidc.NewOIDCClient(
oidc.WithLogger(log.NopLogger()),
oidc.WithAccessTokenVerifyMethod(config.AccessTokenVerificationNone),
oidc.WithProviderMetadata(&oidc.ProviderMetadata{}),
)
_, _, err := client.VerifyAccessToken(context.Background(), "opaque-token")
require.NoError(t, err)
})
t.Run("caller cannot mutate the policy", func(t *testing.T) {
key := newRSAKey(t)
audiences := []string{"opencloud"}
client := newAccessTokenTestClient(key, audiences, &oidc.ProviderMetadata{})
audiences[0] = "immich"
_, _, err := client.VerifyAccessToken(context.Background(), signAccessToken(t, key,
jwt.MapClaims{"iss": "https://issuer.example", "aud": "immich"}))
require.ErrorIs(t, err, jwt.ErrTokenInvalidAudience)
})
}

func TestAccessTokenAudiencesDoNotApplyToLogoutTokens(t *testing.T) {
key := newRSAKey(t)
client := newAccessTokenTestClient(key, []string{"opencloud"}, &oidc.ProviderMetadata{})
token := signAccessToken(t, key, jwt.MapClaims{
"iss": "https://issuer.example",
"sub": "alice",
"aud": "web-client",
"events": map[string]any{
"http://schemas.openid.net/event/backchannel-logout": map[string]any{},
},
})
_, err := client.VerifyLogoutToken(context.Background(), token)
require.NoError(t, err)
}

func TestAccessTokenClaimExtraction(t *testing.T) {
key := newRSAKey(t)
client := newAccessTokenTestClient(key, []string{"opencloud"}, &oidc.ProviderMetadata{})
t.Run("preserves arbitrary claims and numeric types", func(t *testing.T) {
claims := jwt.MapClaims{
"iss": "https://issuer.example", "aud": "opencloud", "sub": "alice", "sid": "session",
"exp": 4102444800.75, "groups": []any{"users", "engineering"},
"profile": map[string]any{"enabled": true, "score": 1.25}, "custom": nil,
}
registered, all, err := client.VerifyAccessToken(context.Background(), signAccessToken(t, key, claims))
require.NoError(t, err)
require.Equal(t, "alice", registered.Subject)
require.Equal(t, "session", registered.SessionID)
require.EqualValues(t, 4102444800, registered.ExpiresAt.Unix())
require.Equal(t, claims, all)
})
t.Run("preserves malformed map claim errors", func(t *testing.T) {
// Typed claims ignore this custom field; decoding MapClaims must still
// reject its overflowing number and preserve the JWT and JSON errors.
_, _, err := client.VerifyAccessToken(context.Background(), signAccessToken(t, key, jwt.MapClaims{
"iss": "https://issuer.example", "aud": "opencloud", "custom": json.Number("1e1000"),
}))
require.ErrorIs(t, err, jwt.ErrTokenMalformed)
var jsonError *json.UnmarshalTypeError
require.ErrorAs(t, err, &jsonError)
require.Equal(t, "number 1e1000", jsonError.Value)
})
t.Run("retains empty map for null payload", func(t *testing.T) {
// With issuer/audience checks omitted, the client API previously accepted
// a signed null payload and returned an initialized, non-nil empty map.
client := oidc.NewOIDCClient(
oidc.WithLogger(log.NopLogger()), oidc.WithJWKS(key.jwks),
oidc.WithProviderMetadata(&oidc.ProviderMetadata{}),
oidc.WithAccessTokenVerifyMethod(config.AccessTokenVerificationJWT),
)
registered, all, err := client.VerifyAccessToken(context.Background(), signAccessToken(t, key, nil))
require.NoError(t, err)
require.Equal(t, oidc.RegClaimsWithSID{}, registered)
require.NotNil(t, all)
require.Empty(t, all)
})
}

func newAccessTokenTestClient(key *signingKey, audiences []string, provider *oidc.ProviderMetadata) oidc.OIDCClient {
return oidc.NewOIDCClient(
oidc.WithLogger(log.NopLogger()),
oidc.WithOidcIssuer("https://issuer.example"),
oidc.WithAccessTokenVerifyMethod(config.AccessTokenVerificationJWT),
oidc.WithAccessTokenAudiences(audiences),
oidc.WithJWKS(key.jwks),
oidc.WithProviderMetadata(provider),
)
}

func signAccessToken(t *testing.T, key *signingKey, claims jwt.MapClaims) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = "1"
signed, err := token.SignedString(key.priv)
require.NoError(t, err)
return signed
}
26 changes: 24 additions & 2 deletions pkg/oidc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type oidcClient struct {
providerLock *sync.Mutex
skipIssuerValidation bool
accessTokenVerifyMethod string
accessTokenAudiences []string
remoteKeySet KeySet
algorithms []string

Expand Down Expand Up @@ -91,6 +92,7 @@ func NewOIDCClient(opts ...Option) OIDCClient {
issuer: options.OIDCIssuer,
httpClient: options.HTTPClient,
accessTokenVerifyMethod: options.AccessTokenVerifyMethod,
accessTokenAudiences: options.AccessTokenAudiences,
JWKSOptions: options.JWKSOptions, // TODO I don't like that we pass down config options ...
JWKS: options.JWKS,
providerLock: &sync.Mutex{},
Expand Down Expand Up @@ -270,6 +272,14 @@ func (c *oidcClient) UserInfo(ctx context.Context, tokenSource oauth2.TokenSourc
}

func (c *oidcClient) VerifyAccessToken(ctx context.Context, token string) (RegClaimsWithSID, jwt.MapClaims, error) {
if len(c.accessTokenAudiences) > 0 && c.accessTokenVerifyMethod != config.AccessTokenVerificationJWT {
return RegClaimsWithSID{}, jwt.MapClaims{}, errors.New("access token audience validation requires the jwt verification method")
}
for _, audience := range c.accessTokenAudiences {
if strings.TrimSpace(audience) == "" {
return RegClaimsWithSID{}, jwt.MapClaims{}, errors.New("access token audiences must not contain empty or whitespace-only entries")
}
}
if err := c.lookupWellKnownOpenidConfiguration(ctx); err != nil {
return RegClaimsWithSID{}, jwt.MapClaims{}, err
}
Expand Down Expand Up @@ -301,14 +311,26 @@ func (c *oidcClient) verifyAccessTokenJWT(token string) (RegClaimsWithSID, jwt.M
issuer = c.provider.AccessTokenIssuer
}

_, err := jwt.ParseWithClaims(token, &claims, jwks.Keyfunc, jwt.WithIssuer(issuer))
_, err := jwt.ParseWithClaims(token, &claims, jwks.Keyfunc, jwt.WithIssuer(issuer), jwt.WithAudience(c.accessTokenAudiences...))
if err != nil {
return claims, mapClaims, err
}
_, _, err = new(jwt.Parser).ParseUnverified(token, mapClaims)
// The token's structure, encoding and signature have already been verified.
// Decode only the payload to retain arbitrary claims without parsing the
// header and signature again. Keep typed claims above for validation.
_, payloadAndSignature, _ := strings.Cut(token, ".")
payload, _, _ := strings.Cut(payloadAndSignature, ".")
claimBytes, err := new(jwt.Parser).DecodeSegment(payload)
if err != nil {
return claims, mapClaims, fmt.Errorf("%w: could not base64 decode claim: %w", jwt.ErrTokenMalformed, err)
}
// Match ParseUnverified's map value semantics, including a null payload.
decodedMapClaims := mapClaims
err = json.Unmarshal(claimBytes, &decodedMapClaims)
// TODO: decode mapClaims to sth readable
c.Logger.Debug().Interface("access token", &claims).Msg("parsed access token")
if err != nil {
err = fmt.Errorf("%w: could not JSON decode claim: %w", jwt.ErrTokenMalformed, err)
c.Logger.Info().Err(err).Msg("Failed to parse/verify the access token.")
return claims, mapClaims, err
}
Expand Down
11 changes: 11 additions & 0 deletions pkg/oidc/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ type Options struct {
// AccessTokenVerifyMethod to use when verifying access tokens
// TODO pass a function or interface to verify? an AccessTokenVerifier?
AccessTokenVerifyMethod string
// AccessTokenAudiences requires at least one matching audience in access tokens.
// An empty list disables audience validation.
AccessTokenAudiences []string
// Config to use
Config *goidc.Config

Expand Down Expand Up @@ -74,6 +77,14 @@ func WithAccessTokenVerifyMethod(val string) Option {
}
}

// WithAccessTokenAudiences sets the allowed audiences for access tokens only.
// An empty list disables audience validation.
func WithAccessTokenAudiences(val []string) Option {
return func(o *Options) {
o.AccessTokenAudiences = append([]string(nil), val...)
}
}

// WithHTTPClient provides a function to set the httpClient option.
func WithHTTPClient(val *http.Client) Option {
return func(o *Options) {
Expand Down
Loading