detached f6ead37

feat(auth): slide token expiry +24h on each CLI request with 1h debounce

commit
f6ead378e9d6e8ec5b7ab10a99d9d6f58da44a06
author
bnema · · 2026-02-24 22:07 UTC
parent
f5b6a2e
signature
unsigned
15 files · +550 -52
M internal/adapters/in/cli/remote/client.go +29 -5
@@ -21,10 +21,11 @@ import (
2121
2222 // Client is an HTTP client for the Gordon admin API.
2323 type Client struct {
24- baseURL string
25- token string
26- httpClient *http.Client
27- insecureTLS bool
24+ baseURL string
25+ token string
26+ httpClient *http.Client
27+ insecureTLS bool
28+ onTokenRefreshed func(newToken string) // optional callback to persist a refreshed token
2829 }
2930
3031 var (
@@ -84,6 +85,15 @@ func WithInsecureTLS(insecure bool) ClientOption {
8485 }
8586 }
8687
88+// WithTokenRefreshCallback sets a callback that is invoked when the server
89+// returns a refreshed token in the X-Gordon-Token response header.
90+// The CLI uses this to persist the new token to the remotes config atomically.
91+func WithTokenRefreshCallback(fn func(newToken string)) ClientOption {
92+ return func(c *Client) {
93+ c.onTokenRefreshed = fn
94+ }
95+}
96+
8797 func (c *Client) applyTLSConfig() {
8898 if !c.insecureTLS {
8999 return
@@ -140,7 +150,21 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h
140150 req.Header.Set("Authorization", "Bearer "+c.token)
141151 }
142152
143- return c.httpClient.Do(req)
153+ resp, err := c.httpClient.Do(req)
154+ if err != nil {
155+ return nil, err
156+ }
157+
158+ // If the server returned a refreshed token, update the client's token and notify any callback.
159+ // This implements the sliding expiry: the server re-signs the token and sends it back.
160+ if newToken := resp.Header.Get("X-Gordon-Token"); newToken != "" && newToken != c.token {
161+ c.token = newToken
162+ if c.onTokenRefreshed != nil {
163+ c.onTokenRefreshed(newToken)
164+ }
165+ }
166+
167+ return resp, nil
144168 }
145169
146170 // parseResponse parses a JSON response into the given target.
@@ -21,10 +21,11 @@ import (
21 21
22 // Client is an HTTP client for the Gordon admin API.22 // Client is an HTTP client for the Gordon admin API.
23 type Client struct {23 type Client struct {
24- baseURL string24+ baseURL string
25- token string25+ token string
26- httpClient *http.Client26+ httpClient *http.Client
27- insecureTLS bool27+ insecureTLS bool
28+ onTokenRefreshed func(newToken string) // optional callback to persist a refreshed token
28 }29 }
29 30
30 var (31 var (
@@ -84,6 +85,15 @@ func WithInsecureTLS(insecure bool) ClientOption {
84 }85 }
85 }86 }
86 87
88+// WithTokenRefreshCallback sets a callback that is invoked when the server
89+// returns a refreshed token in the X-Gordon-Token response header.
90+// The CLI uses this to persist the new token to the remotes config atomically.
91+func WithTokenRefreshCallback(fn func(newToken string)) ClientOption {
92+ return func(c *Client) {
93+ c.onTokenRefreshed = fn
94+ }
95+}
96+
87 func (c *Client) applyTLSConfig() {97 func (c *Client) applyTLSConfig() {
88 if !c.insecureTLS {98 if !c.insecureTLS {
89 return99 return
@@ -140,7 +150,21 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h
140 req.Header.Set("Authorization", "Bearer "+c.token)150 req.Header.Set("Authorization", "Bearer "+c.token)
141 }151 }
142 152
143- return c.httpClient.Do(req)153+ resp, err := c.httpClient.Do(req)
154+ if err != nil {
155+ return nil, err
156+ }
157+
158+ // If the server returned a refreshed token, update the client's token and notify any callback.
159+ // This implements the sliding expiry: the server re-signs the token and sends it back.
160+ if newToken := resp.Header.Get("X-Gordon-Token"); newToken != "" && newToken != c.token {
161+ c.token = newToken
162+ if c.onTokenRefreshed != nil {
163+ c.onTokenRefreshed(newToken)
164+ }
165+ }
166+
167+ return resp, nil
144 }168 }
145 169
146 // parseResponse parses a JSON response into the given target.170 // parseResponse parses a JSON response into the given target.
M internal/adapters/in/cli/remote/config.go +13 -3
@@ -138,16 +138,26 @@ func SaveRemotes(path string, config *ClientConfig) error {
138138 // ResolveRemote resolves the remote URL and token from configuration.
139139 // Precedence: flag > env > config > active remote
140140 func ResolveRemote(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS, isRemote bool) {
141+ url, token, insecureTLS, _, isRemote = ResolveRemoteFull(flagRemote, flagToken, flagInsecure)
142+ return url, token, insecureTLS, isRemote
143+}
144+
145+// ResolveRemoteFull resolves the remote URL, token, and named remote from configuration.
146+// It also returns the remote name for use in token persistence callbacks.
147+// Precedence: flag > env > config > active remote
148+func ResolveRemoteFull(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS bool, remoteName string, isRemote bool) {
141149 config, _ := LoadClientConfig("")
142150 remotes, _ := LoadRemotes("")
143151
144- url, remoteName, isRemote := resolveRemoteURL(flagRemote, config, remotes)
152+ var name string
153+ url, name, isRemote = resolveRemoteURL(flagRemote, config, remotes)
145154 if isRemote {
146155 token = resolveToken(flagToken, config, remotes)
147- insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, remoteName)
156+ insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, name)
157+ remoteName = name
148158 }
149159
150- return url, token, insecureTLS, isRemote
160+ return url, token, insecureTLS, remoteName, isRemote
151161 }
152162
153163 // resolveRemoteURL resolves the remote URL from various sources.
@@ -138,16 +138,26 @@ func SaveRemotes(path string, config *ClientConfig) error {
138 // ResolveRemote resolves the remote URL and token from configuration.138 // ResolveRemote resolves the remote URL and token from configuration.
139 // Precedence: flag > env > config > active remote139 // Precedence: flag > env > config > active remote
140 func ResolveRemote(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS, isRemote bool) {140 func ResolveRemote(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS, isRemote bool) {
141+ url, token, insecureTLS, _, isRemote = ResolveRemoteFull(flagRemote, flagToken, flagInsecure)
142+ return url, token, insecureTLS, isRemote
143+}
144+
145+// ResolveRemoteFull resolves the remote URL, token, and named remote from configuration.
146+// It also returns the remote name for use in token persistence callbacks.
147+// Precedence: flag > env > config > active remote
148+func ResolveRemoteFull(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS bool, remoteName string, isRemote bool) {
141 config, _ := LoadClientConfig("")149 config, _ := LoadClientConfig("")
142 remotes, _ := LoadRemotes("")150 remotes, _ := LoadRemotes("")
143 151
144- url, remoteName, isRemote := resolveRemoteURL(flagRemote, config, remotes)152+ var name string
153+ url, name, isRemote = resolveRemoteURL(flagRemote, config, remotes)
145 if isRemote {154 if isRemote {
146 token = resolveToken(flagToken, config, remotes)155 token = resolveToken(flagToken, config, remotes)
147- insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, remoteName)156+ insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, name)
157+ remoteName = name
148 }158 }
149 159
150- return url, token, insecureTLS, isRemote160+ return url, token, insecureTLS, remoteName, isRemote
151 }161 }
152 162
153 // resolveRemoteURL resolves the remote URL from various sources.163 // resolveRemoteURL resolves the remote URL from various sources.
M internal/adapters/in/cli/root.go +13 -2
@@ -144,12 +144,23 @@ Commands are organized by where they run:
144144 // GetRemoteClient returns a remote client if targeting a remote instance,
145145 // or nil if running locally.
146146 func GetRemoteClient() (*remote.Client, bool) {
147- url, token, insecureTLS, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
147+ url, token, insecureTLS, remoteName, isRemote := remote.ResolveRemoteFull(remoteFlag, tokenFlag, insecureTLSFlag)
148148 if !isRemote {
149149 return nil, false
150150 }
151151
152- client := remote.NewClient(url, remoteClientOptions(token, insecureTLS)...)
152+ opts := remoteClientOptions(token, insecureTLS)
153+
154+ // When using a named remote (from remotes.toml), register a callback to persist
155+ // refreshed tokens returned by the server in the X-Gordon-Token header.
156+ if remoteName != "" {
157+ name := remoteName // capture for closure
158+ opts = append(opts, remote.WithTokenRefreshCallback(func(newToken string) {
159+ _ = remote.UpdateRemoteToken(name, newToken) // best-effort; errors are non-fatal
160+ }))
161+ }
162+
163+ client := remote.NewClient(url, opts...)
153164 return client, true
154165 }
155166
@@ -144,12 +144,23 @@ Commands are organized by where they run:
144 // GetRemoteClient returns a remote client if targeting a remote instance,144 // GetRemoteClient returns a remote client if targeting a remote instance,
145 // or nil if running locally.145 // or nil if running locally.
146 func GetRemoteClient() (*remote.Client, bool) {146 func GetRemoteClient() (*remote.Client, bool) {
147- url, token, insecureTLS, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)147+ url, token, insecureTLS, remoteName, isRemote := remote.ResolveRemoteFull(remoteFlag, tokenFlag, insecureTLSFlag)
148 if !isRemote {148 if !isRemote {
149 return nil, false149 return nil, false
150 }150 }
151 151
152- client := remote.NewClient(url, remoteClientOptions(token, insecureTLS)...)152+ opts := remoteClientOptions(token, insecureTLS)
153+
154+ // When using a named remote (from remotes.toml), register a callback to persist
155+ // refreshed tokens returned by the server in the X-Gordon-Token header.
156+ if remoteName != "" {
157+ name := remoteName // capture for closure
158+ opts = append(opts, remote.WithTokenRefreshCallback(func(newToken string) {
159+ _ = remote.UpdateRemoteToken(name, newToken) // best-effort; errors are non-fatal
160+ }))
161+ }
162+
163+ client := remote.NewClient(url, opts...)
153 return client, true164 return client, true
154 }165 }
155 166
M internal/adapters/in/http/admin/middleware.go +6 -0
@@ -106,6 +106,12 @@ func AuthMiddleware(
106106 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
107107 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
108108
109+ // Attempt to slide token expiry. Non-fatal if it fails.
110+ // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.
111+ if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr == nil && newToken != token {
112+ w.Header().Set("X-Gordon-Token", newToken)
113+ }
114+
109115 next.ServeHTTP(w, r.WithContext(ctx))
110116 })
111117 }
@@ -106,6 +106,12 @@ func AuthMiddleware(
106 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)106 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
107 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)107 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
108 108
109+ // Attempt to slide token expiry. Non-fatal if it fails.
110+ // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.
111+ if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr == nil && newToken != token {
112+ w.Header().Set("X-Gordon-Token", newToken)
113+ }
114+
109 next.ServeHTTP(w, r.WithContext(ctx))115 next.ServeHTTP(w, r.WithContext(ctx))
110 })116 })
111 }117 }
M internal/adapters/in/http/admin/middleware_test.go +6 -0
@@ -83,6 +83,8 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
8383 Subject: "admin",
8484 Scopes: []string{"admin:*:*"},
8585 }, nil)
86+ // ExtendToken is called after successful validation; non-fatal if it fails
87+ authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
8688
8789 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
8890 w.WriteHeader(http.StatusOK)
@@ -110,6 +112,8 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
110112 Subject: "admin",
111113 Scopes: []string{"admin:*:*"},
112114 }, nil)
115+ // ExtendToken is called after successful validation; non-fatal if it fails
116+ authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
113117
114118 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
115119 w.WriteHeader(http.StatusOK)
@@ -247,6 +251,8 @@ func TestAuthMiddleware_Success(t *testing.T) {
247251 Subject: "admin",
248252 Scopes: []string{"admin:*:*"},
249253 }, nil)
254+ // ExtendToken is called after successful validation; non-fatal if it fails
255+ authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
250256
251257 var capturedCtx context.Context
252258 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -83,6 +83,8 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
83 Subject: "admin",83 Subject: "admin",
84 Scopes: []string{"admin:*:*"},84 Scopes: []string{"admin:*:*"},
85 }, nil)85 }, nil)
86+ // ExtendToken is called after successful validation; non-fatal if it fails
87+ authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
86 88
87 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {89 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
88 w.WriteHeader(http.StatusOK)90 w.WriteHeader(http.StatusOK)
@@ -110,6 +112,8 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
110 Subject: "admin",112 Subject: "admin",
111 Scopes: []string{"admin:*:*"},113 Scopes: []string{"admin:*:*"},
112 }, nil)114 }, nil)
115+ // ExtendToken is called after successful validation; non-fatal if it fails
116+ authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
113 117
114 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {118 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
115 w.WriteHeader(http.StatusOK)119 w.WriteHeader(http.StatusOK)
@@ -247,6 +251,8 @@ func TestAuthMiddleware_Success(t *testing.T) {
247 Subject: "admin",251 Subject: "admin",
248 Scopes: []string{"admin:*:*"},252 Scopes: []string{"admin:*:*"},
249 }, nil)253 }, nil)
254+ // ExtendToken is called after successful validation; non-fatal if it fails
255+ authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
250 256
251 var capturedCtx context.Context257 var capturedCtx context.Context
252 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {258 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
M internal/adapters/in/http/middleware/auth_test.go +4 -0
@@ -170,6 +170,10 @@ func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, err
170170 return nil, errors.New("not implemented")
171171 }
172172
173+func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
174+ return "", errors.New("not implemented")
175+}
176+
173177 // Tests for checkScopeAccess function
174178
175179 func TestCheckScopeAccess_ActionMapping(t *testing.T) {
@@ -170,6 +170,10 @@ func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, err
170 return nil, errors.New("not implemented")170 return nil, errors.New("not implemented")
171 }171 }
172 172
173+func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
174+ return "", errors.New("not implemented")
175+}
176+
173 // Tests for checkScopeAccess function177 // Tests for checkScopeAccess function
174 178
175 func TestCheckScopeAccess_ActionMapping(t *testing.T) {179 func TestCheckScopeAccess_ActionMapping(t *testing.T) {
M internal/adapters/out/tokenstore/pass.go +27 -18
@@ -79,12 +79,13 @@ func NewPassStore(log zerowrap.Logger) *PassStore {
7979
8080 // tokenMetadata holds the non-sensitive token information.
8181 type tokenMetadata struct {
82- ID string `json:"id"`
83- Subject string `json:"subject"`
84- Scopes []string `json:"scopes"`
85- IssuedAt time.Time `json:"issued_at"`
86- ExpiresAt time.Time `json:"expires_at,omitempty"`
87- Revoked bool `json:"revoked"`
82+ ID string `json:"id"`
83+ Subject string `json:"subject"`
84+ Scopes []string `json:"scopes"`
85+ IssuedAt time.Time `json:"issued_at"`
86+ ExpiresAt time.Time `json:"expires_at,omitempty"`
87+ Revoked bool `json:"revoked"`
88+ LastExtendedAt time.Time `json:"last_extended_at,omitempty"`
8889 }
8990
9091 // SaveToken stores a token JWT and metadata in pass.
@@ -104,12 +105,13 @@ func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt stri
104105
105106 // Store metadata
106107 meta := tokenMetadata{
107- ID: token.ID,
108- Subject: token.Subject,
109- Scopes: token.Scopes,
110- IssuedAt: token.IssuedAt,
111- ExpiresAt: token.ExpiresAt,
112- Revoked: token.Revoked,
108+ ID: token.ID,
109+ Subject: token.Subject,
110+ Scopes: token.Scopes,
111+ IssuedAt: token.IssuedAt,
112+ ExpiresAt: token.ExpiresAt,
113+ Revoked: token.Revoked,
114+ LastExtendedAt: token.LastExtendedAt,
113115 }
114116 metaJSON, err := json.Marshal(meta)
115117 if err != nil {
@@ -173,12 +175,13 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma
173175 }
174176
175177 token := &domain.Token{
176- ID: meta.ID,
177- Subject: meta.Subject,
178- Scopes: meta.Scopes,
179- IssuedAt: meta.IssuedAt,
180- ExpiresAt: meta.ExpiresAt,
181- Revoked: meta.Revoked,
178+ ID: meta.ID,
179+ Subject: meta.Subject,
180+ Scopes: meta.Scopes,
181+ IssuedAt: meta.IssuedAt,
182+ ExpiresAt: meta.ExpiresAt,
183+ Revoked: meta.Revoked,
184+ LastExtendedAt: meta.LastExtendedAt,
182185 }
183186
184187 // Cache the token
@@ -321,6 +324,12 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error)
321324 return revoked, nil
322325 }
323326
327+// UpdateTokenExpiry updates the JWT and expiry metadata for an existing token.
328+// LastExtendedAt is also updated to track debounce timing.
329+func (s *PassStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error {
330+ return s.SaveToken(ctx, token, newJWT)
331+}
332+
324333 // DeleteToken removes token from pass.
325334 func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
326335 if err := validateSubject(subject); err != nil {
@@ -79,12 +79,13 @@ func NewPassStore(log zerowrap.Logger) *PassStore {
79 79
80 // tokenMetadata holds the non-sensitive token information.80 // tokenMetadata holds the non-sensitive token information.
81 type tokenMetadata struct {81 type tokenMetadata struct {
82- ID string `json:"id"`82+ ID string `json:"id"`
83- Subject string `json:"subject"`83+ Subject string `json:"subject"`
84- Scopes []string `json:"scopes"`84+ Scopes []string `json:"scopes"`
85- IssuedAt time.Time `json:"issued_at"`85+ IssuedAt time.Time `json:"issued_at"`
86- ExpiresAt time.Time `json:"expires_at,omitempty"`86+ ExpiresAt time.Time `json:"expires_at,omitempty"`
87- Revoked bool `json:"revoked"`87+ Revoked bool `json:"revoked"`
88+ LastExtendedAt time.Time `json:"last_extended_at,omitempty"`
88 }89 }
89 90
90 // SaveToken stores a token JWT and metadata in pass.91 // SaveToken stores a token JWT and metadata in pass.
@@ -104,12 +105,13 @@ func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt stri
104 105
105 // Store metadata106 // Store metadata
106 meta := tokenMetadata{107 meta := tokenMetadata{
107- ID: token.ID,108+ ID: token.ID,
108- Subject: token.Subject,109+ Subject: token.Subject,
109- Scopes: token.Scopes,110+ Scopes: token.Scopes,
110- IssuedAt: token.IssuedAt,111+ IssuedAt: token.IssuedAt,
111- ExpiresAt: token.ExpiresAt,112+ ExpiresAt: token.ExpiresAt,
112- Revoked: token.Revoked,113+ Revoked: token.Revoked,
114+ LastExtendedAt: token.LastExtendedAt,
113 }115 }
114 metaJSON, err := json.Marshal(meta)116 metaJSON, err := json.Marshal(meta)
115 if err != nil {117 if err != nil {
@@ -173,12 +175,13 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma
173 }175 }
174 176
175 token := &domain.Token{177 token := &domain.Token{
176- ID: meta.ID,178+ ID: meta.ID,
177- Subject: meta.Subject,179+ Subject: meta.Subject,
178- Scopes: meta.Scopes,180+ Scopes: meta.Scopes,
179- IssuedAt: meta.IssuedAt,181+ IssuedAt: meta.IssuedAt,
180- ExpiresAt: meta.ExpiresAt,182+ ExpiresAt: meta.ExpiresAt,
181- Revoked: meta.Revoked,183+ Revoked: meta.Revoked,
184+ LastExtendedAt: meta.LastExtendedAt,
182 }185 }
183 186
184 // Cache the token187 // Cache the token
@@ -321,6 +324,12 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error)
321 return revoked, nil324 return revoked, nil
322 }325 }
323 326
327+// UpdateTokenExpiry updates the JWT and expiry metadata for an existing token.
328+// LastExtendedAt is also updated to track debounce timing.
329+func (s *PassStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error {
330+ return s.SaveToken(ctx, token, newJWT)
331+}
332+
324 // DeleteToken removes token from pass.333 // DeleteToken removes token from pass.
325 func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {334 func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
326 if err := validateSubject(subject); err != nil {335 if err := validateSubject(subject); err != nil {
M internal/adapters/out/tokenstore/unsafe.go +27 -18
@@ -73,12 +73,13 @@ func (s *UnsafeStore) SaveToken(_ context.Context, token *domain.Token, jwt stri
7373 data := unsafeTokenData{
7474 JWT: jwt,
7575 Metadata: tokenMetadata{
76- ID: token.ID,
77- Subject: token.Subject, // Store original subject in metadata
78- Scopes: token.Scopes,
79- IssuedAt: token.IssuedAt,
80- ExpiresAt: token.ExpiresAt,
81- Revoked: token.Revoked,
76+ ID: token.ID,
77+ Subject: token.Subject, // Store original subject in metadata
78+ Scopes: token.Scopes,
79+ IssuedAt: token.IssuedAt,
80+ ExpiresAt: token.ExpiresAt,
81+ Revoked: token.Revoked,
82+ LastExtendedAt: token.LastExtendedAt,
8283 },
8384 }
8485
@@ -124,12 +125,13 @@ func (s *UnsafeStore) GetToken(_ context.Context, subject string) (string, *doma
124125 }
125126
126127 token := &domain.Token{
127- ID: data.Metadata.ID,
128- Subject: data.Metadata.Subject,
129- Scopes: data.Metadata.Scopes,
130- IssuedAt: data.Metadata.IssuedAt,
131- ExpiresAt: data.Metadata.ExpiresAt,
132- Revoked: data.Metadata.Revoked,
128+ ID: data.Metadata.ID,
129+ Subject: data.Metadata.Subject,
130+ Scopes: data.Metadata.Scopes,
131+ IssuedAt: data.Metadata.IssuedAt,
132+ ExpiresAt: data.Metadata.ExpiresAt,
133+ Revoked: data.Metadata.Revoked,
134+ LastExtendedAt: data.Metadata.LastExtendedAt,
133135 }
134136
135137 return data.JWT, token, nil
@@ -168,12 +170,13 @@ func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) {
168170 }
169171
170172 token := domain.Token{
171- ID: data.Metadata.ID,
172- Subject: data.Metadata.Subject,
173- Scopes: data.Metadata.Scopes,
174- IssuedAt: data.Metadata.IssuedAt,
175- ExpiresAt: data.Metadata.ExpiresAt,
176- Revoked: data.Metadata.Revoked,
173+ ID: data.Metadata.ID,
174+ Subject: data.Metadata.Subject,
175+ Scopes: data.Metadata.Scopes,
176+ IssuedAt: data.Metadata.IssuedAt,
177+ ExpiresAt: data.Metadata.ExpiresAt,
178+ Revoked: data.Metadata.Revoked,
179+ LastExtendedAt: data.Metadata.LastExtendedAt,
177180 }
178181
179182 tokens = append(tokens, token)
@@ -240,6 +243,12 @@ func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error)
240243 return false, nil
241244 }
242245
246+// UpdateTokenExpiry updates the JWT and expiry metadata for an existing token.
247+// LastExtendedAt is also updated to track debounce timing.
248+func (s *UnsafeStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error {
249+ return s.SaveToken(ctx, token, newJWT)
250+}
251+
243252 // DeleteToken removes token file.
244253 func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error {
245254 // SECURITY: Use sanitized filename to prevent path traversal
@@ -73,12 +73,13 @@ func (s *UnsafeStore) SaveToken(_ context.Context, token *domain.Token, jwt stri
73 data := unsafeTokenData{73 data := unsafeTokenData{
74 JWT: jwt,74 JWT: jwt,
75 Metadata: tokenMetadata{75 Metadata: tokenMetadata{
76- ID: token.ID,76+ ID: token.ID,
77- Subject: token.Subject, // Store original subject in metadata77+ Subject: token.Subject, // Store original subject in metadata
78- Scopes: token.Scopes,78+ Scopes: token.Scopes,
79- IssuedAt: token.IssuedAt,79+ IssuedAt: token.IssuedAt,
80- ExpiresAt: token.ExpiresAt,80+ ExpiresAt: token.ExpiresAt,
81- Revoked: token.Revoked,81+ Revoked: token.Revoked,
82+ LastExtendedAt: token.LastExtendedAt,
82 },83 },
83 }84 }
84 85
@@ -124,12 +125,13 @@ func (s *UnsafeStore) GetToken(_ context.Context, subject string) (string, *doma
124 }125 }
125 126
126 token := &domain.Token{127 token := &domain.Token{
127- ID: data.Metadata.ID,128+ ID: data.Metadata.ID,
128- Subject: data.Metadata.Subject,129+ Subject: data.Metadata.Subject,
129- Scopes: data.Metadata.Scopes,130+ Scopes: data.Metadata.Scopes,
130- IssuedAt: data.Metadata.IssuedAt,131+ IssuedAt: data.Metadata.IssuedAt,
131- ExpiresAt: data.Metadata.ExpiresAt,132+ ExpiresAt: data.Metadata.ExpiresAt,
132- Revoked: data.Metadata.Revoked,133+ Revoked: data.Metadata.Revoked,
134+ LastExtendedAt: data.Metadata.LastExtendedAt,
133 }135 }
134 136
135 return data.JWT, token, nil137 return data.JWT, token, nil
@@ -168,12 +170,13 @@ func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) {
168 }170 }
169 171
170 token := domain.Token{172 token := domain.Token{
171- ID: data.Metadata.ID,173+ ID: data.Metadata.ID,
172- Subject: data.Metadata.Subject,174+ Subject: data.Metadata.Subject,
173- Scopes: data.Metadata.Scopes,175+ Scopes: data.Metadata.Scopes,
174- IssuedAt: data.Metadata.IssuedAt,176+ IssuedAt: data.Metadata.IssuedAt,
175- ExpiresAt: data.Metadata.ExpiresAt,177+ ExpiresAt: data.Metadata.ExpiresAt,
176- Revoked: data.Metadata.Revoked,178+ Revoked: data.Metadata.Revoked,
179+ LastExtendedAt: data.Metadata.LastExtendedAt,
177 }180 }
178 181
179 tokens = append(tokens, token)182 tokens = append(tokens, token)
@@ -240,6 +243,12 @@ func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error)
240 return false, nil243 return false, nil
241 }244 }
242 245
246+// UpdateTokenExpiry updates the JWT and expiry metadata for an existing token.
247+// LastExtendedAt is also updated to track debounce timing.
248+func (s *UnsafeStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error {
249+ return s.SaveToken(ctx, token, newJWT)
250+}
251+
243 // DeleteToken removes token file.252 // DeleteToken removes token file.
244 func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error {253 func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error {
245 // SECURITY: Use sanitized filename to prevent path traversal254 // SECURITY: Use sanitized filename to prevent path traversal
M internal/boundaries/in/auth.go +5 -0
@@ -46,4 +46,9 @@ type AuthService interface {
4646 // GetAuthStatus returns authentication status from context.
4747 // Extracts and validates token claims that were set by auth middleware.
4848 GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error)
49+
50+ // ExtendToken re-issues the token with expiry slid forward by 24h.
51+ // Returns the same token string if it was already extended within the debounce window (1h).
52+ // Skips extension for ephemeral access tokens (≤5min) and service tokens.
53+ ExtendToken(ctx context.Context, tokenString string) (string, error)
4954 }
@@ -46,4 +46,9 @@ type AuthService interface {
46 // GetAuthStatus returns authentication status from context.46 // GetAuthStatus returns authentication status from context.
47 // Extracts and validates token claims that were set by auth middleware.47 // Extracts and validates token claims that were set by auth middleware.
48 GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error)48 GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error)
49+
50+ // ExtendToken re-issues the token with expiry slid forward by 24h.
51+ // Returns the same token string if it was already extended within the debounce window (1h).
52+ // Skips extension for ephemeral access tokens (≤5min) and service tokens.
53+ ExtendToken(ctx context.Context, tokenString string) (string, error)
49 }54 }
M internal/boundaries/in/mocks/mock_auth_service.go +63 -0
@@ -714,3 +714,66 @@ func (_c *MockAuthService_ValidateToken_Call) RunAndReturn(run func(ctx context.
714714 _c.Call.Return(run)
715715 return _c
716716 }
717+
718+// ExtendToken provides a mock function for the type MockAuthService
719+func (_mock *MockAuthService) ExtendToken(ctx context.Context, tokenString string) (string, error) {
720+ ret := _mock.Called(ctx, tokenString)
721+
722+ if len(ret) == 0 {
723+ panic("no return value specified for ExtendToken")
724+ }
725+
726+ var r0 string
727+ var r1 error
728+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok {
729+ return returnFunc(ctx, tokenString)
730+ }
731+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
732+ r0 = returnFunc(ctx, tokenString)
733+ } else {
734+ r0 = ret.Get(0).(string)
735+ }
736+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
737+ r1 = returnFunc(ctx, tokenString)
738+ } else {
739+ r1 = ret.Error(1)
740+ }
741+ return r0, r1
742+}
743+
744+// MockAuthService_ExtendToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExtendToken'
745+type MockAuthService_ExtendToken_Call struct {
746+ *mock.Call
747+}
748+
749+// ExtendToken is a helper method to define mock.On call
750+// - ctx context.Context
751+// - tokenString string
752+func (_e *MockAuthService_Expecter) ExtendToken(ctx interface{}, tokenString interface{}) *MockAuthService_ExtendToken_Call {
753+ return &MockAuthService_ExtendToken_Call{Call: _e.mock.On("ExtendToken", ctx, tokenString)}
754+}
755+
756+func (_c *MockAuthService_ExtendToken_Call) Run(run func(ctx context.Context, tokenString string)) *MockAuthService_ExtendToken_Call {
757+ _c.Call.Run(func(args mock.Arguments) {
758+ var arg0 context.Context
759+ if args[0] != nil {
760+ arg0 = args[0].(context.Context)
761+ }
762+ var arg1 string
763+ if args[1] != nil {
764+ arg1 = args[1].(string)
765+ }
766+ run(arg0, arg1)
767+ })
768+ return _c
769+}
770+
771+func (_c *MockAuthService_ExtendToken_Call) Return(s string, err error) *MockAuthService_ExtendToken_Call {
772+ _c.Call.Return(s, err)
773+ return _c
774+}
775+
776+func (_c *MockAuthService_ExtendToken_Call) RunAndReturn(run func(ctx context.Context, tokenString string) (string, error)) *MockAuthService_ExtendToken_Call {
777+ _c.Call.Return(run)
778+ return _c
779+}
@@ -714,3 +714,66 @@ func (_c *MockAuthService_ValidateToken_Call) RunAndReturn(run func(ctx context.
714 _c.Call.Return(run)714 _c.Call.Return(run)
715 return _c715 return _c
716 }716 }
717+
718+// ExtendToken provides a mock function for the type MockAuthService
719+func (_mock *MockAuthService) ExtendToken(ctx context.Context, tokenString string) (string, error) {
720+ ret := _mock.Called(ctx, tokenString)
721+
722+ if len(ret) == 0 {
723+ panic("no return value specified for ExtendToken")
724+ }
725+
726+ var r0 string
727+ var r1 error
728+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok {
729+ return returnFunc(ctx, tokenString)
730+ }
731+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
732+ r0 = returnFunc(ctx, tokenString)
733+ } else {
734+ r0 = ret.Get(0).(string)
735+ }
736+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
737+ r1 = returnFunc(ctx, tokenString)
738+ } else {
739+ r1 = ret.Error(1)
740+ }
741+ return r0, r1
742+}
743+
744+// MockAuthService_ExtendToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExtendToken'
745+type MockAuthService_ExtendToken_Call struct {
746+ *mock.Call
747+}
748+
749+// ExtendToken is a helper method to define mock.On call
750+// - ctx context.Context
751+// - tokenString string
752+func (_e *MockAuthService_Expecter) ExtendToken(ctx interface{}, tokenString interface{}) *MockAuthService_ExtendToken_Call {
753+ return &MockAuthService_ExtendToken_Call{Call: _e.mock.On("ExtendToken", ctx, tokenString)}
754+}
755+
756+func (_c *MockAuthService_ExtendToken_Call) Run(run func(ctx context.Context, tokenString string)) *MockAuthService_ExtendToken_Call {
757+ _c.Call.Run(func(args mock.Arguments) {
758+ var arg0 context.Context
759+ if args[0] != nil {
760+ arg0 = args[0].(context.Context)
761+ }
762+ var arg1 string
763+ if args[1] != nil {
764+ arg1 = args[1].(string)
765+ }
766+ run(arg0, arg1)
767+ })
768+ return _c
769+}
770+
771+func (_c *MockAuthService_ExtendToken_Call) Return(s string, err error) *MockAuthService_ExtendToken_Call {
772+ _c.Call.Return(s, err)
773+ return _c
774+}
775+
776+func (_c *MockAuthService_ExtendToken_Call) RunAndReturn(run func(ctx context.Context, tokenString string) (string, error)) *MockAuthService_ExtendToken_Call {
777+ _c.Call.Return(run)
778+ return _c
779+}
M internal/boundaries/out/mocks/mock_token_store.go +59 -0
@@ -354,6 +354,65 @@ func (_c *MockTokenStore_Revoke_Call) RunAndReturn(run func(ctx context.Context,
354354 return _c
355355 }
356356
357+// UpdateTokenExpiry provides a mock function for the type MockTokenStore
358+func (_mock *MockTokenStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error {
359+ ret := _mock.Called(ctx, token, newJWT)
360+
361+ if len(ret) == 0 {
362+ panic("no return value specified for UpdateTokenExpiry")
363+ }
364+
365+ var r0 error
366+ if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Token, string) error); ok {
367+ r0 = returnFunc(ctx, token, newJWT)
368+ } else {
369+ r0 = ret.Error(0)
370+ }
371+ return r0
372+}
373+
374+// MockTokenStore_UpdateTokenExpiry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateTokenExpiry'
375+type MockTokenStore_UpdateTokenExpiry_Call struct {
376+ *mock.Call
377+}
378+
379+// UpdateTokenExpiry is a helper method to define mock.On call
380+// - ctx context.Context
381+// - token *domain.Token
382+// - newJWT string
383+func (_e *MockTokenStore_Expecter) UpdateTokenExpiry(ctx interface{}, token interface{}, newJWT interface{}) *MockTokenStore_UpdateTokenExpiry_Call {
384+ return &MockTokenStore_UpdateTokenExpiry_Call{Call: _e.mock.On("UpdateTokenExpiry", ctx, token, newJWT)}
385+}
386+
387+func (_c *MockTokenStore_UpdateTokenExpiry_Call) Run(run func(ctx context.Context, token *domain.Token, newJWT string)) *MockTokenStore_UpdateTokenExpiry_Call {
388+ _c.Call.Run(func(args mock.Arguments) {
389+ var arg0 context.Context
390+ if args[0] != nil {
391+ arg0 = args[0].(context.Context)
392+ }
393+ var arg1 *domain.Token
394+ if args[1] != nil {
395+ arg1 = args[1].(*domain.Token)
396+ }
397+ var arg2 string
398+ if args[2] != nil {
399+ arg2 = args[2].(string)
400+ }
401+ run(arg0, arg1, arg2)
402+ })
403+ return _c
404+}
405+
406+func (_c *MockTokenStore_UpdateTokenExpiry_Call) Return(err error) *MockTokenStore_UpdateTokenExpiry_Call {
407+ _c.Call.Return(err)
408+ return _c
409+}
410+
411+func (_c *MockTokenStore_UpdateTokenExpiry_Call) RunAndReturn(run func(ctx context.Context, token *domain.Token, newJWT string) error) *MockTokenStore_UpdateTokenExpiry_Call {
412+ _c.Call.Return(run)
413+ return _c
414+}
415+
357416 // SaveToken provides a mock function for the type MockTokenStore
358417 func (_mock *MockTokenStore) SaveToken(ctx context.Context, token *domain.Token, jwt string) error {
359418 ret := _mock.Called(ctx, token, jwt)
@@ -354,6 +354,65 @@ func (_c *MockTokenStore_Revoke_Call) RunAndReturn(run func(ctx context.Context,
354 return _c354 return _c
355 }355 }
356 356
357+// UpdateTokenExpiry provides a mock function for the type MockTokenStore
358+func (_mock *MockTokenStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error {
359+ ret := _mock.Called(ctx, token, newJWT)
360+
361+ if len(ret) == 0 {
362+ panic("no return value specified for UpdateTokenExpiry")
363+ }
364+
365+ var r0 error
366+ if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Token, string) error); ok {
367+ r0 = returnFunc(ctx, token, newJWT)
368+ } else {
369+ r0 = ret.Error(0)
370+ }
371+ return r0
372+}
373+
374+// MockTokenStore_UpdateTokenExpiry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateTokenExpiry'
375+type MockTokenStore_UpdateTokenExpiry_Call struct {
376+ *mock.Call
377+}
378+
379+// UpdateTokenExpiry is a helper method to define mock.On call
380+// - ctx context.Context
381+// - token *domain.Token
382+// - newJWT string
383+func (_e *MockTokenStore_Expecter) UpdateTokenExpiry(ctx interface{}, token interface{}, newJWT interface{}) *MockTokenStore_UpdateTokenExpiry_Call {
384+ return &MockTokenStore_UpdateTokenExpiry_Call{Call: _e.mock.On("UpdateTokenExpiry", ctx, token, newJWT)}
385+}
386+
387+func (_c *MockTokenStore_UpdateTokenExpiry_Call) Run(run func(ctx context.Context, token *domain.Token, newJWT string)) *MockTokenStore_UpdateTokenExpiry_Call {
388+ _c.Call.Run(func(args mock.Arguments) {
389+ var arg0 context.Context
390+ if args[0] != nil {
391+ arg0 = args[0].(context.Context)
392+ }
393+ var arg1 *domain.Token
394+ if args[1] != nil {
395+ arg1 = args[1].(*domain.Token)
396+ }
397+ var arg2 string
398+ if args[2] != nil {
399+ arg2 = args[2].(string)
400+ }
401+ run(arg0, arg1, arg2)
402+ })
403+ return _c
404+}
405+
406+func (_c *MockTokenStore_UpdateTokenExpiry_Call) Return(err error) *MockTokenStore_UpdateTokenExpiry_Call {
407+ _c.Call.Return(err)
408+ return _c
409+}
410+
411+func (_c *MockTokenStore_UpdateTokenExpiry_Call) RunAndReturn(run func(ctx context.Context, token *domain.Token, newJWT string) error) *MockTokenStore_UpdateTokenExpiry_Call {
412+ _c.Call.Return(run)
413+ return _c
414+}
415+
357 // SaveToken provides a mock function for the type MockTokenStore416 // SaveToken provides a mock function for the type MockTokenStore
358 func (_mock *MockTokenStore) SaveToken(ctx context.Context, token *domain.Token, jwt string) error {417 func (_mock *MockTokenStore) SaveToken(ctx context.Context, token *domain.Token, jwt string) error {
359 ret := _mock.Called(ctx, token, jwt)418 ret := _mock.Called(ctx, token, jwt)
M internal/boundaries/out/token_store.go +4 -0
@@ -26,4 +26,8 @@ type TokenStore interface {
2626
2727 // DeleteToken removes token from store.
2828 DeleteToken(ctx context.Context, subject string) error
29+
30+ // UpdateTokenExpiry updates the JWT and expiry/LastExtendedAt metadata for an existing token.
31+ // Used by token sliding expiry to re-sign tokens without changing the JTI.
32+ UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error
2933 }
@@ -26,4 +26,8 @@ type TokenStore interface {
26 26
27 // DeleteToken removes token from store.27 // DeleteToken removes token from store.
28 DeleteToken(ctx context.Context, subject string) error28 DeleteToken(ctx context.Context, subject string) error
29+
30+ // UpdateTokenExpiry updates the JWT and expiry/LastExtendedAt metadata for an existing token.
31+ // Used by token sliding expiry to re-sign tokens without changing the JTI.
32+ UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error
29 }33 }
M internal/domain/auth.go +7 -6
@@ -20,12 +20,13 @@ const (
2020
2121 // Token represents a generated authentication token stored in the secrets backend.
2222 type Token struct {
23- ID string
24- Subject string
25- Scopes []string
26- IssuedAt time.Time
27- ExpiresAt time.Time // Zero value means never expires
28- Revoked bool
23+ ID string
24+ Subject string
25+ Scopes []string
26+ IssuedAt time.Time
27+ ExpiresAt time.Time // Zero value means never expires
28+ Revoked bool
29+ LastExtendedAt time.Time // Zero value means never extended
2930 }
3031
3132 // TokenClaims represents the JWT claims for a token.
@@ -20,12 +20,13 @@ const (
20 20
21 // Token represents a generated authentication token stored in the secrets backend.21 // Token represents a generated authentication token stored in the secrets backend.
22 type Token struct {22 type Token struct {
23- ID string23+ ID string
24- Subject string24+ Subject string
25- Scopes []string25+ Scopes []string
26- IssuedAt time.Time26+ IssuedAt time.Time
27- ExpiresAt time.Time // Zero value means never expires27+ ExpiresAt time.Time // Zero value means never expires
28- Revoked bool28+ Revoked bool
29+ LastExtendedAt time.Time // Zero value means never extended
29 }30 }
30 31
31 // TokenClaims represents the JWT claims for a token.32 // TokenClaims represents the JWT claims for a token.
M internal/usecase/auth/service.go +102 -0
@@ -27,6 +27,13 @@ const (
2727 MaxAccessTokenLifetime = 5 * time.Minute
2828 // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.
2929 maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)
30+ // tokenExtensionTTL is the amount of time added to a token's expiry when extended.
31+ tokenExtensionTTL = 24 * time.Hour
32+ // tokenExtensionDebounce is the minimum time between token extensions.
33+ tokenExtensionDebounce = time.Hour
34+ // serviceTokenSubject is the subject used for internal service tokens.
35+ // Service tokens are not extended to avoid churn on the token store.
36+ serviceTokenSubject = "gordon-service"
3037 )
3138
3239 // Config holds the authentication configuration.
@@ -433,6 +440,101 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
433440 return tokens, nil
434441 }
435442
443+// ExtendToken re-issues the token with expiry slid forward by 24h.
444+// Returns the same token string if it was already extended within the debounce window (1h).
445+// Skips extension for ephemeral access tokens (≤5min) and service tokens.
446+func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) {
447+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
448+ zerowrap.FieldLayer: "usecase",
449+ zerowrap.FieldUseCase: "ExtendToken",
450+ })
451+ log := zerowrap.FromCtx(ctx)
452+
453+ // Parse claims without store validation first (to check skip conditions cheaply)
454+ rawClaims, err := s.parseTokenClaims(tokenString)
455+ if err != nil {
456+ return "", domain.ErrInvalidToken
457+ }
458+
459+ tokenClaims := buildTokenClaims(rawClaims)
460+
461+ // Skip ephemeral access tokens (≤5min lifetime)
462+ if s.isEphemeralAccessToken(tokenClaims) {
463+ log.Debug().Msg("skipping extension for ephemeral access token")
464+ return tokenString, nil
465+ }
466+
467+ // Skip service tokens
468+ if tokenClaims.Subject == serviceTokenSubject {
469+ log.Debug().Msg("skipping extension for service token")
470+ return tokenString, nil
471+ }
472+
473+ // Full validation including store checks (not expired, not revoked, exists in store)
474+ if _, err := s.ValidateToken(ctx, tokenString); err != nil {
475+ return "", fmt.Errorf("token validation failed: %w", err)
476+ }
477+
478+ // Fetch stored token to check debounce and get metadata
479+ _, storedToken, err := s.tokenStore.GetToken(ctx, tokenClaims.Subject)
480+ if err != nil {
481+ return "", fmt.Errorf("failed to fetch stored token: %w", err)
482+ }
483+
484+ // Debounce: skip if already extended within the last hour
485+ if !storedToken.LastExtendedAt.IsZero() && time.Since(storedToken.LastExtendedAt) < tokenExtensionDebounce {
486+ log.Debug().
487+ Str("subject", tokenClaims.Subject).
488+ Time("last_extended_at", storedToken.LastExtendedAt).
489+ Msg("token extension debounced")
490+ return tokenString, nil
491+ }
492+
493+ // Re-sign with same JTI but new expiry
494+ now := time.Now().UTC()
495+ newExpiresAt := now.Add(tokenExtensionTTL)
496+
497+ newClaims := jwt.MapClaims{
498+ "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests
499+ "sub": tokenClaims.Subject,
500+ "iss": TokenIssuer,
501+ "iat": now.Unix(), // Update to current time (token is being re-issued)
502+ "nbf": now.Unix(), // SECURITY: not-before matches issuance time
503+ "scopes": tokenClaims.Scopes,
504+ "exp": newExpiresAt.Unix(),
505+ }
506+
507+ jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
508+ newTokenString, err := jwtToken.SignedString(s.config.TokenSecret)
509+ if err != nil {
510+ return "", log.WrapErr(err, "failed to re-sign token")
511+ }
512+
513+ // Update stored token with new JWT, expiry, and LastExtendedAt
514+ updatedToken := &domain.Token{
515+ ID: storedToken.ID,
516+ Subject: storedToken.Subject,
517+ Scopes: storedToken.Scopes,
518+ IssuedAt: storedToken.IssuedAt,
519+ ExpiresAt: newExpiresAt,
520+ Revoked: storedToken.Revoked,
521+ LastExtendedAt: now,
522+ }
523+
524+ if err := s.tokenStore.UpdateTokenExpiry(ctx, updatedToken, newTokenString); err != nil {
525+ // Non-fatal: log and return original token rather than failing the request
526+ log.Warn().Err(err).Str("subject", tokenClaims.Subject).Msg("failed to persist extended token, returning original")
527+ return tokenString, nil
528+ }
529+
530+ log.Debug().
531+ Str("subject", tokenClaims.Subject).
532+ Time("new_expires_at", newExpiresAt).
533+ Msg("token expiry extended")
534+
535+ return newTokenString, nil
536+}
537+
436538 // GeneratePasswordHash generates a bcrypt hash for a password.
437539 func (s *Service) GeneratePasswordHash(password string) (string, error) {
438540 hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
@@ -27,6 +27,13 @@ const (
27 MaxAccessTokenLifetime = 5 * time.Minute27 MaxAccessTokenLifetime = 5 * time.Minute
28 // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.28 // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.
29 maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)29 maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)
30+ // tokenExtensionTTL is the amount of time added to a token's expiry when extended.
31+ tokenExtensionTTL = 24 * time.Hour
32+ // tokenExtensionDebounce is the minimum time between token extensions.
33+ tokenExtensionDebounce = time.Hour
34+ // serviceTokenSubject is the subject used for internal service tokens.
35+ // Service tokens are not extended to avoid churn on the token store.
36+ serviceTokenSubject = "gordon-service"
30 )37 )
31 38
32 // Config holds the authentication configuration.39 // Config holds the authentication configuration.
@@ -433,6 +440,101 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
433 return tokens, nil440 return tokens, nil
434 }441 }
435 442
443+// ExtendToken re-issues the token with expiry slid forward by 24h.
444+// Returns the same token string if it was already extended within the debounce window (1h).
445+// Skips extension for ephemeral access tokens (≤5min) and service tokens.
446+func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) {
447+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
448+ zerowrap.FieldLayer: "usecase",
449+ zerowrap.FieldUseCase: "ExtendToken",
450+ })
451+ log := zerowrap.FromCtx(ctx)
452+
453+ // Parse claims without store validation first (to check skip conditions cheaply)
454+ rawClaims, err := s.parseTokenClaims(tokenString)
455+ if err != nil {
456+ return "", domain.ErrInvalidToken
457+ }
458+
459+ tokenClaims := buildTokenClaims(rawClaims)
460+
461+ // Skip ephemeral access tokens (≤5min lifetime)
462+ if s.isEphemeralAccessToken(tokenClaims) {
463+ log.Debug().Msg("skipping extension for ephemeral access token")
464+ return tokenString, nil
465+ }
466+
467+ // Skip service tokens
468+ if tokenClaims.Subject == serviceTokenSubject {
469+ log.Debug().Msg("skipping extension for service token")
470+ return tokenString, nil
471+ }
472+
473+ // Full validation including store checks (not expired, not revoked, exists in store)
474+ if _, err := s.ValidateToken(ctx, tokenString); err != nil {
475+ return "", fmt.Errorf("token validation failed: %w", err)
476+ }
477+
478+ // Fetch stored token to check debounce and get metadata
479+ _, storedToken, err := s.tokenStore.GetToken(ctx, tokenClaims.Subject)
480+ if err != nil {
481+ return "", fmt.Errorf("failed to fetch stored token: %w", err)
482+ }
483+
484+ // Debounce: skip if already extended within the last hour
485+ if !storedToken.LastExtendedAt.IsZero() && time.Since(storedToken.LastExtendedAt) < tokenExtensionDebounce {
486+ log.Debug().
487+ Str("subject", tokenClaims.Subject).
488+ Time("last_extended_at", storedToken.LastExtendedAt).
489+ Msg("token extension debounced")
490+ return tokenString, nil
491+ }
492+
493+ // Re-sign with same JTI but new expiry
494+ now := time.Now().UTC()
495+ newExpiresAt := now.Add(tokenExtensionTTL)
496+
497+ newClaims := jwt.MapClaims{
498+ "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests
499+ "sub": tokenClaims.Subject,
500+ "iss": TokenIssuer,
501+ "iat": now.Unix(), // Update to current time (token is being re-issued)
502+ "nbf": now.Unix(), // SECURITY: not-before matches issuance time
503+ "scopes": tokenClaims.Scopes,
504+ "exp": newExpiresAt.Unix(),
505+ }
506+
507+ jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
508+ newTokenString, err := jwtToken.SignedString(s.config.TokenSecret)
509+ if err != nil {
510+ return "", log.WrapErr(err, "failed to re-sign token")
511+ }
512+
513+ // Update stored token with new JWT, expiry, and LastExtendedAt
514+ updatedToken := &domain.Token{
515+ ID: storedToken.ID,
516+ Subject: storedToken.Subject,
517+ Scopes: storedToken.Scopes,
518+ IssuedAt: storedToken.IssuedAt,
519+ ExpiresAt: newExpiresAt,
520+ Revoked: storedToken.Revoked,
521+ LastExtendedAt: now,
522+ }
523+
524+ if err := s.tokenStore.UpdateTokenExpiry(ctx, updatedToken, newTokenString); err != nil {
525+ // Non-fatal: log and return original token rather than failing the request
526+ log.Warn().Err(err).Str("subject", tokenClaims.Subject).Msg("failed to persist extended token, returning original")
527+ return tokenString, nil
528+ }
529+
530+ log.Debug().
531+ Str("subject", tokenClaims.Subject).
532+ Time("new_expires_at", newExpiresAt).
533+ Msg("token expiry extended")
534+
535+ return newTokenString, nil
536+}
537+
436 // GeneratePasswordHash generates a bcrypt hash for a password.538 // GeneratePasswordHash generates a bcrypt hash for a password.
437 func (s *Service) GeneratePasswordHash(password string) (string, error) {539 func (s *Service) GeneratePasswordHash(password string) (string, error) {
438 hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)540 hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
M internal/usecase/auth/service_test.go +185 -0
@@ -816,3 +816,188 @@ func TestService_GenerateAccessToken_HasNbfClaim(t *testing.T) {
816816
817817 assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens")
818818 }
819+
820+func TestExtendTokenSlidesExpiry(t *testing.T) {
821+ tokenStore := mocks.NewMockTokenStore(t)
822+
823+ svc := NewService(Config{
824+ Enabled: true,
825+ AuthType: domain.AuthTypeToken,
826+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
827+ }, tokenStore, zerowrap.Default())
828+
829+ ctx := testContext()
830+
831+ // Generate a token with a short remaining life (1h) so ExtendToken produces a different exp
832+ var capturedToken *domain.Token
833+ tokenStore.EXPECT().
834+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
835+ Run(func(_ context.Context, t *domain.Token, _ string) {
836+ capturedToken = t
837+ }).
838+ Return(nil)
839+
840+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
841+ require.NoError(t, err)
842+
843+ // ExtendToken flow:
844+ // 1. ValidateToken → GetToken (ensureTokenExists) + IsRevoked
845+ tokenStore.EXPECT().
846+ GetToken(mock.Anything, "testuser").
847+ Return(tokenStr, capturedToken, nil).Once()
848+ tokenStore.EXPECT().
849+ IsRevoked(mock.Anything, capturedToken.ID).
850+ Return(false, nil).Once()
851+
852+ // 2. GetToken again for debounce check
853+ tokenStore.EXPECT().
854+ GetToken(mock.Anything, "testuser").
855+ Return(tokenStr, capturedToken, nil).Once()
856+
857+ // 3. UpdateTokenExpiry
858+ var updatedToken *domain.Token
859+ tokenStore.EXPECT().
860+ UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything).
861+ Run(func(_ context.Context, t *domain.Token, _ string) {
862+ updatedToken = t
863+ }).
864+ Return(nil)
865+
866+ // Extend it
867+ newTokenStr, err := svc.ExtendToken(ctx, tokenStr)
868+ require.NoError(t, err, "ExtendToken should not fail")
869+
870+ // Since the original token expires in 1h and the new one expires in 24h, they must differ
871+ assert.NotEqual(t, tokenStr, newTokenStr, "expected a new token string after extension")
872+ assert.NotNil(t, updatedToken)
873+ assert.False(t, updatedToken.LastExtendedAt.IsZero(), "LastExtendedAt should be set")
874+
875+ // New token must be valid — set up mock expectations for ValidateToken
876+ tokenStore.EXPECT().
877+ GetToken(mock.Anything, "testuser").
878+ Return(newTokenStr, updatedToken, nil)
879+ tokenStore.EXPECT().
880+ IsRevoked(mock.Anything, capturedToken.ID).
881+ Return(false, nil)
882+
883+ claims, err := svc.ValidateToken(ctx, newTokenStr)
884+ require.NoError(t, err, "new token must be valid")
885+
886+ // New token should have ~24h expiry from now
887+ expectedExpiry := time.Now().Add(24 * time.Hour)
888+ actualExpiry := time.Unix(claims.ExpiresAt, 0)
889+ assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now")
890+}
891+
892+func TestExtendTokenDebounce(t *testing.T) {
893+ tokenStore := mocks.NewMockTokenStore(t)
894+
895+ svc := NewService(Config{
896+ Enabled: true,
897+ AuthType: domain.AuthTypeToken,
898+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
899+ }, tokenStore, zerowrap.Default())
900+
901+ ctx := testContext()
902+
903+ // Generate a short-lived token (1h) so the first extension produces a different JWT
904+ var capturedToken *domain.Token
905+ tokenStore.EXPECT().
906+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
907+ Run(func(_ context.Context, t *domain.Token, _ string) {
908+ capturedToken = t
909+ }).
910+ Return(nil)
911+
912+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
913+ require.NoError(t, err)
914+
915+ // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry
916+ tokenStore.EXPECT().
917+ GetToken(mock.Anything, "testuser").
918+ Return(tokenStr, capturedToken, nil).Once()
919+ tokenStore.EXPECT().
920+ IsRevoked(mock.Anything, capturedToken.ID).
921+ Return(false, nil).Once()
922+ tokenStore.EXPECT().
923+ GetToken(mock.Anything, "testuser").
924+ Return(tokenStr, capturedToken, nil).Once()
925+
926+ var extendedToken *domain.Token
927+ var extendedJWT string
928+ tokenStore.EXPECT().
929+ UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything).
930+ Run(func(_ context.Context, t *domain.Token, jwt string) {
931+ extendedToken = t
932+ extendedJWT = jwt
933+ }).
934+ Return(nil).Once()
935+
936+ newToken1, err := svc.ExtendToken(ctx, tokenStr)
937+ require.NoError(t, err)
938+ assert.NotEqual(t, tokenStr, newToken1, "first extend should produce a new token")
939+ assert.NotNil(t, extendedToken)
940+
941+ // Second extend immediately — debounce should kick in (LastExtendedAt just set to now)
942+ // Flow: ValidateToken(GetToken+IsRevoked) + GetToken(debounce) → debounced, no UpdateTokenExpiry
943+ tokenStore.EXPECT().
944+ GetToken(mock.Anything, "testuser").
945+ Return(extendedJWT, extendedToken, nil).Once()
946+ tokenStore.EXPECT().
947+ IsRevoked(mock.Anything, capturedToken.ID).
948+ Return(false, nil).Once()
949+ tokenStore.EXPECT().
950+ GetToken(mock.Anything, "testuser").
951+ Return(extendedJWT, extendedToken, nil).Once()
952+
953+ // No UpdateTokenExpiry should be called — debounced
954+ newToken2, err := svc.ExtendToken(ctx, newToken1)
955+ require.NoError(t, err)
956+
957+ assert.Equal(t, newToken1, newToken2, "expected debounce to return same token within 1h window")
958+}
959+
960+func TestExtendTokenSkipsEphemeral(t *testing.T) {
961+ svc := NewService(Config{
962+ Enabled: true,
963+ AuthType: domain.AuthTypeToken,
964+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
965+ }, nil, zerowrap.Default())
966+
967+ ctx := testContext()
968+
969+ // Generate an ephemeral access token (≤5min)
970+ tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute)
971+ require.NoError(t, err)
972+
973+ // ExtendToken should skip ephemeral tokens and return the same string
974+ result, err := svc.ExtendToken(ctx, tokenStr)
975+ require.NoError(t, err)
976+ assert.Equal(t, tokenStr, result, "ephemeral tokens should not be extended")
977+}
978+
979+func TestExtendTokenSkipsServiceToken(t *testing.T) {
980+ tokenStore := mocks.NewMockTokenStore(t)
981+
982+ svc := NewService(Config{
983+ Enabled: true,
984+ AuthType: domain.AuthTypeToken,
985+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
986+ }, tokenStore, zerowrap.Default())
987+
988+ ctx := testContext()
989+
990+ // Generate a service token — service tokens skip extension before store checks
991+ tokenStore.EXPECT().
992+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
993+ Return(nil)
994+
995+ const svcSubject = "gordon-service"
996+ tokenStr, err := svc.GenerateToken(ctx, svcSubject, []string{"pull"}, 24*time.Hour)
997+ require.NoError(t, err)
998+
999+ // ExtendToken should skip service tokens WITHOUT touching the store
1000+ result, err := svc.ExtendToken(ctx, tokenStr)
1001+ require.NoError(t, err)
1002+ assert.Equal(t, tokenStr, result, "service tokens should not be extended")
1003+}
@@ -816,3 +816,188 @@ func TestService_GenerateAccessToken_HasNbfClaim(t *testing.T) {
816 816
817 assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens")817 assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens")
818 }818 }
819+
820+func TestExtendTokenSlidesExpiry(t *testing.T) {
821+ tokenStore := mocks.NewMockTokenStore(t)
822+
823+ svc := NewService(Config{
824+ Enabled: true,
825+ AuthType: domain.AuthTypeToken,
826+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
827+ }, tokenStore, zerowrap.Default())
828+
829+ ctx := testContext()
830+
831+ // Generate a token with a short remaining life (1h) so ExtendToken produces a different exp
832+ var capturedToken *domain.Token
833+ tokenStore.EXPECT().
834+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
835+ Run(func(_ context.Context, t *domain.Token, _ string) {
836+ capturedToken = t
837+ }).
838+ Return(nil)
839+
840+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
841+ require.NoError(t, err)
842+
843+ // ExtendToken flow:
844+ // 1. ValidateToken → GetToken (ensureTokenExists) + IsRevoked
845+ tokenStore.EXPECT().
846+ GetToken(mock.Anything, "testuser").
847+ Return(tokenStr, capturedToken, nil).Once()
848+ tokenStore.EXPECT().
849+ IsRevoked(mock.Anything, capturedToken.ID).
850+ Return(false, nil).Once()
851+
852+ // 2. GetToken again for debounce check
853+ tokenStore.EXPECT().
854+ GetToken(mock.Anything, "testuser").
855+ Return(tokenStr, capturedToken, nil).Once()
856+
857+ // 3. UpdateTokenExpiry
858+ var updatedToken *domain.Token
859+ tokenStore.EXPECT().
860+ UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything).
861+ Run(func(_ context.Context, t *domain.Token, _ string) {
862+ updatedToken = t
863+ }).
864+ Return(nil)
865+
866+ // Extend it
867+ newTokenStr, err := svc.ExtendToken(ctx, tokenStr)
868+ require.NoError(t, err, "ExtendToken should not fail")
869+
870+ // Since the original token expires in 1h and the new one expires in 24h, they must differ
871+ assert.NotEqual(t, tokenStr, newTokenStr, "expected a new token string after extension")
872+ assert.NotNil(t, updatedToken)
873+ assert.False(t, updatedToken.LastExtendedAt.IsZero(), "LastExtendedAt should be set")
874+
875+ // New token must be valid — set up mock expectations for ValidateToken
876+ tokenStore.EXPECT().
877+ GetToken(mock.Anything, "testuser").
878+ Return(newTokenStr, updatedToken, nil)
879+ tokenStore.EXPECT().
880+ IsRevoked(mock.Anything, capturedToken.ID).
881+ Return(false, nil)
882+
883+ claims, err := svc.ValidateToken(ctx, newTokenStr)
884+ require.NoError(t, err, "new token must be valid")
885+
886+ // New token should have ~24h expiry from now
887+ expectedExpiry := time.Now().Add(24 * time.Hour)
888+ actualExpiry := time.Unix(claims.ExpiresAt, 0)
889+ assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now")
890+}
891+
892+func TestExtendTokenDebounce(t *testing.T) {
893+ tokenStore := mocks.NewMockTokenStore(t)
894+
895+ svc := NewService(Config{
896+ Enabled: true,
897+ AuthType: domain.AuthTypeToken,
898+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
899+ }, tokenStore, zerowrap.Default())
900+
901+ ctx := testContext()
902+
903+ // Generate a short-lived token (1h) so the first extension produces a different JWT
904+ var capturedToken *domain.Token
905+ tokenStore.EXPECT().
906+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
907+ Run(func(_ context.Context, t *domain.Token, _ string) {
908+ capturedToken = t
909+ }).
910+ Return(nil)
911+
912+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
913+ require.NoError(t, err)
914+
915+ // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry
916+ tokenStore.EXPECT().
917+ GetToken(mock.Anything, "testuser").
918+ Return(tokenStr, capturedToken, nil).Once()
919+ tokenStore.EXPECT().
920+ IsRevoked(mock.Anything, capturedToken.ID).
921+ Return(false, nil).Once()
922+ tokenStore.EXPECT().
923+ GetToken(mock.Anything, "testuser").
924+ Return(tokenStr, capturedToken, nil).Once()
925+
926+ var extendedToken *domain.Token
927+ var extendedJWT string
928+ tokenStore.EXPECT().
929+ UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything).
930+ Run(func(_ context.Context, t *domain.Token, jwt string) {
931+ extendedToken = t
932+ extendedJWT = jwt
933+ }).
934+ Return(nil).Once()
935+
936+ newToken1, err := svc.ExtendToken(ctx, tokenStr)
937+ require.NoError(t, err)
938+ assert.NotEqual(t, tokenStr, newToken1, "first extend should produce a new token")
939+ assert.NotNil(t, extendedToken)
940+
941+ // Second extend immediately — debounce should kick in (LastExtendedAt just set to now)
942+ // Flow: ValidateToken(GetToken+IsRevoked) + GetToken(debounce) → debounced, no UpdateTokenExpiry
943+ tokenStore.EXPECT().
944+ GetToken(mock.Anything, "testuser").
945+ Return(extendedJWT, extendedToken, nil).Once()
946+ tokenStore.EXPECT().
947+ IsRevoked(mock.Anything, capturedToken.ID).
948+ Return(false, nil).Once()
949+ tokenStore.EXPECT().
950+ GetToken(mock.Anything, "testuser").
951+ Return(extendedJWT, extendedToken, nil).Once()
952+
953+ // No UpdateTokenExpiry should be called — debounced
954+ newToken2, err := svc.ExtendToken(ctx, newToken1)
955+ require.NoError(t, err)
956+
957+ assert.Equal(t, newToken1, newToken2, "expected debounce to return same token within 1h window")
958+}
959+
960+func TestExtendTokenSkipsEphemeral(t *testing.T) {
961+ svc := NewService(Config{
962+ Enabled: true,
963+ AuthType: domain.AuthTypeToken,
964+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
965+ }, nil, zerowrap.Default())
966+
967+ ctx := testContext()
968+
969+ // Generate an ephemeral access token (≤5min)
970+ tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute)
971+ require.NoError(t, err)
972+
973+ // ExtendToken should skip ephemeral tokens and return the same string
974+ result, err := svc.ExtendToken(ctx, tokenStr)
975+ require.NoError(t, err)
976+ assert.Equal(t, tokenStr, result, "ephemeral tokens should not be extended")
977+}
978+
979+func TestExtendTokenSkipsServiceToken(t *testing.T) {
980+ tokenStore := mocks.NewMockTokenStore(t)
981+
982+ svc := NewService(Config{
983+ Enabled: true,
984+ AuthType: domain.AuthTypeToken,
985+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
986+ }, tokenStore, zerowrap.Default())
987+
988+ ctx := testContext()
989+
990+ // Generate a service token — service tokens skip extension before store checks
991+ tokenStore.EXPECT().
992+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
993+ Return(nil)
994+
995+ const svcSubject = "gordon-service"
996+ tokenStr, err := svc.GenerateToken(ctx, svcSubject, []string{"pull"}, 24*time.Hour)
997+ require.NoError(t, err)
998+
999+ // ExtendToken should skip service tokens WITHOUT touching the store
1000+ result, err := svc.ExtendToken(ctx, tokenStr)
1001+ require.NoError(t, err)
1002+ assert.Equal(t, tokenStr, result, "service tokens should not be extended")
1003+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close