Skip to content
Merged
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
11 changes: 9 additions & 2 deletions e2e/e2e.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ func Logger(traceId string) types.StepFunc {
type SignUpOptions struct {
Username string
Groups []string
JWTSignKey string
JWTSignKey string // PEM-encoded RSA private key
JWTKeyID string
Namespace string
Duration time.Duration
}
Expand All @@ -44,10 +45,16 @@ func SignUp(opts SignUpOptions) types.StepFunc {
}

return func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
privateKey, err := jwtutil.ParseRSAPrivateKeyFromPEM([]byte(opts.JWTSignKey))
if err != nil {
t.Fatal(err)
}

accessToken, err := jwtutil.CreateToken(jwtutil.CreateTokenOptions{
Username: opts.Username,
Groups: opts.Groups,
SigningKey: opts.JWTSignKey,
KeyID: opts.JWTKeyID,
PrivateKey: privateKey,
Duration: opts.Duration,
})
if err != nil {
Expand Down
27 changes: 17 additions & 10 deletions jwtutil/create.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
package jwtutil

import (
"crypto/rsa"
"fmt"
"time"

"github.com/golang-jwt/jwt/v5"
)

const (
JwtSecretEnvKey = "AUTHN_JWT_SECRET"

defaultISS = "krateo.io"
)

Expand All @@ -27,17 +26,24 @@ type CreateTokenOptions struct {
Username string
Groups []string
Duration time.Duration
SigningKey string
KeyID string
PrivateKey *rsa.PrivateKey
}

// CreateToken generates a signed JWT token using the provided
// username, group list, and expiration duration.
// The token is signed using the HS256 algorithm.
// The signing key is read from the environment variable AUTHN_JWT_SECRET.
// If the environment variable is not set, the function returns an error.
// The token is signed asymmetrically using the RS256 algorithm and the
// supplied RSA private key, and carries a "kid" header identifying the key
// so that JWKS-based validators (e.g. agentgateway) can select the matching
// public key. The corresponding public key must be published in the JWKS
// under the same KeyID.
func CreateToken(opts CreateTokenOptions) (string, error) {
if opts.SigningKey == "" {
return "", fmt.Errorf("signing key cannot be empty")
if opts.PrivateKey == nil {
return "", fmt.Errorf("private key cannot be nil")
}

if opts.KeyID == "" {
return "", fmt.Errorf("key ID cannot be empty")
}

if opts.Username == "" {
Expand All @@ -60,7 +66,8 @@ func CreateToken(opts CreateTokenOptions) (string, error) {
},
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = opts.KeyID

return token.SignedString([]byte(opts.SigningKey))
return token.SignedString(opts.PrivateKey)
}
56 changes: 48 additions & 8 deletions jwtutil/create_test.go
Original file line number Diff line number Diff line change
@@ -1,35 +1,40 @@
package jwtutil_test

import (
"crypto/rand"
"crypto/rsa"
"testing"
"time"

"github.com/golang-jwt/jwt/v5"
"github.com/krateo-platformops/plumbing/jwtutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCreateToken(t *testing.T) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)

const keyID = "test-key-id"

tests := []struct {
name string
username string
groups []string
duration time.Duration
secret string
}{
{
name: "with environment secret",
name: "with groups",
username: "alice",
groups: []string{"admin", "dev"},
duration: time.Minute * 30,
secret: "envSecret123",
},
{
name: "with default secret fallback",
name: "without groups",
username: "bob",
groups: []string{},
duration: time.Minute * 15,
secret: "abbracadabra!",
},
}

Expand All @@ -39,20 +44,25 @@ func TestCreateToken(t *testing.T) {
Username: tc.username,
Groups: tc.groups,
Duration: tc.duration,
SigningKey: tc.secret,
KeyID: keyID,
PrivateKey: privateKey,
}

tokenStr, err := jwtutil.CreateToken(opts)
assert.NoError(t, err)
assert.NotEmpty(t, tokenStr)

// Parse and validate the token
// Parse and validate the token using the public key.
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) {
return []byte(tc.secret), nil
return &privateKey.PublicKey, nil
})
assert.NoError(t, err)
assert.True(t, token.Valid)

// The token must be signed with RS256 and carry the kid header.
assert.Equal(t, jwt.SigningMethodRS256.Alg(), token.Method.Alg())
assert.Equal(t, keyID, token.Header["kid"])

claims, ok := token.Claims.(jwt.MapClaims)
assert.True(t, ok)

Expand All @@ -62,3 +72,33 @@ func TestCreateToken(t *testing.T) {
})
}
}

func TestCreateTokenValidation(t *testing.T) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)

tests := []struct {
name string
opts jwtutil.CreateTokenOptions
}{
{
name: "nil private key",
opts: jwtutil.CreateTokenOptions{Username: "alice", KeyID: "kid"},
},
{
name: "empty key ID",
opts: jwtutil.CreateTokenOptions{Username: "alice", PrivateKey: privateKey},
},
{
name: "empty username",
opts: jwtutil.CreateTokenOptions{KeyID: "kid", PrivateKey: privateKey},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := jwtutil.CreateToken(tc.opts)
assert.Error(t, err)
})
}
}
Loading
Loading