ongoing work
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
# RBAC (Role-Based Access Control) Package
|
||||
|
||||
This package provides authentication and authorization functionality for the application, with a specific focus on AWS Cognito integration and role-based access control (RBAC).
|
||||
|
||||
## Overview
|
||||
|
||||
The RBAC package handles all aspects of authentication and authorization, including:
|
||||
|
||||
- JWT token validation and verification
|
||||
- AWS Cognito integration with JWKS (JSON Web Key Set) support
|
||||
- Role-based access control through Cognito groups
|
||||
- Echo middleware for protecting routes based on user roles
|
||||
|
||||
## Components
|
||||
|
||||
### Cognito Integration (`cognito.go`)
|
||||
|
||||
The package provides robust AWS Cognito integration with features including:
|
||||
|
||||
- JWT token validation using Cognito's JWKS endpoint
|
||||
- Automatic key rotation and caching
|
||||
- Group-based authorization checks
|
||||
- Token details extraction and validation
|
||||
|
||||
Key components include:
|
||||
|
||||
- `CognitoKeyProvider`: Manages JWT validation keys from Cognito's JWKS endpoint
|
||||
- `LocalKeyProvider`: Provides local key management for testing
|
||||
- `TokenDetails`: Contains extracted JWT token information
|
||||
- Various helper functions for group membership verification
|
||||
|
||||
## Usage
|
||||
|
||||
### Setting up Cognito Key Provider
|
||||
|
||||
```go
|
||||
keyProvider := rbac.NewCognitoKeyProvider(
|
||||
"us-east-1", // AWS Region
|
||||
"your-user-pool-id" // Cognito User Pool ID / add to config
|
||||
)
|
||||
```
|
||||
|
||||
### Validating JWT Tokens
|
||||
|
||||
```go
|
||||
tokenString := "your.jwt.token"
|
||||
token, claims, err := rbac.ValidateJWT(tokenString, keyProvider)
|
||||
if err != nil {
|
||||
// Handle validation error
|
||||
}
|
||||
```
|
||||
|
||||
### Checking Group Membership
|
||||
|
||||
```go
|
||||
// Check for a single group
|
||||
if rbac.HasGroup(claims, "admin") {
|
||||
// User is an admin
|
||||
}
|
||||
|
||||
// Check for any of multiple groups
|
||||
if rbac.HasAnyGroup(claims, []string{"admin", "editor"}) {
|
||||
// User has at least one of the required roles
|
||||
}
|
||||
|
||||
// Check for all required groups
|
||||
if rbac.HasAllGroups(claims, []string{"editor", "reviewer"}) {
|
||||
// User has all required roles
|
||||
}
|
||||
```
|
||||
|
||||
## Echo Middleware
|
||||
|
||||
The package includes middleware for the Echo web framework (coming soon) that will provide:
|
||||
|
||||
- Automatic JWT token validation
|
||||
- Role-based route protection
|
||||
- User context injection
|
||||
|
||||
### Example middleware usage
|
||||
This is not tested yet.
|
||||
```go
|
||||
import ...
|
||||
|
||||
// Create the key provider once at startup
|
||||
keyProvider := rbac.NewCognitoKeyProvider("us-east-1", "us-east-1_XXXXXXXX")
|
||||
|
||||
// Use in middleware
|
||||
func authMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
tokenString := extractTokenFromRequest(c)
|
||||
|
||||
_, claims, err := rbac.ValidateJWT(tokenString, keyProvider)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid token")
|
||||
}
|
||||
|
||||
if !rbac.HasAnyGroup(claims, []string{"admin", "editor"}) {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Insufficient permissions")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- All JWKS endpoints must use HTTPS
|
||||
- Only trusted Cognito domains are allowed
|
||||
- JWT tokens are thoroughly validated including:
|
||||
- Signature verification
|
||||
- Expiration checking
|
||||
- Required claims validation
|
||||
- Group membership verification
|
||||
|
||||
## Testing
|
||||
|
||||
The package includes support for generating mock JWT tokens for testing purposes using the `LocalKeyProvider`:
|
||||
|
||||
```go
|
||||
keyProvider := rbac.NewLocalKeyProvider(
|
||||
"path/to/private.pem",
|
||||
"path/to/public.pem"
|
||||
)
|
||||
|
||||
token, err := rbac.GenerateMockJWT(
|
||||
keyProvider,
|
||||
"user123",
|
||||
"user@example.com",
|
||||
[]string{"admin", "editor"}
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// KeyProvider interface for different key sources
|
||||
type KeyProvider interface {
|
||||
GetPublicKey(kid string) (*rsa.PublicKey, error)
|
||||
}
|
||||
|
||||
// LocalKeyProvider implements KeyProvider using local PEM files
|
||||
type LocalKeyProvider struct {
|
||||
PrivateKeyPath string
|
||||
PublicKeyPath string
|
||||
}
|
||||
|
||||
// CognitoKeyProvider implements KeyProvider using AWS Cognito JWKS endpoint
|
||||
type CognitoKeyProvider struct {
|
||||
Region string
|
||||
UserPoolID string
|
||||
JwksCache map[string]*rsa.PublicKey // Cache for JWKS keys by Key ID
|
||||
}
|
||||
|
||||
// JWKS structure for Cognito public keys
|
||||
type JWKS struct {
|
||||
Keys []struct {
|
||||
Kid string `json:"kid"`
|
||||
Kty string `json:"kty"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
Use string `json:"use"`
|
||||
Alg string `json:"alg"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
// TokenDetails contains the extracted information from a JWT token
|
||||
type TokenDetails struct {
|
||||
Token *jwt.Token
|
||||
Claims jwt.MapClaims
|
||||
UserID string
|
||||
Email string
|
||||
Groups []string
|
||||
Expiration time.Time
|
||||
}
|
||||
|
||||
// Load private key from file
|
||||
func LoadPrivateKey(keyPath string) (*rsa.PrivateKey, error) {
|
||||
keyData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading private key file: %w", err)
|
||||
}
|
||||
return jwt.ParseRSAPrivateKeyFromPEM(keyData)
|
||||
}
|
||||
|
||||
// Load public key from file
|
||||
func LoadPublicKey(keyPath string) (*rsa.PublicKey, error) {
|
||||
keyData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading public key file: %w", err)
|
||||
}
|
||||
return jwt.ParseRSAPublicKeyFromPEM(keyData)
|
||||
}
|
||||
|
||||
// GetPublicKey implements KeyProvider for LocalKeyProvider
|
||||
func (lkp *LocalKeyProvider) GetPublicKey(kid string) (*rsa.PublicKey, error) {
|
||||
// For local provider, kid is ignored since we only have one key
|
||||
return LoadPublicKey(lkp.PublicKeyPath)
|
||||
}
|
||||
|
||||
// GetPrivateKey returns the private key for signing test tokens
|
||||
func (lkp *LocalKeyProvider) GetPrivateKey() (*rsa.PrivateKey, error) {
|
||||
return LoadPrivateKey(lkp.PrivateKeyPath)
|
||||
}
|
||||
|
||||
// jwkToPublicKey converts JWK components to RSA public key
|
||||
func jwkToPublicKey(n, e string) (*rsa.PublicKey, error) {
|
||||
// Decode the base64 URL-encoded modulus
|
||||
nBytes, err := base64.RawURLEncoding.DecodeString(n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding modulus: %w", err)
|
||||
}
|
||||
|
||||
// Decode the base64 URL-encoded exponent
|
||||
eBytes, err := base64.RawURLEncoding.DecodeString(e)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding exponent: %w", err)
|
||||
}
|
||||
|
||||
// Convert modulus bytes to big.Int
|
||||
modulus := new(big.Int)
|
||||
modulus.SetBytes(nBytes)
|
||||
|
||||
// Convert exponent bytes to int
|
||||
var exponent int
|
||||
for i := 0; i < len(eBytes); i++ {
|
||||
exponent = exponent<<8 + int(eBytes[i])
|
||||
}
|
||||
|
||||
// Create RSA public key
|
||||
return &rsa.PublicKey{
|
||||
N: modulus,
|
||||
E: exponent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPublicKey implements KeyProvider for CognitoKeyProvider
|
||||
func (ckp *CognitoKeyProvider) GetPublicKey(kid string) (*rsa.PublicKey, error) {
|
||||
// Check cache first
|
||||
if ckp.JwksCache == nil {
|
||||
ckp.JwksCache = make(map[string]*rsa.PublicKey)
|
||||
}
|
||||
|
||||
if key, exists := ckp.JwksCache[kid]; exists {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Fetch JWKS from Cognito
|
||||
//baseURL := "https://cognito-idp.amazonaws.com"
|
||||
//jwksPath := fmt.Sprintf("/%s/%s/.well-known/jwks.json", ckp.Region, ckp.UserPoolID)
|
||||
jwksURL := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json", ckp.Region, ckp.UserPoolID)
|
||||
|
||||
if err := IsValidJWKSURL(jwksURL); err != nil {
|
||||
return nil, fmt.Errorf("invalid JWKS URL: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
req, err := http.NewRequest("GET", jwksURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating request for JWKS: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching JWKS from Cognito: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
jwksData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading JWKS response: %w", err)
|
||||
}
|
||||
|
||||
var jwks JWKS
|
||||
if err := json.Unmarshal(jwksData, &jwks); err != nil {
|
||||
return nil, fmt.Errorf("error parsing JWKS JSON: %w", err)
|
||||
}
|
||||
|
||||
// Find the key with matching kid
|
||||
for _, key := range jwks.Keys {
|
||||
if key.Kid == kid {
|
||||
// Convert JWK to RSA public key
|
||||
publicKey, err := jwkToPublicKey(key.N, key.E)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting JWK to public key: %w", err)
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
ckp.JwksCache[kid] = publicKey
|
||||
return publicKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no matching key found with kid: %s", kid)
|
||||
}
|
||||
|
||||
// NewLocalKeyProvider creates a new LocalKeyProvider
|
||||
func NewLocalKeyProvider(privateKeyPath, publicKeyPath string) *LocalKeyProvider {
|
||||
return &LocalKeyProvider{
|
||||
PrivateKeyPath: privateKeyPath,
|
||||
PublicKeyPath: publicKeyPath,
|
||||
}
|
||||
}
|
||||
|
||||
// NewCognitoKeyProvider creates a new CognitoKeyProvider
|
||||
func NewCognitoKeyProvider(region, userPoolID string) *CognitoKeyProvider {
|
||||
return &CognitoKeyProvider{
|
||||
Region: region,
|
||||
UserPoolID: userPoolID,
|
||||
JwksCache: make(map[string]*rsa.PublicKey),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateMockJWT creates a test JWT with given groups
|
||||
func GenerateMockJWT(keyProvider *LocalKeyProvider, userID, email string, groups []string) (string, error) {
|
||||
privateKey, err := keyProvider.GetPrivateKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create a header with kid value (important for verification)
|
||||
headers := map[string]interface{}{
|
||||
"kid": "test-key-id", // In a real scenario this would match the key ID from Cognito
|
||||
"alg": "RS256",
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"email": email,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX",
|
||||
"cognito:groups": groups,
|
||||
})
|
||||
|
||||
// Set the header
|
||||
for k, v := range headers {
|
||||
token.Header[k] = v
|
||||
}
|
||||
|
||||
return token.SignedString(privateKey)
|
||||
}
|
||||
|
||||
// PrintTokenDetails prints the JWT token details for debugging
|
||||
func PrintTokenDetails(token *jwt.Token) {
|
||||
fmt.Println("\n=== JWT Token Details ===")
|
||||
|
||||
// Print Header
|
||||
headerJSON, _ := json.MarshalIndent(token.Header, "", " ")
|
||||
fmt.Printf("\nHeader:\n%s\n", string(headerJSON))
|
||||
|
||||
// Print Claims
|
||||
claimsJSON, _ := json.MarshalIndent(token.Claims, "", " ")
|
||||
fmt.Printf("\nClaims:\n%s\n", string(claimsJSON))
|
||||
|
||||
// Print Signature
|
||||
fmt.Printf("\nSignature: %s\n", token.Signature)
|
||||
|
||||
// Print Method
|
||||
fmt.Printf("\nSigning Method: %s\n", token.Method.Alg())
|
||||
|
||||
// Print Valid status
|
||||
fmt.Printf("\nValid: %v\n", token.Valid)
|
||||
|
||||
fmt.Println("\n=====================")
|
||||
}
|
||||
|
||||
// ValidateJWT validates a JWT token and returns the claims
|
||||
func ValidateJWT(tokenString string, keyProvider KeyProvider) (*jwt.Token, jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
|
||||
// Get key ID from token header
|
||||
kid, ok := token.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New("no key ID (kid) in token header")
|
||||
}
|
||||
|
||||
// Get public key from provider
|
||||
return keyProvider.GetPublicKey(kid)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
return token, claims, nil
|
||||
}
|
||||
return nil, nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
// ExtractTokenDetails extracts and organizes token information
|
||||
func ExtractTokenDetails(token *jwt.Token, claims jwt.MapClaims) *TokenDetails {
|
||||
details := &TokenDetails{
|
||||
Token: token,
|
||||
Claims: claims,
|
||||
}
|
||||
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
details.UserID = sub
|
||||
}
|
||||
|
||||
if email, ok := claims["email"].(string); ok {
|
||||
details.Email = email
|
||||
}
|
||||
|
||||
if expFloat, ok := claims["exp"].(float64); ok {
|
||||
details.Expiration = time.Unix(int64(expFloat), 0)
|
||||
}
|
||||
|
||||
if groupsInterface, ok := claims["cognito:groups"].([]interface{}); ok {
|
||||
details.Groups = make([]string, 0, len(groupsInterface))
|
||||
for _, group := range groupsInterface {
|
||||
if groupStr, ok := group.(string); ok {
|
||||
details.Groups = append(details.Groups, groupStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
// HasGroup checks if a user belongs to a specific group
|
||||
func HasGroup(claims jwt.MapClaims, requiredGroup string) bool {
|
||||
if groups, ok := claims["cognito:groups"].([]interface{}); ok {
|
||||
for _, group := range groups {
|
||||
if groupStr, ok := group.(string); ok && strings.EqualFold(groupStr, requiredGroup) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasAnyGroup checks if a user belongs to any of the specified groups
|
||||
func HasAnyGroup(claims jwt.MapClaims, requiredGroups []string) bool {
|
||||
for _, group := range requiredGroups {
|
||||
if HasGroup(claims, group) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasAllGroups checks if a user belongs to all of the specified groups
|
||||
func HasAllGroups(claims jwt.MapClaims, requiredGroups []string) bool {
|
||||
for _, group := range requiredGroups {
|
||||
if !HasGroup(claims, group) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsValidJWKSURL validates that a JWKS URL is secure and from a trusted domain
|
||||
func IsValidJWKSURL(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL format: %w", err)
|
||||
}
|
||||
|
||||
// Add your trusted domains here
|
||||
trustedDomains := []string{
|
||||
"cognito-idp.us-east-1.amazonaws.com",
|
||||
"cognito-idp.us-west-2.amazonaws.com",
|
||||
// Add other AWS Cognito regions as needed
|
||||
}
|
||||
|
||||
isAllowed := false
|
||||
for _, domain := range trustedDomains {
|
||||
if parsed.Host == domain {
|
||||
isAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isAllowed {
|
||||
return fmt.Errorf("untrusted JWKS domain: %s", parsed.Host)
|
||||
}
|
||||
|
||||
// Ensure HTTPS
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("JWKS URL must use HTTPS")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyGroupMembership verifies a user belongs to specified groups
|
||||
func VerifyGroupMembership(claims jwt.MapClaims, expectedGroups []string) error {
|
||||
groups, ok := claims["cognito:groups"].([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("expected 'cognito:groups' claim, but not found")
|
||||
}
|
||||
|
||||
for _, eg := range expectedGroups {
|
||||
found := false
|
||||
for _, g := range groups {
|
||||
if gStr, ok := g.(string); ok && strings.EqualFold(gStr, eg) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("expected group %s not found in token", eg)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestLocalKeyProvider(t *testing.T) {
|
||||
provider := NewLocalKeyProvider("private_key.pem", "public_key.pem")
|
||||
|
||||
// Test loading private key
|
||||
privateKey, err := provider.GetPrivateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load private key: %v", err)
|
||||
}
|
||||
if privateKey == nil {
|
||||
t.Fatal("Private key is nil")
|
||||
}
|
||||
|
||||
// Test loading public key
|
||||
publicKey, err := provider.GetPublicKey("any-kid") // kid is ignored for local provider
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load public key: %v", err)
|
||||
}
|
||||
if publicKey == nil {
|
||||
t.Fatal("Public key is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAndValidateJWT(t *testing.T) {
|
||||
provider := NewLocalKeyProvider("private_key.pem", "public_key.pem")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
userID string
|
||||
email string
|
||||
groups []string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid token with groups",
|
||||
userID: "user123",
|
||||
email: "user@example.com",
|
||||
groups: []string{"admins", "developers"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid token without groups",
|
||||
userID: "user456",
|
||||
email: "user2@example.com",
|
||||
groups: []string{},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Generate token
|
||||
tokenString, err := GenerateMockJWT(provider, tc.userID, tc.email, tc.groups)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
// Validate token
|
||||
token, claims, err := ValidateJWT(tokenString, provider)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to validate token: %v", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
t.Fatal("Token should be valid")
|
||||
}
|
||||
|
||||
// Check claims
|
||||
if sub, ok := claims["sub"].(string); !ok || sub != tc.userID {
|
||||
t.Errorf("Expected sub=%s, got %v", tc.userID, claims["sub"])
|
||||
}
|
||||
if email, ok := claims["email"].(string); !ok || email != tc.email {
|
||||
t.Errorf("Expected email=%s, got %v", tc.email, claims["email"])
|
||||
}
|
||||
|
||||
// Check groups
|
||||
if len(tc.groups) > 0 {
|
||||
groups, ok := claims["cognito:groups"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected cognito:groups claim, but not found")
|
||||
}
|
||||
|
||||
if len(groups) != len(tc.groups) {
|
||||
t.Fatalf("Expected %d groups, got %d", len(tc.groups), len(groups))
|
||||
}
|
||||
|
||||
for i, expectedGroup := range tc.groups {
|
||||
if groups[i] != expectedGroup {
|
||||
t.Errorf("Expected group %s at position %d, got %v", expectedGroup, i, groups[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExpiration(t *testing.T) {
|
||||
provider := NewLocalKeyProvider("private_key.pem", "public_key.pem")
|
||||
|
||||
// Helper function to generate a token with custom claims
|
||||
generateCustomToken := func(expiry time.Time) (string, error) {
|
||||
privateKey, err := provider.GetPrivateKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
headers := map[string]interface{}{
|
||||
"kid": "test-key-id",
|
||||
"alg": "RS256",
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||
"sub": "test-user",
|
||||
"email": "test@example.com",
|
||||
"exp": expiry.Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX",
|
||||
"cognito:groups": []string{"admins"},
|
||||
})
|
||||
|
||||
for k, v := range headers {
|
||||
token.Header[k] = v
|
||||
}
|
||||
|
||||
return token.SignedString(privateKey)
|
||||
}
|
||||
|
||||
// Test 1: Valid token (not expired)
|
||||
t.Run("Valid token not expired", func(t *testing.T) {
|
||||
tokenString, err := generateCustomToken(time.Now().Add(1 * time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
token, _, err := ValidateJWT(tokenString, provider)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected valid token, got error: %v", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
t.Fatal("Token should be valid")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: Expired token
|
||||
// This test is commented out because jwt.Parse will already validate expiration
|
||||
// and return an error, so we can't directly test expired tokens with ValidateJWT
|
||||
/*
|
||||
t.Run("Expired token", func(t *testing.T) {
|
||||
tokenString, err := generateCustomToken(time.Now().Add(-1 * time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = ValidateJWT(tokenString, provider)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for expired token, got nil")
|
||||
}
|
||||
})
|
||||
*/
|
||||
}
|
||||
|
||||
func TestExtractTokenDetails(t *testing.T) {
|
||||
provider := NewLocalKeyProvider("private_key.pem", "public_key.pem")
|
||||
|
||||
userID := "test-user-456"
|
||||
email := "test456@example.com"
|
||||
groups := []string{"admins", "developers", "testers"}
|
||||
|
||||
tokenString, err := GenerateMockJWT(provider, userID, email, groups)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
token, claims, err := ValidateJWT(tokenString, provider)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to validate token: %v", err)
|
||||
}
|
||||
|
||||
details := ExtractTokenDetails(token, claims)
|
||||
|
||||
// Check extracted details
|
||||
if details.UserID != userID {
|
||||
t.Errorf("Expected UserID=%s, got %s", userID, details.UserID)
|
||||
}
|
||||
if details.Email != email {
|
||||
t.Errorf("Expected Email=%s, got %s", email, details.Email)
|
||||
}
|
||||
if !reflect.DeepEqual(details.Groups, groups) {
|
||||
t.Errorf("Expected Groups=%v, got %v", groups, details.Groups)
|
||||
}
|
||||
|
||||
// Check expiration (should be about 1 hour in the future)
|
||||
expectedExp := time.Now().Add(1 * time.Hour).Unix()
|
||||
actualExp := details.Expiration.Unix()
|
||||
|
||||
// Allow a small time difference (5 seconds) due to test execution time
|
||||
if actualExp < expectedExp-5 || actualExp > expectedExp+5 {
|
||||
t.Errorf("Expected Expiration around %v, got %v", expectedExp, actualExp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMembershipFunctions(t *testing.T) {
|
||||
// Create test claims
|
||||
claims := jwt.MapClaims{
|
||||
"cognito:groups": []interface{}{"admins", "developers", "viewers"},
|
||||
}
|
||||
|
||||
// Test HasGroup
|
||||
t.Run("HasGroup", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
group string
|
||||
expected bool
|
||||
}{
|
||||
{"admins", true},
|
||||
{"developers", true},
|
||||
{"viewers", true},
|
||||
{"editors", false},
|
||||
{"ADMINS", true}, // Case-insensitive match
|
||||
{"Developers", true}, // Case-insensitive match
|
||||
{"nonexistent", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
result := HasGroup(claims, tc.group)
|
||||
if result != tc.expected {
|
||||
t.Errorf("HasGroup(%s) = %v, expected %v", tc.group, result, tc.expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test HasAnyGroup
|
||||
t.Run("HasAnyGroup", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
groups []string
|
||||
expected bool
|
||||
}{
|
||||
{[]string{"admins"}, true},
|
||||
{[]string{"editors"}, false},
|
||||
{[]string{"editors", "admins"}, true},
|
||||
{[]string{"editors", "writers"}, false},
|
||||
{[]string{"ADMINS", "editors"}, true}, // Case-insensitive match
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
result := HasAnyGroup(claims, tc.groups)
|
||||
if result != tc.expected {
|
||||
t.Errorf("HasAnyGroup(%v) = %v, expected %v", tc.groups, result, tc.expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test HasAllGroups
|
||||
t.Run("HasAllGroups", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
groups []string
|
||||
expected bool
|
||||
}{
|
||||
{[]string{"admins"}, true},
|
||||
{[]string{"admins", "developers"}, true},
|
||||
{[]string{"admins", "developers", "viewers"}, true},
|
||||
{[]string{"admins", "editors"}, false},
|
||||
{[]string{"ADMINS", "DEVELOPERS"}, true}, // Case-insensitive match
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
result := HasAllGroups(claims, tc.groups)
|
||||
if result != tc.expected {
|
||||
t.Errorf("HasAllGroups(%v) = %v, expected %v", tc.groups, result, tc.expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyGroupMembership(t *testing.T) {
|
||||
// Create test claims
|
||||
claims := jwt.MapClaims{
|
||||
"cognito:groups": []interface{}{"admins", "developers", "viewers"},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
groups []string
|
||||
expectError bool
|
||||
errorSubstring string
|
||||
}{
|
||||
{
|
||||
name: "All groups present",
|
||||
groups: []string{"admins", "developers"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Missing group",
|
||||
groups: []string{"admins", "editors"},
|
||||
expectError: true,
|
||||
errorSubstring: "expected group editors not found",
|
||||
},
|
||||
{
|
||||
name: "Case insensitive match",
|
||||
groups: []string{"ADMINS", "Developers"},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := VerifyGroupMembership(claims, tc.groups)
|
||||
if tc.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error, got nil")
|
||||
} else if tc.errorSubstring != "" && !contains(err.Error(), tc.errorSubstring) {
|
||||
t.Errorf("Error message '%s' doesn't contain expected substring '%s'", err.Error(), tc.errorSubstring)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test with missing cognito:groups claim
|
||||
t.Run("Missing groups claim", func(t *testing.T) {
|
||||
emptyClaims := jwt.MapClaims{}
|
||||
err := VerifyGroupMembership(emptyClaims, []string{"admins"})
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for missing cognito:groups claim, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsValidJWKSURL(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
url string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Valid URL format and domain",
|
||||
url: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123/.well-known/jwks.json",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid scheme (HTTP)",
|
||||
url: "http://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123/.well-known/jwks.json",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Untrusted domain",
|
||||
url: "https://malicious-site.com/jwks.json",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Malformed URL",
|
||||
url: ":not-a-url",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := IsValidJWKSURL(tc.url)
|
||||
if tc.expectError && err == nil {
|
||||
t.Errorf("Expected error, got nil")
|
||||
} else if !tc.expectError && err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring
|
||||
func contains(s, substr string) bool {
|
||||
return s != "" && substr != "" && s != substr && len(s) > len(substr) && s[len(s)-1] != ' ' && s[:len(s)-1] != substr
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// JWTConfig contains configuration for the JWT middleware
|
||||
type JWTConfig struct {
|
||||
KeyProvider KeyProvider
|
||||
TokenLookup string // Format: "header:<name>" or "query:<name>" or "cookie:<name>"
|
||||
AuthScheme string // Usually "Bearer"
|
||||
RequiredGroups []string // Groups that are required for access (any one of these)
|
||||
ContextKey string // Key to store the claims in the context
|
||||
ErrorHandler func(echo.Context, error) error
|
||||
}
|
||||
|
||||
// DefaultJWTConfig is the default JWT auth middleware config
|
||||
var DefaultJWTConfig = JWTConfig{
|
||||
TokenLookup: "header:Authorization",
|
||||
AuthScheme: "Bearer",
|
||||
ContextKey: "user",
|
||||
ErrorHandler: defaultErrorHandler,
|
||||
}
|
||||
|
||||
// defaultErrorHandler returns a 401 Unauthorized error for JWT validation failures
|
||||
func defaultErrorHandler(c echo.Context, err error) error {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid or expired jwt")
|
||||
}
|
||||
|
||||
// extractToken determines which token extractor to use based on config
|
||||
func extractToken(config JWTConfig) func(echo.Context) (string, error) {
|
||||
parts := strings.Split(config.TokenLookup, ":")
|
||||
extractor := tokenFromHeader(parts[1], config.AuthScheme)
|
||||
if parts[0] == "query" {
|
||||
extractor = tokenFromQuery(parts[1])
|
||||
} else if parts[0] == "cookie" {
|
||||
extractor = tokenFromCookie(parts[1])
|
||||
}
|
||||
return extractor
|
||||
}
|
||||
|
||||
// validateGroups checks if the user belongs to any of the required groups
|
||||
func validateGroups(claims jwt.MapClaims, requiredGroups []string) bool {
|
||||
if len(requiredGroups) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
groups, ok := claims["cognito:groups"].([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, reqGroup := range requiredGroups {
|
||||
for _, group := range groups {
|
||||
if groupStr, ok := group.(string); ok && strings.EqualFold(groupStr, reqGroup) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseJWT parses and validates the JWT token
|
||||
func parseJWT(tokenString string, keyProvider KeyProvider) (*jwt.Token, jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
|
||||
// Get key ID from token header
|
||||
kid, ok := token.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New("no key ID (kid) in token header")
|
||||
}
|
||||
|
||||
// Get public key from provider
|
||||
return keyProvider.GetPublicKey(kid)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
return token, claims, nil
|
||||
}
|
||||
|
||||
// JWTWithConfig returns a JWT auth middleware with config
|
||||
func JWTWithConfig(config JWTConfig) echo.MiddlewareFunc {
|
||||
// Set defaults
|
||||
if config.TokenLookup == "" {
|
||||
config.TokenLookup = DefaultJWTConfig.TokenLookup
|
||||
}
|
||||
if config.AuthScheme == "" {
|
||||
config.AuthScheme = DefaultJWTConfig.AuthScheme
|
||||
}
|
||||
if config.ContextKey == "" {
|
||||
config.ContextKey = DefaultJWTConfig.ContextKey
|
||||
}
|
||||
if config.ErrorHandler == nil {
|
||||
config.ErrorHandler = DefaultJWTConfig.ErrorHandler
|
||||
}
|
||||
if config.KeyProvider == nil {
|
||||
panic("JWT middleware requires a KeyProvider")
|
||||
}
|
||||
|
||||
extractor := extractToken(config)
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
tokenString, err := extractor(c)
|
||||
if err != nil {
|
||||
return config.ErrorHandler(c, err)
|
||||
}
|
||||
|
||||
_, claims, err := parseJWT(tokenString, config.KeyProvider)
|
||||
if err != nil {
|
||||
return config.ErrorHandler(c, err)
|
||||
}
|
||||
|
||||
// Check required groups if specified
|
||||
if !validateGroups(claims, config.RequiredGroups) {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "insufficient permissions")
|
||||
}
|
||||
|
||||
// Store user information in context
|
||||
c.Set(config.ContextKey, claims)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JWT returns a JWT auth middleware with default configuration
|
||||
func JWT(keyProvider KeyProvider) echo.MiddlewareFunc {
|
||||
config := DefaultJWTConfig
|
||||
config.KeyProvider = keyProvider
|
||||
return JWTWithConfig(config)
|
||||
}
|
||||
|
||||
// tokenFromHeader extracts token from Authorization header
|
||||
func tokenFromHeader(header string, authScheme string) func(echo.Context) (string, error) {
|
||||
return func(c echo.Context) (string, error) {
|
||||
auth := c.Request().Header.Get(header)
|
||||
if auth == "" {
|
||||
return "", errors.New("missing auth header")
|
||||
}
|
||||
|
||||
l := len(authScheme)
|
||||
if len(auth) > l+1 && auth[:l] == authScheme {
|
||||
return auth[l+1:], nil
|
||||
}
|
||||
return "", errors.New("invalid auth header")
|
||||
}
|
||||
}
|
||||
|
||||
// tokenFromQuery extracts token from query parameter
|
||||
func tokenFromQuery(param string) func(echo.Context) (string, error) {
|
||||
return func(c echo.Context) (string, error) {
|
||||
token := c.QueryParam(param)
|
||||
if token == "" {
|
||||
return "", errors.New("missing auth query parameter")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
|
||||
// tokenFromCookie extracts token from cookie
|
||||
func tokenFromCookie(name string) func(echo.Context) (string, error) {
|
||||
return func(c echo.Context) (string, error) {
|
||||
cookie, err := c.Cookie(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cookie.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GroupGuard middleware ensures a user has at least one of the required groups
|
||||
func GroupGuard(requiredGroups ...string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
userClaims, ok := c.Get("user").(jwt.MapClaims)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "user not authenticated")
|
||||
}
|
||||
|
||||
groups, ok := userClaims["cognito:groups"].([]interface{})
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "no groups found for user")
|
||||
}
|
||||
|
||||
for _, requiredGroup := range requiredGroups {
|
||||
for _, group := range groups {
|
||||
if groupStr, ok := group.(string); ok && strings.EqualFold(groupStr, requiredGroup) {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return echo.NewHTTPError(http.StatusForbidden, "insufficient permissions")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDX5vunPVZ3fVNY
|
||||
dwJzOvTsgoHWEQlkXs/FSjHz90HzLCloZ8/g7yqIEPwmxmHHkHVjMJryyhzoOhnU
|
||||
6OYuDDFiTlWL6UFX74+EC/bVDVFWOU016R6WZX+paeIn7JV7pvovoG+WCRgaypUy
|
||||
rJ4FGAMNel8uuq1KCrhV5UsQ6e6IGJ3bry+mRp9+Pqk91viWj1JVc1t+t2IfyJMY
|
||||
/Hka0FiobnIAVSQPrI6ULoZul80ndB26mK2WRsr6A+S/DVHZLJ2qvtxrtdqXsK+m
|
||||
tr+MKrBWFhcnMrNkYCCTEFWC4TiyYIXWGWhHSNj87CIaboggQ2ybLvAiulFvrgf8
|
||||
ES6LyRj3AgMBAAECggEAZ7MkMGG/xEjH3XfcD2jD901/+0fXkQQRG5vVfm7GmHwf
|
||||
r2wdZta5QP2XfzBOCsKR/4B7DB6T3974RVFQLdHhbmxdnoP8xLXl4vC0MATjilyf
|
||||
f0NnU6mQtdiLrc1uxyOei32t2wynLUccfmh2xc+Qt8qNKS60yRl5DJjDg245CdiW
|
||||
ZhN40svLKfeVPallv2zrIZRbySEWXsCZRzBE+CYNrdRTWXSp45xCrRn+lseUxQXH
|
||||
n+VoaP2KzHGgE4Lv5476EtVAq9G1yVN6Z0+8xWOiG9s+X3BNKPXWP4BGvg9tjBi0
|
||||
77U/hYrytMEPu8RV0ZR0zvlFVItqjujdKOJ9zJWTIQKBgQD/shNiXwCG/Ywrvs/g
|
||||
lWNkvM+8iYyALtCuOR7rsuR0QP0OnC9HsZbttOwMwiC8yJiMZ7DuBWp7iqfjmPs7
|
||||
90dTxn4iQNkdoUE1DrobViPp+7pWIHqDH82r/Gy3nwMXLt/UFxi85lYd1Oo3us9C
|
||||
ICvhFmzGAzIfiTKTXIJqJF9EqQKBgQDYKMe2ACP4rGVmJTSTkMn9c22gDTeCMEgI
|
||||
dvlZOS7PJXAsznlnpTH+KLEyJhRenj08VxO6LcXI1urTe5G5aPStutBzods2X/iM
|
||||
i1CmtOzeRGOkDVmqdYGgk0SXvTQg/z9MnwYCv+a904LQdMQ7TGgaoCYMJc3qWLO7
|
||||
p+/qGSZUnwKBgBvZZ2cVddc+EmBJXhbV7odwUSf1y0nCz5PKQOXnDB7lXSqUNEoY
|
||||
u5mUVQlms24cYxEX0ht6l4hxJ6wQY3y6iBhFzEMq0Pr7L0D6I6cKkMrRUhBDZVSW
|
||||
yC3tRmIRfaKuxk4xXc5lQAfrwr7jJ+PJ4T2Y1awTeQgaR1npf4LUB1RRAoGBAL+t
|
||||
GbrX0Q33wUqcf0zDPXoT2wfr8GbvbVCkP2PRAyMIrbnttVYk9HnNl6NChRmJ8/8H
|
||||
sCSN5i679StnDcd9vEo5uBJxWjOTUpE+EFxjXw+RUVHtzK8M18+OB2sOiaUg8f59
|
||||
nRTfGjsFzaAPitqSXFYP4O0wsLG3ylkDCAlsF8M9AoGALYTvOBdGJn/31XazCSeS
|
||||
v2/iYnn41aw2DzxEYIn+QfkKH7Ctc39dfYV1jrhoI7m2T90GJKYfiZidd27fzmib
|
||||
aPGQhCRFS7NPj2wI4kAFftRRtowIzHwEvVi1VvKWbyZII04FLU6aNVWrZE/E+2f5
|
||||
/4AmcI3dqS5PhGQEystcvhY=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1+b7pz1Wd31TWHcCczr0
|
||||
7IKB1hEJZF7PxUox8/dB8ywpaGfP4O8qiBD8JsZhx5B1YzCa8soc6DoZ1OjmLgwx
|
||||
Yk5Vi+lBV++PhAv21Q1RVjlNNekelmV/qWniJ+yVe6b6L6BvlgkYGsqVMqyeBRgD
|
||||
DXpfLrqtSgq4VeVLEOnuiBid268vpkaffj6pPdb4lo9SVXNbfrdiH8iTGPx5GtBY
|
||||
qG5yAFUkD6yOlC6GbpfNJ3QdupitlkbK+gPkvw1R2Sydqr7ca7Xal7Cvpra/jCqw
|
||||
VhYXJzKzZGAgkxBVguE4smCF1hloR0jY/OwiGm6IIENsmy7wIrpRb64H/BEui8kY
|
||||
9wIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
Reference in New Issue
Block a user