Enterprise API Gateway

Part 2: Authentication & Authorization

software active Part 2 of 4
Technologies:
GoEnvoygRPCKubernetesPrometheus

Building on our core gateway, we’ll now implement a comprehensive security layer with JWT validation, OAuth2 integration, and role-based access control.

Security Architecture

Our security implementation includes:

  • JWT Token Validation: Verify and decode JWT tokens
  • OAuth2 Integration: Support for multiple OAuth2 providers
  • RBAC (Role-Based Access Control): Fine-grained permissions
  • API Key Management: Support for API keys alongside tokens

JWT Authentication Service

First, let’s implement a JWT validation service:

// internal/auth/jwt.go
package auth

import (
    "crypto/rsa"
    "fmt"
    "time"
    
    "github.com/golang-jwt/jwt/v4"
)

type JWTValidator struct {
    publicKey   *rsa.PublicKey
    issuer      string
    audience    string
}

type Claims struct {
    UserID    string   `json:"user_id"`
    Email     string   `json:"email"`
    Roles     []string `json:"roles"`
    Scopes    []string `json:"scopes"`
    jwt.RegisteredClaims
}

func NewJWTValidator(publicKey *rsa.PublicKey, issuer, audience string) *JWTValidator {
    return &JWTValidator{
        publicKey: publicKey,
        issuer:    issuer,
        audience:  audience,
    }
}

func (v *JWTValidator) ValidateToken(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return v.publicKey, nil
    })
    
    if err != nil {
        return nil, err
    }
    
    if claims, ok := token.Claims.(*Claims); ok && token.Valid {
        // Validate issuer and audience
        if claims.Issuer != v.issuer {
            return nil, fmt.Errorf("invalid issuer")
        }
        
        if !claims.VerifyAudience(v.audience, true) {
            return nil, fmt.Errorf("invalid audience")
        }
        
        return claims, nil
    }
    
    return nil, fmt.Errorf("invalid token")
}

OAuth2 Integration

We implement OAuth2 support for multiple providers:

// internal/auth/oauth2.go
package auth

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    
    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
    "golang.org/x/oauth2/github"
)

type OAuth2Provider interface {
    GetConfig() *oauth2.Config
    GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error)
}

type UserInfo struct {
    ID       string `json:"id"`
    Email    string `json:"email"`
    Name     string `json:"name"`
    Picture  string `json:"picture"`
    Provider string `json:"provider"`
}

type GoogleProvider struct {
    config *oauth2.Config
}

func NewGoogleProvider(clientID, clientSecret, redirectURL string) *GoogleProvider {
    return &GoogleProvider{
        config: &oauth2.Config{
            ClientID:     clientID,
            ClientSecret: clientSecret,
            RedirectURL:  redirectURL,
            Scopes:       []string{"openid", "email", "profile"},
            Endpoint:     google.Endpoint,
        },
    }
}

func (p *GoogleProvider) GetConfig() *oauth2.Config {
    return p.config
}

func (p *GoogleProvider) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
    client := p.config.Client(ctx, token)
    resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    var googleUser struct {
        ID      string `json:"id"`
        Email   string `json:"email"`
        Name    string `json:"name"`
        Picture string `json:"picture"`
    }
    
    if err := json.NewDecoder(resp.Body).Decode(&googleUser); err != nil {
        return nil, err
    }
    
    return &UserInfo{
        ID:       googleUser.ID,
        Email:    googleUser.Email,
        Name:     googleUser.Name,
        Picture:  googleUser.Picture,
        Provider: "google",
    }, nil
}

Role-Based Access Control

Implement RBAC with fine-grained permissions:

// internal/auth/rbac.go
package auth

import (
    "context"
    "fmt"
)

type Permission string

const (
    ReadUsers   Permission = "users:read"
    WriteUsers  Permission = "users:write"
    ReadOrders  Permission = "orders:read"
    WriteOrders Permission = "orders:write"
    AdminPanel  Permission = "admin:access"
)

type Role struct {
    Name        string
    Permissions []Permission
}

type RBACService struct {
    roles map[string]*Role
}

func NewRBACService() *RBACService {
    rbac := &RBACService{
        roles: make(map[string]*Role),
    }
    
    // Define default roles
    rbac.roles["user"] = &Role{
        Name:        "user",
        Permissions: []Permission{ReadUsers},
    }
    
    rbac.roles["customer"] = &Role{
        Name:        "customer",
        Permissions: []Permission{ReadUsers, ReadOrders},
    }
    
    rbac.roles["admin"] = &Role{
        Name: "admin",
        Permissions: []Permission{
            ReadUsers, WriteUsers,
            ReadOrders, WriteOrders,
            AdminPanel,
        },
    }
    
    return rbac
}

