Add option to configure access token verification
Allow to switch jwt access token verification and off. Many (most?) IDP
provide JWT encoded access tokens. If ocis is configure to assume jwt
access tokens (access_token_verify_method==jwt) we now properly verify
the tokens signature and a set of standard claims ("exp", "iat" and nbf"
by way of the jwt module's standard verification and "iss" explicitliy).
This change also allows for introduction of other access token verification
mechanism in the future (e.g. through introspection (RFC7662).
This commit is contained in:
committed by
Ralf Haferkamp
parent
454cea8a1c
commit
eb94530433
@@ -189,6 +189,7 @@ func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config)
|
|||||||
middleware.HTTPClient(oidcHTTPClient),
|
middleware.HTTPClient(oidcHTTPClient),
|
||||||
middleware.TokenCacheSize(cfg.OIDC.UserinfoCache.Size),
|
middleware.TokenCacheSize(cfg.OIDC.UserinfoCache.Size),
|
||||||
middleware.TokenCacheTTL(time.Second*time.Duration(cfg.OIDC.UserinfoCache.TTL)),
|
middleware.TokenCacheTTL(time.Second*time.Duration(cfg.OIDC.UserinfoCache.TTL)),
|
||||||
|
middleware.AccessTokenVerifyMethod(cfg.OIDC.AccessTokenVerifyMethod),
|
||||||
|
|
||||||
// basic Options
|
// basic Options
|
||||||
middleware.Logger(logger),
|
middleware.Logger(logger),
|
||||||
|
|||||||
@@ -80,12 +80,20 @@ type AuthMiddleware struct {
|
|||||||
CredentialsByUserAgent map[string]string `yaml:"credentials_by_user_agent"`
|
CredentialsByUserAgent map[string]string `yaml:"credentials_by_user_agent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
AccessTokenVerificationNone = "none"
|
||||||
|
AccessTokenVerificationJWT = "jwt"
|
||||||
|
// tdb:
|
||||||
|
// AccessTokenVerificationIntrospect = "introspect"
|
||||||
|
)
|
||||||
|
|
||||||
// OIDC is the config for the OpenID-Connect middleware. If set the proxy will try to authenticate every request
|
// OIDC is the config for the OpenID-Connect middleware. If set the proxy will try to authenticate every request
|
||||||
// with the configured oidc-provider
|
// with the configured oidc-provider
|
||||||
type OIDC struct {
|
type OIDC struct {
|
||||||
Issuer string `yaml:"issuer" env:"OCIS_URL;OCIS_OIDC_ISSUER;PROXY_OIDC_ISSUER" desc:"URL of the OIDC issuer. It defaults to URL of the builtin IDP."`
|
Issuer string `yaml:"issuer" env:"OCIS_URL;OCIS_OIDC_ISSUER;PROXY_OIDC_ISSUER" desc:"URL of the OIDC issuer. It defaults to URL of the builtin IDP."`
|
||||||
Insecure bool `yaml:"insecure" env:"OCIS_INSECURE;PROXY_OIDC_INSECURE" desc:"Disable TLS certificate validation for connections to the IDP. Note that this is not recommended for production environments."`
|
Insecure bool `yaml:"insecure" env:"OCIS_INSECURE;PROXY_OIDC_INSECURE" desc:"Disable TLS certificate validation for connections to the IDP. Note that this is not recommended for production environments."`
|
||||||
UserinfoCache UserinfoCache `yaml:"user_info_cache"`
|
AccessTokenVerifyMethod string `yaml:"access_token_verify_method" env:"PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD" desc:"Sets how OIDC access tokens should be verified. Possible values: 'none', which means that no special validation apart from using it for accessing the IPD's userinfo endpoint will be done. Or 'jwt', which tries to parse the access token as a jwt token and verifies the signature using the keys published on the IDP's 'jwks_uri'."`
|
||||||
|
UserinfoCache UserinfoCache `yaml:"user_info_cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserinfoCache is a TTL cache configuration.
|
// UserinfoCache is a TTL cache configuration.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func DefaultConfig() *config.Config {
|
|||||||
Issuer: "https://localhost:9200",
|
Issuer: "https://localhost:9200",
|
||||||
Insecure: true,
|
Insecure: true,
|
||||||
//Insecure: true,
|
//Insecure: true,
|
||||||
|
AccessTokenVerifyMethod: config.AccessTokenVerificationJWT,
|
||||||
UserinfoCache: config.UserinfoCache{
|
UserinfoCache: config.UserinfoCache{
|
||||||
Size: 1024,
|
Size: 1024,
|
||||||
TTL: 10,
|
TTL: 10,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package parser
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
|
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
|
||||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||||
@@ -41,5 +42,14 @@ func Validate(cfg *config.Config) error {
|
|||||||
return shared.MissingMachineAuthApiKeyError(cfg.Service.Name)
|
return shared.MissingMachineAuthApiKeyError(cfg.Service.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.OIDC.AccessTokenVerifyMethod != config.AccessTokenVerificationNone &&
|
||||||
|
cfg.OIDC.AccessTokenVerifyMethod != config.AccessTokenVerificationJWT {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Invalid value '%s' for 'access_token_verify_method' in service %s. Possible values are: '%s' or '%s'.",
|
||||||
|
cfg.OIDC.AccessTokenVerifyMethod, cfg.Service.Name,
|
||||||
|
config.AccessTokenVerificationJWT, config.AccessTokenVerificationNone,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ func newOIDCAuth(options Options) func(http.Handler) http.Handler {
|
|||||||
TokenCacheSize(options.UserinfoCacheSize),
|
TokenCacheSize(options.UserinfoCacheSize),
|
||||||
TokenCacheTTL(options.UserinfoCacheTTL),
|
TokenCacheTTL(options.UserinfoCacheTTL),
|
||||||
CredentialsByUserAgent(options.CredentialsByUserAgent),
|
CredentialsByUserAgent(options.CredentialsByUserAgent),
|
||||||
|
AccessTokenVerifyMethod(options.AccessTokenVerifyMethod),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||||
"github.com/owncloud/ocis/v2/ocis-pkg/sync"
|
"github.com/owncloud/ocis/v2/ocis-pkg/sync"
|
||||||
|
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,12 +30,13 @@ func OIDCAuth(optionSetters ...Option) func(next http.Handler) http.Handler {
|
|||||||
tokenCache := sync.NewCache(options.UserinfoCacheSize)
|
tokenCache := sync.NewCache(options.UserinfoCacheSize)
|
||||||
|
|
||||||
h := oidcAuth{
|
h := oidcAuth{
|
||||||
logger: options.Logger,
|
logger: options.Logger,
|
||||||
providerFunc: options.OIDCProviderFunc,
|
providerFunc: options.OIDCProviderFunc,
|
||||||
httpClient: options.HTTPClient,
|
httpClient: options.HTTPClient,
|
||||||
oidcIss: options.OIDCIss,
|
oidcIss: options.OIDCIss,
|
||||||
tokenCache: &tokenCache,
|
tokenCache: &tokenCache,
|
||||||
tokenCacheTTL: options.UserinfoCacheTTL,
|
tokenCacheTTL: options.UserinfoCacheTTL,
|
||||||
|
accessTokenVerifyMethod: options.AccessTokenVerifyMethod,
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
@@ -51,6 +54,12 @@ func OIDCAuth(optionSetters ...Option) func(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force init of jwks keyfunc if needed (contacts the .well-known and jwks endpoints on first call)
|
||||||
|
if h.accessTokenVerifyMethod == config.AccessTokenVerificationJWT && h.getKeyfunc() == nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
token := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
|
token := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
|
||||||
|
|
||||||
claims, status := h.getClaims(token, req)
|
claims, status := h.getClaims(token, req)
|
||||||
@@ -66,19 +75,27 @@ func OIDCAuth(optionSetters ...Option) func(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type oidcAuth struct {
|
type oidcAuth struct {
|
||||||
logger log.Logger
|
logger log.Logger
|
||||||
provider OIDCProvider
|
provider OIDCProvider
|
||||||
jwks *keyfunc.JWKS
|
jwks *keyfunc.JWKS
|
||||||
providerFunc func() (OIDCProvider, error)
|
providerFunc func() (OIDCProvider, error)
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
oidcIss string
|
oidcIss string
|
||||||
tokenCache *sync.Cache
|
tokenCache *sync.Cache
|
||||||
tokenCacheTTL time.Duration
|
tokenCacheTTL time.Duration
|
||||||
|
accessTokenVerifyMethod string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m oidcAuth) getClaims(token string, req *http.Request) (claims map[string]interface{}, status int) {
|
func (m oidcAuth) getClaims(token string, req *http.Request) (claims map[string]interface{}, status int) {
|
||||||
hit := m.tokenCache.Load(token)
|
hit := m.tokenCache.Load(token)
|
||||||
if hit == nil {
|
if hit == nil {
|
||||||
|
aClaims, err := m.verifyAccessToken(token)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error().Err(err).Msg("Failed to verify access token")
|
||||||
|
status = http.StatusUnauthorized
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
oauth2Token := &oauth2.Token{
|
oauth2Token := &oauth2.Token{
|
||||||
AccessToken: token,
|
AccessToken: token,
|
||||||
}
|
}
|
||||||
@@ -99,7 +116,7 @@ func (m oidcAuth) getClaims(token string, req *http.Request) (claims map[string]
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
expiration := m.extractExpiration(token)
|
expiration := m.extractExpiration(aClaims)
|
||||||
m.tokenCache.Store(token, claims, expiration)
|
m.tokenCache.Store(token, claims, expiration)
|
||||||
|
|
||||||
m.logger.Debug().Interface("claims", claims).Interface("userInfo", userInfo).Time("expiration", expiration.UTC()).Msg("unmarshalled and cached userinfo")
|
m.logger.Debug().Interface("claims", claims).Interface("userInfo", userInfo).Time("expiration", expiration.UTC()).Msg("unmarshalled and cached userinfo")
|
||||||
@@ -115,28 +132,52 @@ func (m oidcAuth) getClaims(token string, req *http.Request) (claims map[string]
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractExpiration tries to extract the expriration time from the access token
|
func (m oidcAuth) verifyAccessToken(token string) (jwt.RegisteredClaims, error) {
|
||||||
// It tries so by parsing (and verifying the signature) the access_token as JWT.
|
switch m.accessTokenVerifyMethod {
|
||||||
// If it is a valid JWT the `exp` claim will be used that the token expiration time.
|
case config.AccessTokenVerificationJWT:
|
||||||
// If it is not a valid JWT we fallback to the configured cache TTL.
|
return m.verifyAccessTokenJWT(token)
|
||||||
// This could still be enhanced by trying a to use the introspection endpoint (RFC7662),
|
case config.AccessTokenVerificationNone:
|
||||||
// to validate the token. If it exists.
|
m.logger.Debug().Msg("Access Token verification disabled")
|
||||||
func (m oidcAuth) extractExpiration(token string) time.Time {
|
return jwt.RegisteredClaims{}, nil
|
||||||
defaultExpiration := time.Now().Add(m.tokenCacheTTL)
|
default:
|
||||||
|
m.logger.Error().Str("access_token_verify_method", m.accessTokenVerifyMethod).Msg("Unknown Access Token verification setting")
|
||||||
|
return jwt.RegisteredClaims{}, errors.New("Unknown Access Token Verification method")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyAccessTokenJWT tries to parse and verify the access token as a JWT.
|
||||||
|
func (m oidcAuth) verifyAccessTokenJWT(token string) (jwt.RegisteredClaims, error) {
|
||||||
|
var claims jwt.RegisteredClaims
|
||||||
jwks := m.getKeyfunc()
|
jwks := m.getKeyfunc()
|
||||||
if jwks == nil {
|
if jwks == nil {
|
||||||
return defaultExpiration
|
return claims, errors.New("Error initializing jwks keyfunc")
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := jwt.RegisteredClaims{}
|
|
||||||
_, err := jwt.ParseWithClaims(token, &claims, jwks.Keyfunc)
|
_, err := jwt.ParseWithClaims(token, &claims, jwks.Keyfunc)
|
||||||
|
m.logger.Debug().Interface("access token", &claims).Msg("parsed access token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Info().Err(err).Msg("Error parsing access_token as JWT")
|
m.logger.Info().Err(err).Msg("Failed to parse/verify the access token.")
|
||||||
return defaultExpiration
|
return claims, err
|
||||||
}
|
}
|
||||||
if claims.ExpiresAt != nil {
|
|
||||||
m.logger.Debug().Str("exp", claims.ExpiresAt.String()).Msg("Expiration Time from access_token")
|
if !claims.VerifyIssuer(m.oidcIss, true) {
|
||||||
return claims.ExpiresAt.Time
|
vErr := jwt.ValidationError{}
|
||||||
|
vErr.Inner = jwt.ErrTokenInvalidIssuer
|
||||||
|
vErr.Errors |= jwt.ValidationErrorIssuer
|
||||||
|
return claims, vErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractExpiration tries to extract the expriration time from the access token
|
||||||
|
// If the access token does not have an exp claim it will fallback to the configured
|
||||||
|
// default expiration
|
||||||
|
func (m oidcAuth) extractExpiration(aClaims jwt.RegisteredClaims) time.Time {
|
||||||
|
defaultExpiration := time.Now().Add(m.tokenCacheTTL)
|
||||||
|
if aClaims.ExpiresAt != nil {
|
||||||
|
m.logger.Debug().Str("exp", aClaims.ExpiresAt.String()).Msg("Expiration Time from access_token")
|
||||||
|
return aClaims.ExpiresAt.Time
|
||||||
}
|
}
|
||||||
return defaultExpiration
|
return defaultExpiration
|
||||||
}
|
}
|
||||||
@@ -166,8 +207,10 @@ type jwksJSON struct {
|
|||||||
func (m *oidcAuth) getKeyfunc() *keyfunc.JWKS {
|
func (m *oidcAuth) getKeyfunc() *keyfunc.JWKS {
|
||||||
if m.jwks == nil {
|
if m.jwks == nil {
|
||||||
wellKnown := strings.TrimSuffix(m.oidcIss, "/") + "/.well-known/openid-configuration"
|
wellKnown := strings.TrimSuffix(m.oidcIss, "/") + "/.well-known/openid-configuration"
|
||||||
|
|
||||||
resp, err := m.httpClient.Get(wellKnown)
|
resp, err := m.httpClient.Get(wellKnown)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
m.logger.Error().Err(err).Msg("Failed to set request for .well-known/openid-configuration")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -184,15 +227,15 @@ func (m *oidcAuth) getKeyfunc() *keyfunc.JWKS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var j jwksJSON
|
var j jwksJSON
|
||||||
err = json.
|
err = json.Unmarshal(body, &j)
|
||||||
Unmarshal(body, &j)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Error().Err(err).Msg("failed to decode provider discovered openid-configuration")
|
m.logger.Error().Err(err).Msg("failed to decode provider openid-configuration")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.logger.Debug().Str("jwks", j.JWKSURL).Msg("discovered jwks endpoint")
|
m.logger.Debug().Str("jwks", j.JWKSURL).Msg("discovered jwks endpoint")
|
||||||
// FIXME: make configurable
|
// FIXME: make configurable
|
||||||
options := keyfunc.Options{
|
options := keyfunc.Options{
|
||||||
|
Client: m.httpClient,
|
||||||
RefreshErrorHandler: func(err error) {
|
RefreshErrorHandler: func(err error) {
|
||||||
m.logger.Error().Err(err).Msg("There was an error with the jwt.Keyfunc")
|
m.logger.Error().Err(err).Msg("There was an error with the jwt.Keyfunc")
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||||
|
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ func TestOIDCAuthMiddleware(t *testing.T) {
|
|||||||
return mockOP(false), nil
|
return mockOP(false), nil
|
||||||
}),
|
}),
|
||||||
OIDCIss("https://localhost:9200"),
|
OIDCIss("https://localhost:9200"),
|
||||||
|
AccessTokenVerifyMethod(config.AccessTokenVerificationNone),
|
||||||
)(next)
|
)(next)
|
||||||
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "https://idp.example.com", nil)
|
r := httptest.NewRequest(http.MethodGet, "https://idp.example.com", nil)
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ type Options struct {
|
|||||||
UserinfoCacheTTL time.Duration
|
UserinfoCacheTTL time.Duration
|
||||||
// CredentialsByUserAgent sets the auth challenges on a per user-agent basis
|
// CredentialsByUserAgent sets the auth challenges on a per user-agent basis
|
||||||
CredentialsByUserAgent map[string]string
|
CredentialsByUserAgent map[string]string
|
||||||
|
// AccessTokenVerifyMethod configures how access_tokens should be verified but the oidc_auth middleware.
|
||||||
|
// Possible values currently: "jwt" and "none"
|
||||||
|
AccessTokenVerifyMethod string
|
||||||
}
|
}
|
||||||
|
|
||||||
// newOptions initializes the available default options.
|
// newOptions initializes the available default options.
|
||||||
@@ -193,3 +196,10 @@ func UserProvider(up backend.UserBackend) Option {
|
|||||||
o.UserProvider = up
|
o.UserProvider = up
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccessTokenVerifyMethod set the mechanism for access token verification
|
||||||
|
func AccessTokenVerifyMethod(method string) Option {
|
||||||
|
return func(o *Options) {
|
||||||
|
o.AccessTokenVerifyMethod = method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user