diff --git a/internal/apirouter/auth_middleware.go b/internal/apirouter/auth_middleware.go index d28797daf..5fd12bfca 100644 --- a/internal/apirouter/auth_middleware.go +++ b/internal/apirouter/auth_middleware.go @@ -107,19 +107,19 @@ func APIKeyOrTenantJWTAuthMiddleware(apiKey string, jwtKey string) gin.HandlerFu } // Try JWT auth - tokenTenantID, err := JWT.ExtractTenantID(jwtKey, token) + claims, err := JWT.Extract(jwtKey, token) if err != nil { c.AbortWithStatus(http.StatusUnauthorized) return } // If tenantID param exists, verify it matches token - if paramTenantID := c.Param("tenantID"); paramTenantID != "" && paramTenantID != tokenTenantID { + if paramTenantID := c.Param("tenantID"); paramTenantID != "" && paramTenantID != claims.TenantID { c.AbortWithStatus(http.StatusUnauthorized) return } - c.Set("tenantID", tokenTenantID) + c.Set("tenantID", claims.TenantID) c.Set(authRoleKey, RoleTenant) c.Next() } @@ -143,19 +143,19 @@ func TenantJWTAuthMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { return } - tokenTenantID, err := JWT.ExtractTenantID(jwtKey, token) + claims, err := JWT.Extract(jwtKey, token) if err != nil { c.AbortWithStatus(http.StatusUnauthorized) return } // If tenantID param exists, verify it matches token - if paramTenantID := c.Param("tenantID"); paramTenantID != "" && paramTenantID != tokenTenantID { + if paramTenantID := c.Param("tenantID"); paramTenantID != "" && paramTenantID != claims.TenantID { c.AbortWithStatus(http.StatusUnauthorized) return } - c.Set("tenantID", tokenTenantID) + c.Set("tenantID", claims.TenantID) c.Set(authRoleKey, RoleTenant) c.Next() } diff --git a/internal/apirouter/auth_middleware_test.go b/internal/apirouter/auth_middleware_test.go index 06e5be77f..a6654d530 100644 --- a/internal/apirouter/auth_middleware_test.go +++ b/internal/apirouter/auth_middleware_test.go @@ -176,7 +176,7 @@ func TestAPIKeyOrTenantJWTAuthMiddleware(t *testing.T) { c.Params = []gin.Param{{Key: "tenantID", Value: "different_tenant"}} // Create JWT token for tenantID - token, err := apirouter.JWT.New(jwtSecret, tenantID) + token, err := apirouter.JWT.New(jwtSecret, apirouter.JWTClaims{TenantID: tenantID}) if err != nil { t.Fatal(err) } @@ -201,7 +201,7 @@ func TestAPIKeyOrTenantJWTAuthMiddleware(t *testing.T) { c.Params = []gin.Param{{Key: "tenantID", Value: tenantID}} // Create JWT token for tenantID - token, err := apirouter.JWT.New(jwtSecret, tenantID) + token, err := apirouter.JWT.New(jwtSecret, apirouter.JWTClaims{TenantID: tenantID}) if err != nil { t.Fatal(err) } @@ -251,7 +251,7 @@ func TestAPIKeyOrTenantJWTAuthMiddleware(t *testing.T) { } func newJWTToken(t *testing.T, secret string, tenantID string) string { - token, err := apirouter.JWT.New(secret, tenantID) + token, err := apirouter.JWT.New(secret, apirouter.JWTClaims{TenantID: tenantID}) if err != nil { t.Fatal(err) } diff --git a/internal/apirouter/jwt.go b/internal/apirouter/jwt.go index d0f5f5c65..9578f78e2 100644 --- a/internal/apirouter/jwt.go +++ b/internal/apirouter/jwt.go @@ -19,18 +19,28 @@ var ( ErrInvalidToken = errors.New("invalid token") ) -func (_ jsonwebtoken) New(jwtSecret string, tenantID string) (string, error) { +// JWTClaims contains the custom claims for JWT tokens +type JWTClaims struct { + TenantID string + DeploymentID string +} + +func (_ jsonwebtoken) New(jwtSecret string, claims JWTClaims) (string, error) { now := time.Now() - token := jwt.NewWithClaims(signingMethod, jwt.MapClaims{ + mapClaims := jwt.MapClaims{ "iss": issuer, - "sub": tenantID, + "sub": claims.TenantID, "iat": now.Unix(), "exp": now.Add(24 * time.Hour).Unix(), - }) + } + if claims.DeploymentID != "" { + mapClaims["deployment_id"] = claims.DeploymentID + } + token := jwt.NewWithClaims(signingMethod, mapClaims) return token.SignedString([]byte(jwtSecret)) } -func (_ jsonwebtoken) ExtractTenantID(jwtSecret string, tokenString string) (string, error) { +func (_ jsonwebtoken) Extract(jwtSecret string, tokenString string) (JWTClaims, error) { token, err := jwt.Parse( tokenString, func(token *jwt.Token) (interface{}, error) { @@ -39,14 +49,28 @@ func (_ jsonwebtoken) ExtractTenantID(jwtSecret string, tokenString string) (str jwt.WithIssuer(issuer), ) if err != nil || !token.Valid { - return "", ErrInvalidToken + return JWTClaims{}, ErrInvalidToken + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return JWTClaims{}, ErrInvalidToken } tenantID, err := token.Claims.GetSubject() if err != nil { - return "", ErrInvalidToken + return JWTClaims{}, ErrInvalidToken + } + + var deploymentID string + if did, ok := claims["deployment_id"].(string); ok { + deploymentID = did } - return tenantID, nil + + return JWTClaims{ + TenantID: tenantID, + DeploymentID: deploymentID, + }, nil } func (_ jsonwebtoken) Verify(jwtSecret string, tokenString string, tenantID string) (bool, error) { diff --git a/internal/apirouter/jwt_test.go b/internal/apirouter/jwt_test.go index 5a90acbd0..158a5c93a 100644 --- a/internal/apirouter/jwt_test.go +++ b/internal/apirouter/jwt_test.go @@ -16,18 +16,34 @@ func TestJWT(t *testing.T) { const issuer = "outpost" const jwtKey = "supersecret" const tenantID = "tenantID" + const deploymentID = "deployment123" var signingMethod = jwt.SigningMethodHS256 t.Run("should generate a new jwt token", func(t *testing.T) { t.Parallel() - token, err := apirouter.JWT.New(jwtKey, tenantID) + token, err := apirouter.JWT.New(jwtKey, apirouter.JWTClaims{TenantID: tenantID}) assert.Nil(t, err) assert.NotEqual(t, "", token) }) + t.Run("should generate a new jwt token with deployment_id", func(t *testing.T) { + t.Parallel() + token, err := apirouter.JWT.New(jwtKey, apirouter.JWTClaims{ + TenantID: tenantID, + DeploymentID: deploymentID, + }) + assert.Nil(t, err) + assert.NotEqual(t, "", token) + + // Verify deployment_id is in the token + claims, err := apirouter.JWT.Extract(jwtKey, token) + assert.Nil(t, err) + assert.Equal(t, deploymentID, claims.DeploymentID) + }) + t.Run("should verify a valid jwt token", func(t *testing.T) { t.Parallel() - token, err := apirouter.JWT.New(jwtKey, tenantID) + token, err := apirouter.JWT.New(jwtKey, apirouter.JWTClaims{TenantID: tenantID}) if err != nil { t.Fatal(err) } @@ -36,24 +52,51 @@ func TestJWT(t *testing.T) { assert.True(t, valid) }) - t.Run("should extract tenantID from valid token", func(t *testing.T) { + t.Run("should extract claims from valid token", func(t *testing.T) { t.Parallel() - token, err := apirouter.JWT.New(jwtKey, tenantID) + token, err := apirouter.JWT.New(jwtKey, apirouter.JWTClaims{TenantID: tenantID}) if err != nil { t.Fatal(err) } - extractedTenantID, err := apirouter.JWT.ExtractTenantID(jwtKey, token) + claims, err := apirouter.JWT.Extract(jwtKey, token) assert.Nil(t, err) - assert.Equal(t, tenantID, extractedTenantID) + assert.Equal(t, tenantID, claims.TenantID) + assert.Equal(t, "", claims.DeploymentID) }) - t.Run("should fail to extract tenantID from invalid token", func(t *testing.T) { + t.Run("should fail to extract claims from invalid token", func(t *testing.T) { t.Parallel() - _, err := apirouter.JWT.ExtractTenantID(jwtKey, "invalid_token") + _, err := apirouter.JWT.Extract(jwtKey, "invalid_token") assert.ErrorIs(t, err, apirouter.ErrInvalidToken) }) - t.Run("should fail to extract tenantID from token with invalid issuer", func(t *testing.T) { + t.Run("should extract all claims from valid token", func(t *testing.T) { + t.Parallel() + token, err := apirouter.JWT.New(jwtKey, apirouter.JWTClaims{ + TenantID: tenantID, + DeploymentID: deploymentID, + }) + if err != nil { + t.Fatal(err) + } + claims, err := apirouter.JWT.Extract(jwtKey, token) + assert.Nil(t, err) + assert.Equal(t, tenantID, claims.TenantID) + assert.Equal(t, deploymentID, claims.DeploymentID) + }) + + t.Run("should return empty deployment_id when not in token", func(t *testing.T) { + t.Parallel() + token, err := apirouter.JWT.New(jwtKey, apirouter.JWTClaims{TenantID: tenantID}) + if err != nil { + t.Fatal(err) + } + claims, err := apirouter.JWT.Extract(jwtKey, token) + assert.Nil(t, err) + assert.Equal(t, "", claims.DeploymentID) + }) + + t.Run("should fail to extract claims from token with invalid issuer", func(t *testing.T) { t.Parallel() now := time.Now() jwtToken := jwt.NewWithClaims(signingMethod, jwt.MapClaims{ @@ -66,7 +109,7 @@ func TestJWT(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = apirouter.JWT.ExtractTenantID(jwtKey, token) + _, err = apirouter.JWT.Extract(jwtKey, token) assert.ErrorIs(t, err, apirouter.ErrInvalidToken) }) diff --git a/internal/apirouter/router.go b/internal/apirouter/router.go index 070760cad..5ab20f77b 100644 --- a/internal/apirouter/router.go +++ b/internal/apirouter/router.go @@ -49,6 +49,7 @@ type RouterConfig struct { ServiceName string APIKey string JWTSecret string + DeploymentID string Topics []string Registry destregistry.Registry PortalConfig portal.PortalConfig @@ -138,7 +139,7 @@ func NewRouter( apiRouter := r.Group("/api/v1") apiRouter.Use(SetTenantIDMiddleware()) - tenantHandlers := NewTenantHandlers(logger, telemetry, cfg.JWTSecret, entityStore) + tenantHandlers := NewTenantHandlers(logger, telemetry, cfg.JWTSecret, cfg.DeploymentID, entityStore) destinationHandlers := NewDestinationHandlers(logger, telemetry, entityStore, cfg.Topics, cfg.Registry) publishHandlers := NewPublishHandlers(logger, publishmqEventHandler) logHandlers := NewLogHandlers(logger, logStore) diff --git a/internal/apirouter/router_test.go b/internal/apirouter/router_test.go index 7b05249d4..4cb35a101 100644 --- a/internal/apirouter/router_test.go +++ b/internal/apirouter/router_test.go @@ -109,7 +109,7 @@ func TestRouterWithAPIKey(t *testing.T) { router, _, _ := setupTestRouter(t, apiKey, jwtSecret) tenantID := "tenantID" - validToken, err := apirouter.JWT.New(jwtSecret, tenantID) + validToken, err := apirouter.JWT.New(jwtSecret, apirouter.JWTClaims{TenantID: tenantID}) if err != nil { t.Fatal(err) } @@ -201,7 +201,7 @@ func TestRouterWithoutAPIKey(t *testing.T) { router, _, _ := setupTestRouter(t, apiKey, jwtSecret) tenantID := "tenantID" - validToken, err := apirouter.JWT.New(jwtSecret, tenantID) + validToken, err := apirouter.JWT.New(jwtSecret, apirouter.JWTClaims{TenantID: tenantID}) if err != nil { t.Fatal(err) } diff --git a/internal/apirouter/tenant_handlers.go b/internal/apirouter/tenant_handlers.go index 0221c7221..53772394e 100644 --- a/internal/apirouter/tenant_handlers.go +++ b/internal/apirouter/tenant_handlers.go @@ -13,23 +13,26 @@ import ( ) type TenantHandlers struct { - logger *logging.Logger - telemetry telemetry.Telemetry - jwtSecret string - entityStore models.EntityStore + logger *logging.Logger + telemetry telemetry.Telemetry + jwtSecret string + deploymentID string + entityStore models.EntityStore } func NewTenantHandlers( logger *logging.Logger, telemetry telemetry.Telemetry, jwtSecret string, + deploymentID string, entityStore models.EntityStore, ) *TenantHandlers { return &TenantHandlers{ - logger: logger, - telemetry: telemetry, - jwtSecret: jwtSecret, - entityStore: entityStore, + logger: logger, + telemetry: telemetry, + jwtSecret: jwtSecret, + deploymentID: deploymentID, + entityStore: entityStore, } } @@ -180,7 +183,10 @@ func (h *TenantHandlers) RetrieveToken(c *gin.Context) { if tenant == nil { return } - jwtToken, err := JWT.New(h.jwtSecret, tenant.ID) + jwtToken, err := JWT.New(h.jwtSecret, JWTClaims{ + TenantID: tenant.ID, + DeploymentID: h.deploymentID, + }) if err != nil { AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) return @@ -193,7 +199,10 @@ func (h *TenantHandlers) RetrievePortal(c *gin.Context) { if tenant == nil { return } - jwtToken, err := JWT.New(h.jwtSecret, tenant.ID) + jwtToken, err := JWT.New(h.jwtSecret, JWTClaims{ + TenantID: tenant.ID, + DeploymentID: h.deploymentID, + }) if err != nil { AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) return diff --git a/internal/services/builder.go b/internal/services/builder.go index 8b3809c2a..43d879134 100644 --- a/internal/services/builder.go +++ b/internal/services/builder.go @@ -199,6 +199,7 @@ func (b *ServiceBuilder) BuildAPIWorkers(baseRouter *gin.Engine) error { ServiceName: b.cfg.OpenTelemetry.GetServiceName(), APIKey: b.cfg.APIKey, JWTSecret: b.cfg.APIJWTSecret, + DeploymentID: b.cfg.DeploymentID, Topics: b.cfg.Topics, Registry: svc.destRegistry, PortalConfig: b.cfg.GetPortalConfig(),