func (r *RBACService) CheckPermission(userRoles []string, required Permission) bool {
    for _, roleName := range userRoles {
        if role, exists := r.roles[roleName]; exists {
            for _, perm := range role.Permissions {
                if perm == required {
                    return true
                }
            }
        }
    }
    return false
}

func (r *RBACService) GetUserPermissions(userRoles []string) []Permission {
    var permissions []Permission
    seen := make(map[Permission]bool)
    
    for _, roleName := range userRoles {
        if role, exists := r.roles[roleName]; exists {
            for _, perm := range role.Permissions {
                if !seen[perm] {
                    permissions = append(permissions, perm)
                    seen[perm] = true
                }
            }
        }
    }
    
    return permissions
}

Envoy External Auth Filter

We integrate our auth service with Envoy’s external auth filter:

// internal/auth/server.go
package auth

import (
    "context"
    "strings"
    
    core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
    auth "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type AuthServer struct {
    jwtValidator *JWTValidator
    rbacService  *RBACService
}

func NewAuthServer(jwtValidator *JWTValidator, rbacService *RBACService) *AuthServer {
    return &AuthServer{
        jwtValidator: jwtValidator,
        rbacService:  rbacService,
    }
}

func (s *AuthServer) Check(ctx context.Context, req *auth.CheckRequest) (*auth.CheckResponse, error) {
    // Extract authorization header
    authHeader := ""
    if req.Attributes.Request.Http.Headers != nil {
        authHeader = req.Attributes.Request.Http.Headers["authorization"]
    }
    
    if authHeader == "" {
        return &auth.CheckResponse{
            Status: &status.Status{Code: int32(codes.Unauthenticated)},
        }, nil
    }
    
    // Remove "Bearer " prefix
    token := strings.TrimPrefix(authHeader, "Bearer ")
    
    // Validate JWT token
    claims, err := s.jwtValidator.ValidateToken(token)
    if err != nil {
        return &auth.CheckResponse{
            Status: &status.Status{Code: int32(codes.Unauthenticated)},
        }, nil
    }
    
    // Check permissions based on the requested path
    path := req.Attributes.Request.Http.Path
    required := s.getRequiredPermission(path)
    
    if required != "" && !s.rbacService.CheckPermission(claims.Roles, required) {
        return &auth.CheckResponse{
            Status: &status.Status{Code: int32(codes.PermissionDenied)},
        }, nil
    }
    
    // Add user info to headers for upstream services
    headers := map[string]string{
        "x-user-id":    claims.UserID,
        "x-user-email": claims.Email,
        "x-user-roles": strings.Join(claims.Roles, ","),
    }
    
    return &auth.CheckResponse{
        Status: &status.Status{Code: int32(codes.OK)},
        HttpResponse: &auth.CheckResponse_OkResponse{
            OkResponse: &auth.OkHttpResponse{
                Headers: []*core.HeaderValueOption{},
            },
        },
    }, nil
}

func (s *AuthServer) getRequiredPermission(path string) Permission {
    switch {
    case strings.HasPrefix(path, "/api/v1/admin"):
        return AdminPanel
    case strings.HasPrefix(path, "/api/v1/users") && strings.Contains(path, "POST"):
        return WriteUsers
    case strings.HasPrefix(path, "/api/v1/users"):
        return ReadUsers
    case strings.HasPrefix(path, "/api/v1/orders") && strings.Contains(path, "POST"):
        return WriteOrders
    case strings.HasPrefix(path, "/api/v1/orders"):
        return ReadOrders
    default:
        return ""
    }
}

Testing Authentication

Test the authentication system:

# Get a JWT token
TOKEN=$(curl -s -X POST http://auth.example.com/oauth2/token \
  -d "grant_type=authorization_code&code=xyz")

# Make authenticated request
curl -H "Authorization: Bearer $TOKEN" \
     http://localhost:8080/api/v1/users

# Test unauthorized access
curl http://localhost:8080/api/v1/admin/users
# Should return 401 Unauthorized

Next Steps

In Part 3, we’ll implement rate limiting and circuit breaking to ensure our API gateway can handle high loads and gracefully degrade when upstream services are unhealthy.

Key topics will include:

  • Distributed rate limiting with Redis
  • Circuit breaker patterns
  • Bulkhead isolation
  • Graceful degradation strategies