Skip to content

Commit

Permalink
Add generic OAuth support (cadence-workflow#5638)
Browse files Browse the repository at this point in the history
What changed?
Adding generic JWT token validation. JWKS setting is needed to retrieve correct public key from issuer.
Group information and Admin flag is extracted from token using JMESPath query.

Why?
This change allows server to validate not only self-signed JWT tokens, but users can use any Identity provider they want. Cadence SDK client has to add acquired JWT token to RPC call.
  • Loading branch information
mantas-sidlauskas authored Feb 12, 2024
1 parent d3bff7d commit fbdacb4
Show file tree
Hide file tree
Showing 13 changed files with 428 additions and 79 deletions.
3 changes: 2 additions & 1 deletion cmd/server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ require (
go.uber.org/zap v1.13.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.16.0 // indirect
gonum.org/v1/gonum v0.7.0 // indirect
google.golang.org/grpc v1.59.0 // indirect
Expand All @@ -80,6 +80,7 @@ require (
cloud.google.com/go/iam v1.1.3 // indirect
cloud.google.com/go/storage v1.30.1 // indirect
github.com/BurntSushi/toml v0.4.1 // indirect
github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 // indirect
github.com/apache/thrift v0.16.0 // indirect
github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3 // indirect
Expand Down
6 changes: 4 additions & 2 deletions cmd/server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7Biccwk
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/Shopify/sarama v1.33.0 h1:2K4mB9M4fo46sAM7t6QTsmSO8dLX1OqznLM7vn3OjZ8=
github.com/Shopify/sarama v1.33.0/go.mod h1:lYO7LwEBkE0iAeTl94UfPSrDaavFzSFlmn+5isARATQ=
Expand Down Expand Up @@ -590,8 +592,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20170927054726-6dc17368e09b/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
Expand Down
2 changes: 1 addition & 1 deletion common/archiver/gcloud/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ require (
go.uber.org/zap v1.13.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.16.0 // indirect
google.golang.org/grpc v1.59.0 // indirect
gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19 // indirect
Expand Down
4 changes: 2 additions & 2 deletions common/archiver/gcloud/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20170927054726-6dc17368e09b/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
Expand Down
10 changes: 5 additions & 5 deletions common/authorization/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func cfgOAuth() config.Authorization {
return config.Authorization{
OAuthAuthorizer: config.OAuthAuthorizer{
Enable: true,
JwtCredentials: config.JwtCredentials{
JwtCredentials: &config.JwtCredentials{
Algorithm: jwt.SigningMethodRS256.Name,
PublicKey: "../../config/credentials/keytest.pub",
},
Expand All @@ -83,10 +83,10 @@ func (s *factorySuite) TestFactoryNoopAuthorizer() {
}{
{cfgNoop(), &nopAuthority{}, nil},
{cfgOAuthVar, &oauthAuthority{
authorizationCfg: cfgOAuthVar.OAuthAuthorizer,
log: s.logger,
publicKey: publicKey,
parser: jwt.NewParser(jwt.WithValidMethods([]string{cfgOAuthVar.OAuthAuthorizer.JwtCredentials.Algorithm}), jwt.WithIssuedAt()),
config: cfgOAuthVar.OAuthAuthorizer,
log: s.logger,
publicKey: publicKey,
parser: jwt.NewParser(jwt.WithValidMethods([]string{cfgOAuthVar.OAuthAuthorizer.JwtCredentials.Algorithm}), jwt.WithIssuedAt()),
}, nil},
}

Expand Down
139 changes: 109 additions & 30 deletions common/authorization/oauthAuthorizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import (
"strings"
"time"

"github.com/MicahParks/keyfunc/v2"
"github.com/golang-jwt/jwt/v5"
"github.com/jmespath/go-jmespath"
"go.uber.org/yarpc"

"github.com/uber/cadence/common"
Expand All @@ -39,12 +41,18 @@ import (

var _ jwt.Claims = (*JWTClaims)(nil)

const (
groupSeparator = " "
jwtInternalIssuer = "internal-jwt"
)

type oauthAuthority struct {
authorizationCfg config.OAuthAuthorizer
domainCache cache.DomainCache
log log.Logger
parser *jwt.Parser
publicKey interface{}
config config.OAuthAuthorizer
domainCache cache.DomainCache
log log.Logger
parser *jwt.Parser
publicKey interface{}
jwks *keyfunc.JWKS
}

// JWTClaims is a Cadence specific claim with embeded Claims defined https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
Expand All @@ -61,41 +69,51 @@ func (j JWTClaims) GetGroups() []string {
return strings.Split(j.Groups, groupSeparator)
}

const groupSeparator = " "

// NewOAuthAuthorizer creates a oauth authority
// NewOAuthAuthorizer creates an oauth Authorizer
func NewOAuthAuthorizer(
authorizationCfg config.OAuthAuthorizer,
oauthConfig config.OAuthAuthorizer,
log log.Logger,
domainCache cache.DomainCache,
) (Authorizer, error) {

key, err := common.LoadRSAPublicKey(authorizationCfg.JwtCredentials.PublicKey)
if err != nil {
return nil, fmt.Errorf("loading RSA public key: %w", err)
var jwks *keyfunc.JWKS
var key interface{}
var err error

if oauthConfig.JwtCredentials != nil {
if oauthConfig.JwtCredentials.Algorithm != jwt.SigningMethodRS256.Name {
return nil, fmt.Errorf("algorithm %q is not supported", oauthConfig.JwtCredentials.Algorithm)
}

if key, err = common.LoadRSAPublicKey(oauthConfig.JwtCredentials.PublicKey); err != nil {
return nil, fmt.Errorf("loading RSA public key: %w", err)
}
}

if authorizationCfg.JwtCredentials.Algorithm != jwt.SigningMethodRS256.Name {
return nil, fmt.Errorf("algorithm %q is not supported", authorizationCfg.JwtCredentials.Algorithm)
if oauthConfig.Provider != nil {
if oauthConfig.Provider.JWKSURL == "" {
return nil, fmt.Errorf("JWKSURL is not set")
}
// Create the JWKS from the resource at the given URL.
if jwks, err = keyfunc.Get(oauthConfig.Provider.JWKSURL, keyfunc.Options{}); err != nil {
return nil, fmt.Errorf("creating JWKS from resource: %s error: %w", oauthConfig.Provider.JWKSURL, err)
}
}

return &oauthAuthority{
authorizationCfg: authorizationCfg,
domainCache: domainCache,
log: log,
config: oauthConfig,
domainCache: domainCache,
log: log,
parser: jwt.NewParser(
jwt.WithValidMethods([]string{authorizationCfg.JwtCredentials.Algorithm}),
jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Name}),
jwt.WithIssuedAt(),
),
publicKey: key,
jwks: jwks,
}, nil
}

// Authorize defines the logic to verify get claims from token
func (a *oauthAuthority) Authorize(
ctx context.Context,
attributes *Attributes,
) (Result, error) {
func (a *oauthAuthority) Authorize(ctx context.Context, attributes *Attributes) (Result, error) {
call := yarpc.CallFromContext(ctx)

token := call.Header(common.AuthorizationTokenHeaderName)
Expand All @@ -105,14 +123,25 @@ func (a *oauthAuthority) Authorize(
}

var claims JWTClaims

_, err := a.parser.ParseWithClaims(token, &claims, a.keyFunc)

parsedToken, err := a.parser.ParseWithClaims(token, &claims, a.keyFunc)
if err != nil {
a.log.Debug("request is not authorized", tag.Error(err))
return Result{Decision: DecisionDeny}, nil
}

if !isTokenInternal(parsedToken) {
parsed, _, err := a.parser.ParseUnverified(token, jwt.MapClaims{})
if err != nil {
a.log.Debug("request is not authorized", tag.Error(err))
return Result{Decision: DecisionDeny}, nil
}

if err := a.parseExternal(parsed.Claims.(jwt.MapClaims), &claims); err != nil {
a.log.Debug("request is not authorized", tag.Error(err))
return Result{Decision: DecisionDeny}, nil
}
}

if err := a.validateTTL(&claims); err != nil {
a.log.Debug("request is not authorized", tag.Error(err))
return Result{Decision: DecisionDeny}, nil
Expand All @@ -137,8 +166,16 @@ func (a *oauthAuthority) Authorize(

// keyFunc returns correct key to check signature
func (a *oauthAuthority) keyFunc(token *jwt.Token) (interface{}, error) {
// only local public key is supported currently
return a.publicKey, nil
if isTokenInternal(token) && a.publicKey != nil {
return a.publicKey, nil
}
// External provider with JWKS provided
// https://datatracker.ietf.org/doc/html/rfc7517
if a.jwks != nil {
return a.jwks.Keyfunc(token)
}

return nil, errors.New("no public key for verification")
}

func (a *oauthAuthority) validateTTL(claims *JWTClaims) error {
Expand All @@ -158,8 +195,50 @@ func (a *oauthAuthority) validateTTL(claims *JWTClaims) error {
return errors.New("token is expired")
}

if timeLeft > a.authorizationCfg.MaxJwtTTL {
return fmt.Errorf("token TTL: %d is larger than MaxTTL allowed: %d", timeLeft, a.authorizationCfg.MaxJwtTTL)
if timeLeft > a.config.MaxJwtTTL {
return fmt.Errorf("token TTL: %d is larger than MaxTTL allowed: %d", timeLeft, a.config.MaxJwtTTL)
}

return nil
}

func isTokenInternal(token *jwt.Token) bool {
// external providers should set kid part always
if _, ok := token.Header["kid"]; !ok {
return true
}

issuer, err := token.Claims.GetIssuer()
if err != nil {
return false
}

return issuer == jwtInternalIssuer
}

func (a *oauthAuthority) parseExternal(rawClaims map[string]interface{}, claims *JWTClaims) error {
if a.config.Provider.GroupsAttributePath != "" {
userGroups, err := jmespath.Search(a.config.Provider.GroupsAttributePath, rawClaims)
if err != nil {
return fmt.Errorf("extracting JWT Groups claim: %w", err)
}

if _, ok := userGroups.(string); !ok {
return errors.New("cannot convert groups to string")
}

claims.Groups = userGroups.(string)
}

if a.config.Provider.AdminAttributePath != "" {
isAdmin, err := jmespath.Search(a.config.Provider.AdminAttributePath, rawClaims)
if err != nil {
return fmt.Errorf("extracting JWT Admin claim: %w", err)
}
if _, ok := isAdmin.(bool); !ok {
return errors.New("cannot convert isAdmin to bool")
}
claims.Admin = isAdmin.(bool)
}

return nil
Expand Down
Loading

0 comments on commit fbdacb4

Please sign in to comment.