Files

790 lines
23 KiB
Go

package jwt
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
stdtime "time"
"github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/hanzo-fi/go-libs/v5/pkg/authn/oidc"
logging "github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
libtime "github.com/hanzo-fi/go-libs/v5/pkg/types/time"
)
func setupTestKeySet(t *testing.T) (oidc.KeySet, *rsa.PrivateKey, string) {
// Generate RSA key pair
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
// Create JSON Web Key
jwk := jose.JSONWebKey{
Key: &privateKey.PublicKey,
KeyID: "test-key-id",
Algorithm: string(jose.RS256),
Use: oidc.KeyUseSignature,
}
// Create KeySet
keySet := oidc.NewStaticKeySet(jwk)
issuer := "https://test-issuer.example.com"
return keySet, privateKey, issuer
}
func createAccessToken(t *testing.T, privateKey *rsa.PrivateKey, issuer string, audience string, scopes []string, subject string) string {
now := stdtime.Now().UTC()
expirationTime := libtime.New(now.Add(1 * stdtime.Hour))
audiences := make([]string, 0, 1)
if audience != "" {
audiences = append(audiences, audience)
}
accessTokenClaims := oidc.NewAccessTokenClaims(
issuer,
subject,
audiences,
expirationTime,
"test-jti",
"test-client",
)
// Set scopes
accessTokenClaims.Scopes = scopes
return signAccessTokenClaims(t, privateKey, accessTokenClaims)
}
func signAccessTokenClaims(t *testing.T, privateKey *rsa.PrivateKey, accessTokenClaims *oidc.AccessTokenClaims) string {
return signClaims(t, privateKey, accessTokenClaims)
}
func signClaims(t *testing.T, privateKey *rsa.PrivateKey, claims any) string {
// Create JWT using go-jose
signer, err := jose.NewSigner(
jose.SigningKey{
Algorithm: jose.RS256,
Key: privateKey,
},
(&jose.SignerOptions{}).WithHeader("kid", "test-key-id"),
)
require.NoError(t, err)
claimsJSON, err := json.Marshal(claims)
require.NoError(t, err)
signed, err := signer.Sign(claimsJSON)
require.NoError(t, err)
token, err := signed.CompactSerialize()
require.NoError(t, err)
return token
}
func createAccessTokenWithOrgClaims(
t *testing.T,
privateKey *rsa.PrivateKey,
issuer string,
audience string,
scopes []string,
subject string,
organizationID string,
) string {
now := stdtime.Now().UTC()
expirationTime := libtime.New(now.Add(1 * stdtime.Hour))
audiences := make([]string, 0, 1)
if audience != "" {
audiences = append(audiences, audience)
}
accessTokenClaims := oidc.NewOrganizationAwareAccessTokenClaims(
issuer,
subject,
audiences,
expirationTime,
"test-jti",
"test-client",
)
// Set scopes
accessTokenClaims.Scopes = scopes
privateClaims := map[string]interface{}{}
if organizationID != "" {
privateClaims[oidc.ClaimOrganizationID] = organizationID
}
accessTokenClaims.Claims = privateClaims
// Create JWT using go-jose
signer, err := jose.NewSigner(
jose.SigningKey{
Algorithm: jose.RS256,
Key: privateKey,
},
(&jose.SignerOptions{}).WithHeader("kid", "test-key-id"),
)
require.NoError(t, err)
claimsJSON, err := accessTokenClaims.MarshalJSON()
require.NoError(t, err)
signed, err := signer.Sign(claimsJSON)
require.NoError(t, err)
token, err := signed.CompactSerialize()
require.NoError(t, err)
return token
}
func TestJWTAuth_Authenticate(t *testing.T) {
t.Parallel()
autoPassingAdditionalChecks := []AdditionalCheck{
func(*http.Request, *oidc.AccessTokenClaims) error { return nil },
}
t.Run("success with valid token", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
auth := NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, []AdditionalCheck{})
// Create access token
token := createAccessToken(t, privateKey, issuer, "", []string{}, "test-user")
// Create request with valid token
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := auth.Authenticate(nil, req)
require.NoError(t, err)
require.True(t, authenticated)
})
t.Run("failure without authorization header", func(t *testing.T) {
t.Parallel()
keySet, _, issuer := setupTestKeySet(t)
tests := []struct {
name string
auth Authenticator
}{
{
name: "JWTAuth",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, []AdditionalCheck{}),
},
{
name: "JWTAuth with additional checks",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, autoPassingAdditionalChecks),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req = req.WithContext(logging.TestingContext())
authenticated, err := tt.auth.Authenticate(nil, req)
require.Error(t, err)
assert.Contains(t, err.Error(), "no authorization header")
assert.False(t, authenticated)
})
}
})
t.Run("failure with malformed authorization header", func(t *testing.T) {
t.Parallel()
keySet, _, issuer := setupTestKeySet(t)
headers := map[string]string{
"invalid scheme": "Invalid token",
"bearer without gap": "Bearertoken",
"bearer without token": "Bearer",
"bearer blank token": "Bearer ",
"extra token segment": "Bearer token extra",
}
tests := []struct {
name string
auth Authenticator
}{
{
name: "JWTAuth",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, nil),
},
{
name: "JWTAuth with additional checks",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, autoPassingAdditionalChecks),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for name, header := range headers {
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", header)
req = req.WithContext(logging.TestingContext())
authenticated, err := tt.auth.Authenticate(nil, req)
require.Error(t, err)
assert.False(t, authenticated)
assert.Contains(t, err.Error(), "malformed authorization header")
})
}
})
}
})
t.Run("failure with invalid token", func(t *testing.T) {
t.Parallel()
keySet, _, issuer := setupTestKeySet(t)
tests := []struct {
name string
auth Authenticator
}{
{
name: "JWTAuth",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, nil),
},
{
name: "JWTAuth with additional checks",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, autoPassingAdditionalChecks),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
req = req.WithContext(logging.TestingContext())
authenticated, err := tt.auth.Authenticate(nil, req)
require.Error(t, err)
require.False(t, authenticated)
})
}
})
t.Run("failure with expired token", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
tests := []struct {
name string
auth Authenticator
}{
{
name: "JWTAuth",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, nil),
},
{
name: "JWTAuth with additional checks",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, autoPassingAdditionalChecks),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create an expired token
now := stdtime.Now().UTC()
expirationTime := libtime.New(now.Add(-1 * stdtime.Hour)) // Expired 1 hour ago
accessTokenClaims := oidc.NewAccessTokenClaims(
issuer,
"test-user",
[]string{"test-client"},
expirationTime,
"test-jti",
"test-client",
)
signer, err := jose.NewSigner(
jose.SigningKey{
Algorithm: jose.RS256,
Key: privateKey,
},
(&jose.SignerOptions{}).WithHeader("kid", "test-key-id"),
)
require.NoError(t, err)
claimsJSON, err := json.Marshal(accessTokenClaims)
require.NoError(t, err)
signed, err := signer.Sign(claimsJSON)
require.NoError(t, err)
token, err := signed.CompactSerialize()
require.NoError(t, err)
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := tt.auth.Authenticate(nil, req)
require.Error(t, err)
assert.False(t, authenticated)
})
}
})
t.Run("failure with token before not-before time", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
tests := []struct {
name string
auth Authenticator
}{
{
name: "JWTAuth",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, nil),
},
{
name: "JWTAuth with additional checks",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, autoPassingAdditionalChecks),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
now := stdtime.Now().UTC()
expirationTime := libtime.New(now.Add(2 * stdtime.Hour))
accessTokenClaims := oidc.NewAccessTokenClaims(
issuer,
"test-user",
[]string{"test-client"},
expirationTime,
"test-jti",
"test-client",
)
accessTokenClaims.NotBefore = oidc.FromTime(libtime.New(now.Add(1 * stdtime.Hour)))
token := signAccessTokenClaims(t, privateKey, accessTokenClaims)
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := tt.auth.Authenticate(nil, req)
require.Error(t, err)
assert.ErrorIs(t, err, oidc.ErrNotBefore)
assert.False(t, authenticated)
})
}
})
t.Run("failure with malformed not-before claim", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
tests := []struct {
name string
auth Authenticator
}{
{
name: "JWTAuth",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, nil),
},
{
name: "JWTAuth with additional checks",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, autoPassingAdditionalChecks),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
now := stdtime.Now().UTC()
token := signClaims(t, privateKey, map[string]any{
"iss": issuer,
"sub": "test-user",
"aud": []string{"test-client"},
"exp": now.Add(2 * stdtime.Hour).Unix(),
"iat": now.Unix(),
"nbf": "not-a-time",
"client_id": "test-client",
"jti": "test-jti",
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := tt.auth.Authenticate(nil, req)
require.Error(t, err)
assert.Contains(t, err.Error(), "oidc.Time")
assert.False(t, authenticated)
})
}
})
t.Run("success with valid scopes for GET request", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
tests := []struct {
name string
auth Authenticator
}{
{
name: "JWTAuth",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", true, nil),
},
{
name: "JWTAuth with additional checks",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", true, autoPassingAdditionalChecks),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create access token with read scope
token := createAccessToken(t, privateKey, issuer, "", []string{"test-service:read"}, "test-user")
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := tt.auth.Authenticate(nil, req)
require.NoError(t, err)
assert.True(t, authenticated)
})
}
})
t.Run("success with write scope for POST request", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
tests := []struct {
name string
auth Authenticator
}{
{
name: "JWTAuth",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", true, nil),
},
{
name: "JWTAuth with additional checks",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", true, autoPassingAdditionalChecks),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create access token with write scope
token := createAccessToken(t, privateKey, issuer, "", []string{"test-service:write"}, "test-user")
req := httptest.NewRequest("POST", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := tt.auth.Authenticate(nil, req)
require.NoError(t, err)
assert.True(t, authenticated)
})
}
})
t.Run("failure with insufficient scopes for POST request", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
tests := []struct {
name string
auth Authenticator
}{
{
name: "JWTAuth",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", true, nil),
},
{
name: "JWTAuth with additional checks",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", true, autoPassingAdditionalChecks),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create access token with only read scope (not enough for POST)
token := createAccessToken(t, privateKey, issuer, "", []string{"test-service:read"}, "test-user")
req := httptest.NewRequest("POST", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := tt.auth.Authenticate(nil, req)
require.Error(t, err)
assert.False(t, authenticated)
assert.Contains(t, err.Error(), "missing access")
})
}
})
t.Run("success with write scope for GET request", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
tests := []struct {
name string
auth Authenticator
}{
{
name: "JWTAuth",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", true, nil),
},
{
name: "JWTAuth with additional checks",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", true, autoPassingAdditionalChecks),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create access token with write scope
token := createAccessToken(t, privateKey, issuer, "", []string{"test-service:write"}, "test-user")
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := tt.auth.Authenticate(nil, req)
require.NoError(t, err)
assert.True(t, authenticated)
})
}
})
t.Run("failure with different issuer", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
unexpectedIssuer := "https://test-issuer.differentdomain.com"
tests := []struct {
name string
auth Authenticator
}{
{
name: "JWTAuth",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, nil),
},
{
name: "JWTAuth with additional checks",
auth: NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, autoPassingAdditionalChecks),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create access token
token := createAccessToken(t, privateKey, unexpectedIssuer, "", []string{}, "test-user")
// Create request with valid token
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := tt.auth.Authenticate(nil, req)
require.Error(t, err)
assert.False(t, authenticated)
assert.ErrorIs(t, err, oidc.ErrIssuerInvalid)
})
}
})
t.Run("failure due to additional check", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
var additionalChecksPerformed = 0
expectedErr := errors.New("expected")
autoFailingAdditionalChecks := []AdditionalCheck{
func(*http.Request, *oidc.AccessTokenClaims) error {
additionalChecksPerformed++
return nil
},
func(*http.Request, *oidc.AccessTokenClaims) error {
additionalChecksPerformed++
return expectedErr
},
func(*http.Request, *oidc.AccessTokenClaims) error {
additionalChecksPerformed++
return nil
},
}
auth := NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, autoFailingAdditionalChecks)
// Create access token
token := createAccessToken(t, privateKey, issuer, "", []string{}, "test-user")
// Create request with valid token
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := auth.Authenticate(nil, req)
require.Error(t, err)
require.False(t, authenticated)
assert.ErrorIs(t, err, expectedErr)
assert.Equal(t, 2, additionalChecksPerformed)
})
t.Run("CheckOrganizationIDClaim success with valid token and correct orgID", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
expectedOrgID := "abcdefghijkl"
provider := func(*http.Request) (string, error) { return expectedOrgID, nil }
additionalChecks := []AdditionalCheck{
CheckOrganizationIDClaim(provider),
}
auth := NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, additionalChecks)
// Create access token
token := createAccessTokenWithOrgClaims(t, privateKey, issuer, "", []string{}, "test-user", expectedOrgID)
// Create request with valid token
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := auth.Authenticate(nil, req)
require.NoError(t, err)
assert.True(t, authenticated)
})
t.Run("CheckOrganizationIDClaim success with valid token and no expected orgID", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
provider := func(*http.Request) (string, error) { return "", nil }
additionalChecks := []AdditionalCheck{
CheckOrganizationIDClaim(provider),
}
auth := NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, additionalChecks)
// Create access token
token := createAccessTokenWithOrgClaims(t, privateKey, issuer, "", []string{}, "test-user", "")
// Create request with valid token
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := auth.Authenticate(nil, req)
require.NoError(t, err)
assert.True(t, authenticated)
})
t.Run("CheckOrganizationIDClaim failure with valid token and mismatched orgID", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
expectedOrgID := "abcdefghijkl"
provider := func(*http.Request) (string, error) { return expectedOrgID, nil }
additionalChecks := []AdditionalCheck{
CheckOrganizationIDClaim(provider),
}
auth := NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, additionalChecks)
// Create access token
token := createAccessTokenWithOrgClaims(t, privateKey, issuer, "", []string{}, "test-user", "someotherorgid")
// Create request with valid token
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := auth.Authenticate(nil, req)
require.Error(t, err)
assert.ErrorIs(t, err, oidc.ErrOrgIDInvalid)
assert.False(t, authenticated)
})
t.Run("CheckOrganizationIDClaim failure with token that doesn't contain orgID", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
expectedOrgID := "abcdefghijkl"
provider := func(*http.Request) (string, error) { return expectedOrgID, nil }
additionalChecks := []AdditionalCheck{
CheckOrganizationIDClaim(provider),
}
auth := NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, additionalChecks)
// Create access token
token := createAccessTokenWithOrgClaims(t, privateKey, issuer, "", []string{}, "test-user", "")
// Create request with valid token
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := auth.Authenticate(nil, req)
require.Error(t, err)
assert.ErrorIs(t, err, oidc.ErrOrgIDNotPresent)
assert.False(t, authenticated)
})
t.Run("CheckAudienceClaim audience mismatches", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
expectedAudience := "http://expected.mydomain.com"
additionalChecks := []AdditionalCheck{
CheckAudienceClaim(expectedAudience),
}
auth := NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, additionalChecks)
// Create access token
token := createAccessTokenWithOrgClaims(t, privateKey, issuer, "", []string{}, "test-user", "")
// Create request with valid token
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := auth.Authenticate(nil, req)
require.Error(t, err)
assert.ErrorIs(t, err, oidc.ErrAudience)
assert.False(t, authenticated)
})
t.Run("CheckAudienceClaim audience matches", func(t *testing.T) {
t.Parallel()
keySet, privateKey, issuer := setupTestKeySet(t)
expectedAudience := "http://expected.mydomain.com"
additionalChecks := []AdditionalCheck{
CheckAudienceClaim(expectedAudience),
}
auth := NewJWTAuth(map[string]oidc.KeySet{issuer: keySet}, "test-service", false, additionalChecks)
// Create access token
tokenAudience := expectedAudience
token := createAccessTokenWithOrgClaims(t, privateKey, issuer, tokenAudience, []string{}, "test-user", "")
// Create request with valid token
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(logging.TestingContext())
authenticated, err := auth.Authenticate(nil, req)
require.NoError(t, err)
assert.True(t, authenticated)
})
}