Skip to content

Commit b125c7f

Browse files
authored
Fix sse expiration token (#799)
* feat: enhance SSEManager to support token management and expiry handling * test: add sse test * fix conflicts * refactor: add error handling for sse token refresh
1 parent 60affe8 commit b125c7f

7 files changed

Lines changed: 213 additions & 64 deletions

File tree

backend/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.25
55
require (
66
github.com/cosmos/go-bip39 v1.0.0
77
github.com/gin-gonic/gin v1.11.0
8+
github.com/golang/mock v1.6.0
89
github.com/google/uuid v1.6.0
910
github.com/hashicorp/go-multierror v1.1.1
1011
github.com/hashicorp/go-retryablehttp v0.7.8

backend/go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
398398
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
399399
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
400400
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
401+
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
401402
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
402403
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
403404
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@@ -448,6 +449,7 @@ golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8t
448449
golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
449450
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
450451
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
452+
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
451453
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
452454
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
453455
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
@@ -456,6 +458,7 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
456458
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
457459
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
458460
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
461+
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
459462
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
460463
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
461464
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
@@ -474,6 +477,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
474477
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
475478
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
476479
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
480+
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
481+
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
477482
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
478483
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
479484
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -502,6 +507,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
502507
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
503508
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
504509
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
510+
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
505511
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
506512
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
507513
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=

backend/internal/api/app/app_dependencies.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ func createAppDependencies(ctx context.Context, config cfg.Configuration) (appDe
108108
return appDependencies{}, err
109109
}
110110

111-
appCommunication, err := createAppCommunication(config, appCore.db, appCore.ewfEngine, appCore.metrics)
111+
appCommunication, err := createAppCommunication(config, appCore.db, appCore.ewfEngine, appCore.metrics, appSecurity.tokenManager)
112112
if err != nil {
113113
return appDependencies{}, err
114114
}
@@ -228,7 +228,7 @@ func createAppSecurity(ctx context.Context, config cfg.Configuration) (appSecuri
228228
}, nil
229229
}
230230

231-
func createAppCommunication(config cfg.Configuration, db models.DB, ewfEngine *ewf.Engine, metrics *metrics.Metrics) (appCommunication, error) {
231+
func createAppCommunication(config cfg.Configuration, db models.DB, ewfEngine *ewf.Engine, metrics *metrics.Metrics, tokenManager auth.TokenManager) (appCommunication, error) {
232232
var mailSender mailsender.MailSender
233233
var mailContentFormatter mailcontentformatter.MailContentFormatter
234234

@@ -242,7 +242,7 @@ func createAppCommunication(config cfg.Configuration, db models.DB, ewfEngine *e
242242
mailContentFormatter = mailcontentformatter.NewMailHTMLFormatter()
243243
}
244244
mailService := mailservice.NewMailService(mailSender, mailContentFormatter, config)
245-
sseManager := realtime.NewSSEManager()
245+
sseManager := realtime.NewSSEManager(tokenManager)
246246

247247
notificationRepo := corepersistence.NewGormNotificationRepository(db)
248248
userRepo := corepersistence.NewGormUserRepository(db)

backend/internal/infrastructure/realtime/sse.go

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import (
44
"context"
55
"encoding/json"
66
"io"
7+
"kubecloud/internal/auth"
78
"kubecloud/internal/core/models"
9+
"kubecloud/internal/infrastructure/logger"
810
"net/http"
11+
"strings"
912
"sync"
1013
"time"
1114

12-
"kubecloud/internal/infrastructure/logger"
13-
1415
"github.com/gin-gonic/gin"
1516
)
1617

@@ -23,10 +24,11 @@ const (
2324

2425
// SSEManager handles Server-Sent Events for real-time notifications
2526
type SSEManager struct {
26-
clients map[int][]chan SSEMessage // userID -> client channels
27-
mu sync.RWMutex
28-
ctx context.Context
29-
cancel context.CancelFunc
27+
clients map[int][]chan SSEMessage // userID -> client channels
28+
mu sync.RWMutex
29+
ctx context.Context
30+
cancel context.CancelFunc
31+
tokenManager auth.TokenManager
3032
}
3133

3234
// SSEMessage represents a server-sent event message
@@ -40,12 +42,13 @@ type SSEMessage struct {
4042
}
4143

4244
// NewSSEManager creates a new SSE manager
43-
func NewSSEManager() *SSEManager {
45+
func NewSSEManager(tokenManager auth.TokenManager) *SSEManager {
4446
ctx, cancel := context.WithCancel(context.Background())
4547
manager := &SSEManager{
46-
clients: make(map[int][]chan SSEMessage),
47-
ctx: ctx,
48-
cancel: cancel,
48+
clients: make(map[int][]chan SSEMessage),
49+
ctx: ctx,
50+
cancel: cancel,
51+
tokenManager: tokenManager,
4952
}
5053

5154
return manager
@@ -69,7 +72,7 @@ func (s *SSEManager) Stop() {
6972
}
7073

7174
// AddClient adds a new client channel for a user
72-
func (s *SSEManager) AddClient(userID int) chan SSEMessage {
75+
func (s *SSEManager) addClient(userID int) chan SSEMessage {
7376
s.mu.Lock()
7477
defer s.mu.Unlock()
7578

@@ -80,7 +83,7 @@ func (s *SSEManager) AddClient(userID int) chan SSEMessage {
8083
}
8184

8285
// RemoveClient removes a client channel for a user
83-
func (s *SSEManager) RemoveClient(userID int, clientChan chan SSEMessage) {
86+
func (s *SSEManager) removeClient(userID int, clientChan chan SSEMessage) {
8487
s.mu.Lock()
8588
defer s.mu.Unlock()
8689

@@ -124,14 +127,23 @@ func (s *SSEManager) Notify(userID int, msgType string, severity models.Notifica
124127
// Message sent successfully
125128
case <-time.After(2 * time.Second):
126129
// Client not responding, remove it
127-
go s.RemoveClient(userID, ch)
130+
go s.removeClient(userID, ch)
128131
case <-s.ctx.Done():
129132
return
130133
}
131134
}
132135

133136
}
134137

138+
// setupExpiryTimer creates a timer that fires when the token expires
139+
func (s *SSEManager) setupExpiryTimer(claims *auth.TokenClaims) *time.Timer {
140+
if claims == nil || claims.ExpiresAt == nil {
141+
return nil
142+
}
143+
144+
return time.NewTimer(time.Until(claims.ExpiresAt.Time))
145+
}
146+
135147
// HandleSSE handles SSE HTTP connections
136148
func (s *SSEManager) HandleSSE(c *gin.Context) {
137149
userID := c.GetInt("user_id")
@@ -140,20 +152,48 @@ func (s *SSEManager) HandleSSE(c *gin.Context) {
140152
return
141153
}
142154

155+
// Extract token from query or Authorization header
156+
tokenStr := c.Query("token")
157+
if tokenStr == "" {
158+
authHeader := c.GetHeader("Authorization")
159+
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
160+
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
161+
return
162+
}
163+
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
164+
}
165+
166+
claims, err := s.tokenManager.VerifyToken(tokenStr)
167+
if err != nil {
168+
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
169+
return
170+
}
171+
172+
// Setup token expiry enforcement timer using claims
173+
expiryTimer := s.setupExpiryTimer(claims)
174+
if expiryTimer != nil {
175+
defer expiryTimer.Stop()
176+
}
177+
143178
// Set SSE headers
144179
c.Header("Content-Type", "text/event-stream")
145180
c.Header("Cache-Control", "no-cache")
146181
c.Header("Connection", "keep-alive")
147182

148183
// Add client and get channel
149-
clientChan := s.AddClient(userID)
150-
defer s.RemoveClient(userID, clientChan)
184+
clientChan := s.addClient(userID)
185+
defer s.removeClient(userID, clientChan)
151186

152187
log := logger.ForOperation("sse", "handle_connection").With().Int("user_id", userID).Logger()
153188

154189
// Send initial connection message
155190
s.Notify(userID, "connected", models.NotificationSeverityInfo, map[string]string{"status": "connected"}, "")
156191

192+
var expiryC <-chan time.Time
193+
if expiryTimer != nil {
194+
expiryC = expiryTimer.C
195+
}
196+
157197
// Stream messages to client
158198
c.Stream(func(w io.Writer) bool {
159199
select {
@@ -171,6 +211,10 @@ func (s *SSEManager) HandleSSE(c *gin.Context) {
171211
c.SSEvent("message", string(data))
172212
return true
173213

214+
case <-expiryC:
215+
// Token expired (nil channel never fires)
216+
return false
217+
174218
case <-c.Request.Context().Done():
175219
log.Debug().Msg("Client disconnected")
176220
return false

backend/internal/infrastructure/realtime/sse_test.go

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
)
1111

1212
func TestNewSSEManager(t *testing.T) {
13-
manager := NewSSEManager()
13+
manager := NewSSEManager(nil)
1414
if manager.clients == nil {
1515
t.Error("clients map not initialized")
1616
}
@@ -23,11 +23,11 @@ func TestNewSSEManager(t *testing.T) {
2323
}
2424

2525
func TestSSEManager_AddClient(t *testing.T) {
26-
manager := NewSSEManager()
26+
manager := NewSSEManager(nil)
2727
defer manager.Stop()
2828

2929
userID := 1
30-
clientChan := manager.AddClient(userID)
30+
clientChan := manager.addClient(userID)
3131

3232
if clientChan == nil {
3333
t.Error("AddClient returned nil channel")
@@ -50,11 +50,11 @@ func TestSSEManager_AddClient(t *testing.T) {
5050
}
5151

5252
func TestSSEManager_RemoveClient(t *testing.T) {
53-
manager := NewSSEManager()
53+
manager := NewSSEManager(nil)
5454
defer manager.Stop()
5555

5656
userID := 1
57-
clientChan := manager.AddClient(userID)
57+
clientChan := manager.addClient(userID)
5858

5959
// Verify client exists
6060
manager.mu.RLock()
@@ -66,7 +66,7 @@ func TestSSEManager_RemoveClient(t *testing.T) {
6666
}
6767

6868
// Remove client
69-
manager.RemoveClient(userID, clientChan)
69+
manager.removeClient(userID, clientChan)
7070

7171
// Verify client was removed
7272
manager.mu.RLock()
@@ -79,12 +79,12 @@ func TestSSEManager_RemoveClient(t *testing.T) {
7979
}
8080

8181
func TestSSEManager_RemoveClient_MultipleClients(t *testing.T) {
82-
manager := NewSSEManager()
82+
manager := NewSSEManager(nil)
8383
defer manager.Stop()
8484

8585
userID := 1
86-
clientChan1 := manager.AddClient(userID)
87-
clientChan2 := manager.AddClient(userID)
86+
clientChan1 := manager.addClient(userID)
87+
clientChan2 := manager.addClient(userID)
8888

8989
// Verify both clients exist
9090
manager.mu.RLock()
@@ -96,7 +96,7 @@ func TestSSEManager_RemoveClient_MultipleClients(t *testing.T) {
9696
}
9797

9898
// Remove first client
99-
manager.RemoveClient(userID, clientChan1)
99+
manager.removeClient(userID, clientChan1)
100100

101101
// Verify only second client remains
102102
manager.mu.RLock()
@@ -112,11 +112,11 @@ func TestSSEManager_RemoveClient_MultipleClients(t *testing.T) {
112112
}
113113

114114
func TestSSEManager_Notify(t *testing.T) {
115-
manager := NewSSEManager()
115+
manager := NewSSEManager(nil)
116116
defer manager.Stop()
117117

118118
userID := 1
119-
clientChan := manager.AddClient(userID)
119+
clientChan := manager.addClient(userID)
120120

121121
data := map[string]string{"message": "test message", "status": "success"}
122122

@@ -147,12 +147,12 @@ func TestSSEManager_Notify(t *testing.T) {
147147
}
148148

149149
func TestSSEManager_Notify_MultipleClients(t *testing.T) {
150-
manager := NewSSEManager()
150+
manager := NewSSEManager(nil)
151151
defer manager.Stop()
152152

153153
userID := 1
154-
clientChan1 := manager.AddClient(userID)
155-
clientChan2 := manager.AddClient(userID)
154+
clientChan1 := manager.addClient(userID)
155+
clientChan2 := manager.addClient(userID)
156156

157157
message := SSEMessage{
158158
Type: "test",
@@ -192,10 +192,10 @@ func TestSSEManager_Notify_MultipleClients(t *testing.T) {
192192
}
193193

194194
func TestSSEManager_Stop(t *testing.T) {
195-
manager := NewSSEManager()
195+
manager := NewSSEManager(nil)
196196

197197
userID := 1
198-
manager.AddClient(userID)
198+
manager.addClient(userID)
199199

200200
// Stop the manager
201201
manager.Stop()
@@ -250,11 +250,11 @@ func TestSSEMessage_JSONMarshal(t *testing.T) {
250250
}
251251

252252
func TestSSEManager_Notify_WithTaskID(t *testing.T) {
253-
manager := NewSSEManager()
253+
manager := NewSSEManager(nil)
254254
defer manager.Stop()
255255

256256
userID := 1
257-
clientChan := manager.AddClient(userID)
257+
clientChan := manager.addClient(userID)
258258

259259
taskID := "task-123"
260260

0 commit comments

Comments
 (0)