diff --git a/.env.example b/.env.example index 2eed21ad6..a95925960 100644 --- a/.env.example +++ b/.env.example @@ -441,8 +441,22 @@ # Default: # AUTH_PASSWORD_SECURITY= +############################################################################### +# Algoritm to be use for JWT signature. +# +# Supported valus: +# - HS256, HS384, HS512 +# - PS256, PS384, PS512, +# - RS256, RS384, RS512 +# +# Provide shared secret string for HS256, HS384, HS512 and full private key or path to the file PS* and RS* algorithms. +# Type: string +# Default: HS512 +# AUTH_JWT_ALGORITHM=HS512 + ############################################################################### # Secret used for signing JWT tokens. +# Value is used only when HS256, HS384 or HS512 algorithm is used. # # [IMPORTANT] # ==== @@ -453,6 +467,12 @@ # Default: # AUTH_JWT_SECRET= +############################################################################### +# Raw private key or absolute or relative path to the file containing one. +# Type: string +# Default: +# AUTH_JWT_KEY= + ############################################################################### # Lifetime of the access token. Should be shorter than lifetime of the refresh token. # Type: time.Duration diff --git a/app/boot_auth.go b/app/boot_auth.go index 8c5be2a69..690d7b607 100644 --- a/app/boot_auth.go +++ b/app/boot_auth.go @@ -7,11 +7,20 @@ import ( "github.com/cortezaproject/corteza-server/auth/oauth2" "github.com/cortezaproject/corteza-server/pkg/auth" "github.com/cortezaproject/corteza-server/pkg/id" + "github.com/cortezaproject/corteza-server/pkg/options" "github.com/cortezaproject/corteza-server/store" "github.com/cortezaproject/corteza-server/system/types" + "github.com/lestrrat-go/jwx/jwa" + "github.com/lestrrat-go/jwx/jwk" + "github.com/lestrrat-go/jwx/jwt" + "go.uber.org/zap" + "io/ioutil" + "strings" ) func (app *CortezaApp) initAuth(ctx context.Context) (err error) { + log := app.Log.Named("auth") + if app.Opt.Auth.DefaultClient != "" { // default client will help streamline authorization with default clients app.DefaultAuthClient, err = store.LookupAuthClientByHandle(ctx, app.Store, app.Opt.Auth.DefaultClient) @@ -36,8 +45,24 @@ func (app *CortezaApp) initAuth(ctx context.Context) (err error) { return fmt.Errorf("could not set token verifier") } + // create token signature function from AUTH_ options + // and pass it on to the token issuer + alg, key, err := prepareSignatureFnParams(app.Opt.Auth) + if err != nil { + return fmt.Errorf("could not initialize token signer: %w", err) + } + + log.Info( + "initializing JWT and authentication procedures", + zap.Stringer("algoritm", alg), + ) + + // construct token issuer with algorithm, secrets, auth.TokenIssuer, err = auth.NewTokenIssuer( - auth.WithSecretSigner(app.Opt.Auth.Secret), + auth.WithSigner(func(t jwt.Token) ([]byte, error) { + return jwt.Sign(t, alg, key) + }), + // @todo implement configurable issuer claim //auth.WithDefaultIssuer(app.Opt.Auth.TokenClaimIssuer), auth.WithDefaultExpiration(app.Opt.Auth.AccessTokenLifetime), @@ -71,4 +96,60 @@ func (app *CortezaApp) initAuth(ctx context.Context) (err error) { if err != nil { return fmt.Errorf("could not initialize token issuer: %w", err) } + + return +} + +// helper function that loads and/or parses private keys and initializes JWT signer function +// from the given arguments +func prepareSignatureFnParams(opt options.AuthOpt) (alg jwa.SignatureAlgorithm, key any, err error) { + alg = jwa.SignatureAlgorithm(opt.JwtAlgorithm) + + switch alg { + case jwa.HS256, jwa.HS384, jwa.HS512: + // expecting secret to be set + if len(opt.Secret) == 0 { + return alg, nil, fmt.Errorf("token secret missing") + } + + key = []byte(opt.Secret) + case + jwa.PS256, jwa.PS384, jwa.PS512, + jwa.RS256, jwa.RS384, jwa.RS512: + if len(opt.JwtKey) == 0 { + return alg, nil, fmt.Errorf("token key missing") + } + + // if given key dos not begins with "-----BEGIN" + // assume it's path to a file and load contents of that file + if !strings.HasPrefix(opt.JwtKey, "-----BEGIN") { + var ( + keyFile = opt.JwtKey + b []byte + ) + if b, err = ioutil.ReadFile(keyFile); err != nil { + return alg, nil, fmt.Errorf("could not read key file: %w", err) + } + + // overwrite th input and load + opt.JwtKey = string(b) + + // recheck contents of the key + if !strings.HasPrefix(opt.JwtKey, "-----BEGIN") { + return alg, nil, fmt.Errorf("file %q does not contain a valid private key", keyFile) + } + } + + // generates pem.Private from the kInput string + key, err = jwk.ParseKey([]byte(opt.JwtKey), jwk.WithPEM(true)) + case "": + // should be caught by options init procedure and set to default, + // but you never know... + err = fmt.Errorf("token signature algorithm empty or missing") + + default: + err = fmt.Errorf("token signature algorithm %q not supported", alg) + } + + return } diff --git a/app/boot_auth_test.go b/app/boot_auth_test.go new file mode 100644 index 000000000..5136925fc --- /dev/null +++ b/app/boot_auth_test.go @@ -0,0 +1,143 @@ +package app + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/options" + "github.com/lestrrat-go/jwx/jwa" + "github.com/stretchr/testify/require" + "testing" +) + +func TestPrepareSignatureFnParams(t *testing.T) { + type ( + test struct { + name string + opt options.AuthOpt + err error + } + ) + + var ( + privateKey = string(genKey()) + ) + + tests := []test{ + { + name: "empty algo", + opt: options.AuthOpt{JwtKey: "foobar"}, + err: fmt.Errorf("token signature algorithm empty or missing"), + }, + { + name: "unknown algo", + opt: options.AuthOpt{JwtAlgorithm: "foobar", JwtKey: "foobar"}, + err: fmt.Errorf("token signature algorithm \"foobar\" not supported"), + }, + { + name: "empty key", + opt: options.AuthOpt{JwtAlgorithm: jwa.HS256.String()}, + err: fmt.Errorf("token secret missing"), + }, + { + name: "empty key", + opt: options.AuthOpt{JwtAlgorithm: jwa.PS256.String()}, + err: fmt.Errorf("token key missing"), + }, + { + // "shared secret" string + name: "HS256", + opt: options.AuthOpt{JwtAlgorithm: jwa.HS256.String(), Secret: "test key"}, + }, + { + // "shared secret" string + name: "HS384", + opt: options.AuthOpt{JwtAlgorithm: jwa.HS384.String(), Secret: "test key"}, + }, + { + // "shared secret" string + name: "HS512", + opt: options.AuthOpt{JwtAlgorithm: jwa.HS512.String(), Secret: "test key"}, + }, + { + // requires private key + name: "PS256", + opt: options.AuthOpt{JwtAlgorithm: jwa.PS256.String(), JwtKey: privateKey}, + }, + { + // requires private key + name: "PS384", + opt: options.AuthOpt{JwtAlgorithm: jwa.PS384.String(), JwtKey: privateKey}, + }, + { + // requires private key + name: "PS512", + opt: options.AuthOpt{JwtAlgorithm: jwa.PS512.String(), JwtKey: privateKey}, + }, + { + // requires private key + name: "RS256", + opt: options.AuthOpt{JwtAlgorithm: jwa.RS256.String(), JwtKey: privateKey}, + }, + { + // requires private key + name: "RS384", + opt: options.AuthOpt{JwtAlgorithm: jwa.RS384.String(), JwtKey: privateKey}, + }, + { + // requires private key + name: "RS512", + opt: options.AuthOpt{JwtAlgorithm: jwa.RS512.String(), JwtKey: privateKey}, + }, + { + // requires private key + name: "RS512 from a file", + opt: options.AuthOpt{JwtAlgorithm: jwa.RS512.String(), JwtKey: "test_files/key.pem"}, + }, + { + // requires private key + name: "RS512 from a non-existing file", + opt: options.AuthOpt{JwtAlgorithm: jwa.RS512.String(), JwtKey: "test_files/not-here.pem"}, + err: fmt.Errorf("could not read key file: open test_files/not-here.pem: no such file or directory"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + req = require.New(t) + ) + + alg, key, err := prepareSignatureFnParams(tt.opt) + if tt.err != nil { + req.EqualError(err, tt.err.Error()) + return + } + + req.NoError(err) + req.NotEmpty(alg) + req.NotEmpty(key) + }) + } +} + +func genKey() []byte { + bitSize := 4096 + + // Generate RSA key. + key, err := rsa.GenerateKey(rand.Reader, bitSize) + if err != nil { + panic(err) + } + + // Encode private key to PKCS#1 ASN.1 PEM. + return pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }, + ) + +} diff --git a/app/boot_levels.go b/app/boot_levels.go index 0bf0b8db1..864830dc3 100644 --- a/app/boot_levels.go +++ b/app/boot_levels.go @@ -289,7 +289,7 @@ func (app *CortezaApp) InitServices(ctx context.Context) (err error) { } if err = app.initAuth(ctx); err != nil { - return + return fmt.Errorf("can not initialize auth: %w", err) } app.WsServer = websocket.Server( diff --git a/app/options/auth.cue b/app/options/auth.cue index 9567afc20..9f2121fe0 100644 --- a/app/options/auth.cue +++ b/app/options/auth.cue @@ -29,10 +29,25 @@ auth: schema.#optionsGroup & { ==== """ } + jwt_algorithm: { + defaultGoExpr: "\"HS512\"" + defaultValue: "HS512" + description: """ + Algoritm to be use for JWT signature. + + Supported valus: + - HS256, HS384, HS512 + - PS256, PS384, PS512, + - RS256, RS384, RS512 + + Provide shared secret string for HS256, HS384, HS512 and full private key or path to the file PS* and RS* algorithms. + """ + } secret: { defaultGoExpr: "getSecretFromEnv(\"jwt secret\")" description: """ Secret used for signing JWT tokens. + Value is used only when HS256, HS384 or HS512 algorithm is used. [IMPORTANT] ==== @@ -42,6 +57,11 @@ auth: schema.#optionsGroup & { """ env: "AUTH_JWT_SECRET" } + jwt_key: { + description: """ + Raw private key or absolute or relative path to the file containing one. + """ + } access_token_lifetime: { type: "time.Duration" description: """ diff --git a/app/test_files/key.pem b/app/test_files/key.pem new file mode 100644 index 000000000..700ce790b --- /dev/null +++ b/app/test_files/key.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/pkg/options/options.gen.go b/pkg/options/options.gen.go index c4e331b59..947d16ad0 100644 --- a/pkg/options/options.gen.go +++ b/pkg/options/options.gen.go @@ -97,7 +97,9 @@ type ( AuthOpt struct { LogEnabled bool `env:"AUTH_LOG_ENABLED"` PasswordSecurity bool `env:"AUTH_PASSWORD_SECURITY"` + JwtAlgorithm string `env:"AUTH_JWT_ALGORITHM"` Secret string `env:"AUTH_JWT_SECRET"` + JwtKey string `env:"AUTH_JWT_KEY"` AccessTokenLifetime time.Duration `env:"AUTH_OAUTH2_ACCESS_TOKEN_LIFETIME"` RefreshTokenLifetime time.Duration `env:"AUTH_OAUTH2_REFRESH_TOKEN_LIFETIME"` ExternalRedirectURL string `env:"AUTH_EXTERNAL_REDIRECT_URL"` @@ -514,6 +516,7 @@ func Apigw() (o *ApigwOpt) { func Auth() (o *AuthOpt) { o = &AuthOpt{ PasswordSecurity: true, + JwtAlgorithm: "HS512", Secret: getSecretFromEnv("jwt secret"), AccessTokenLifetime: time.Hour * 2, RefreshTokenLifetime: time.Hour * 24 * 3,