Skip to content

Commit cb5f9fe

Browse files
authored
feat: Tenant API & JWT authentication (#7)
* refactor: make config & redis dependency more explicit * refactor: move config type declaration to individual packages * chore: remove debug log * test: Destination model * test: Use miniredis for testing * test: Destination handlers * chore: Remove debug log * chore: Support API_PORT env * feat: Authentication middleware * test: Authentication middleware * chore: Remove debug log * chore: Rename middleware name to specify API key mechanism * feat: Tenant's CRUD * refactor: Use Redis hash for tenant * refactor: Use time.Time for tenant created timestamp * feat: Generate tenant scope JWT token * test: Add JWT test case for malformed token * feat: Support tenant scope JWT auth * chore: Rename param of jwt functions * test: Router & different auth mechanism * chore: Update .env.example
1 parent c5944c7 commit cb5f9fe

File tree

15 files changed

+965
-16
lines changed

15 files changed

+965
-16
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
PORT=4000
2+
API_PORT=4000
3+
API_KEY=apikey
4+
JWT_SECRET=jwtsecret
25

36
# REDIS_HOST=<auto_set_up_via_docker_compose>
47
REDIS_PORT=6379

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ go 1.23.0
55
require (
66
github.com/alicebob/miniredis/v2 v2.33.0
77
github.com/gin-gonic/gin v1.10.0
8+
github.com/golang-jwt/jwt/v5 v5.2.1
9+
github.com/google/go-cmp v0.6.0
810
github.com/google/uuid v1.6.0
911
github.com/joho/godotenv v1.5.1
1012
github.com/redis/go-redis/extra/redisotel/v9 v9.5.3

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4
5252
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
5353
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
5454
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
55+
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
56+
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
5557
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
5658
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
5759
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=

internal/config/config.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ const (
1616
)
1717

1818
type Config struct {
19-
Service ServiceType
20-
Port int
21-
Hostname string
22-
APIKey string
19+
Service ServiceType
20+
Port int
21+
Hostname string
22+
APIKey string
23+
JWTSecret string
2324

2425
Redis *redis.RedisConfig
2526
OpenTelemetry *otel.OpenTelemetryConfig
@@ -79,10 +80,11 @@ func Parse(flags Flags) (*Config, error) {
7980

8081
// Initialize config values
8182
config := &Config{
82-
Hostname: hostname,
83-
Service: service,
84-
Port: getPort(viper),
85-
APIKey: viper.GetString("API_KEY"),
83+
Hostname: hostname,
84+
Service: service,
85+
Port: getPort(viper),
86+
APIKey: viper.GetString("API_KEY"),
87+
JWTSecret: viper.GetString("JWT_SECRET"),
8688
Redis: &redis.RedisConfig{
8789
Host: viper.GetString("REDIS_HOST"),
8890
Port: mustInt(viper, "REDIS_PORT"),

internal/services/api/api.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import (
99
"time"
1010

1111
"github.com/hookdeck/EventKit/internal/config"
12+
"github.com/hookdeck/EventKit/internal/destination"
1213
"github.com/hookdeck/EventKit/internal/redis"
14+
"github.com/hookdeck/EventKit/internal/tenant"
1315
"github.com/uptrace/opentelemetry-go-extra/otelzap"
1416
"go.uber.org/zap"
1517
)
@@ -27,7 +29,15 @@ func NewService(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, log
2729
return nil, err
2830
}
2931

30-
router := NewRouter(cfg, logger, redisClient)
32+
router := NewRouter(
33+
RouterConfig{
34+
Hostname: cfg.Hostname,
35+
APIKey: cfg.APIKey,
36+
JWTSecret: cfg.JWTSecret,
37+
},
38+
tenant.NewHandlers(logger, redisClient, cfg.JWTSecret),
39+
destination.NewHandlers(redisClient),
40+
)
3141

3242
service := &APIService{}
3343
service.logger = logger

internal/services/api/auth_middleware.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77

88
"github.com/gin-gonic/gin"
9+
"github.com/hookdeck/EventKit/internal/tenant"
910
)
1011

1112
func apiKeyAuthMiddleware(apiKey string) gin.HandlerFunc {
@@ -33,7 +34,41 @@ func apiKeyAuthMiddleware(apiKey string) gin.HandlerFunc {
3334
}
3435
}
3536

37+
func apiKeyOrTenantJWTAuthMiddleware(apiKey string, jwtKey string) gin.HandlerFunc {
38+
return func(c *gin.Context) {
39+
authorizationToken, err := extractBearerToken(c.GetHeader("Authorization"))
40+
if err != nil {
41+
// TODO: Consider sending a more detailed error message.
42+
// Currently we don't have clear specs on how to send back error message.
43+
c.AbortWithStatus(http.StatusUnauthorized)
44+
return
45+
}
46+
if authorizationToken == apiKey {
47+
c.Next()
48+
return
49+
}
50+
tenantID := c.Param("tenantID")
51+
valid, err := tenant.JWT.Verify(jwtKey, authorizationToken, tenantID)
52+
if err != nil {
53+
// TODO: Consider sending a more detailed error message.
54+
// Currently we don't have clear specs on how to send back error message.
55+
c.AbortWithStatus(http.StatusUnauthorized)
56+
return
57+
}
58+
if !valid {
59+
// TODO: Consider sending a more detailed error message.
60+
// Currently we don't have clear specs on how to send back error message.
61+
c.AbortWithStatus(http.StatusUnauthorized)
62+
return
63+
}
64+
c.Next()
65+
}
66+
}
67+
3668
func extractBearerToken(header string) (string, error) {
69+
if header == "" {
70+
return "", nil
71+
}
3772
if !strings.HasPrefix(header, "Bearer ") {
3873
return "", errors.New("invalid bearer token")
3974
}

internal/services/api/router.go

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,46 @@ import (
44
"net/http"
55

66
"github.com/gin-gonic/gin"
7-
"github.com/hookdeck/EventKit/internal/config"
87
"github.com/hookdeck/EventKit/internal/destination"
9-
"github.com/hookdeck/EventKit/internal/redis"
10-
"github.com/uptrace/opentelemetry-go-extra/otelzap"
8+
"github.com/hookdeck/EventKit/internal/tenant"
119
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
1210
)
1311

14-
func NewRouter(cfg *config.Config, logger *otelzap.Logger, redisClient *redis.Client) http.Handler {
12+
type RouterConfig struct {
13+
Hostname string
14+
APIKey string
15+
JWTSecret string
16+
}
17+
18+
func NewRouter(
19+
cfg RouterConfig,
20+
tenantHandlers *tenant.TenantHandlers,
21+
destinationHandlers *destination.DestinationHandlers,
22+
) http.Handler {
1523
r := gin.Default()
1624
r.Use(otelgin.Middleware(cfg.Hostname))
17-
r.Use(apiKeyAuthMiddleware(cfg.APIKey))
1825

1926
r.GET("/healthz", func(c *gin.Context) {
20-
logger.Ctx(c.Request.Context()).Info("health check")
2127
c.Status(http.StatusOK)
2228
})
2329

24-
destinationHandlers := destination.NewHandlers(redisClient)
30+
// Admin router is a router group with the API key auth mechanism.
31+
adminRouter := r.Group("/", apiKeyAuthMiddleware(cfg.APIKey))
32+
33+
adminRouter.PUT("/:tenantID", tenantHandlers.Upsert)
34+
adminRouter.GET("/:tenantID/portal", tenantHandlers.RetrievePortal)
35+
36+
// Tenant router is a router group that accepts either
37+
// - a tenant's JWT token OR
38+
// - the preconfigured API key
39+
//
40+
// If the EventKit service deployment isn't configured with an API key, then
41+
// it's assumed that the service runs in a secure environment
42+
// and the JWT check is NOT necessary either.
43+
tenantRouter := r.Group("/", apiKeyOrTenantJWTAuthMiddleware(cfg.APIKey, cfg.JWTSecret))
44+
45+
tenantRouter.GET("/:tenantID", tenantHandlers.Retrieve)
46+
tenantRouter.DELETE("/:tenantID", tenantHandlers.Delete)
2547

2648
r.GET("/destinations", destinationHandlers.List)
2749
r.POST("/destinations", destinationHandlers.Create)

0 commit comments

Comments
 (0)