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 ( | |||
| 21 | 21 | ||
| 22 | 22 | // Client is an HTTP client for the Gordon admin API. | |
| 23 | 23 | 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 | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 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 | 97 | func (c *Client) applyTLSConfig() { | |
| 88 | 98 | if !c.insecureTLS { | |
| 89 | 99 | return | |
| @@ -140,7 +150,21 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h | |||
| 140 | 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 | 170 | // 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 string | 24 | + | baseURL string |
| 25 | - | token string | 25 | + | token string |
| 26 | - | httpClient *http.Client | 26 | + | httpClient *http.Client |
| 27 | - | insecureTLS bool | 27 | + | 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 | return | 99 | 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 { | |||
| 138 | 138 | // ResolveRemote resolves the remote URL and token from configuration. | |
| 139 | 139 | // Precedence: flag > env > config > active remote | |
| 140 | 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 | 149 | config, _ := LoadClientConfig("") | |
| 142 | 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 | 154 | if isRemote { | |
| 146 | 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, isRemote | |
| 160 | + | return url, token, insecureTLS, remoteName, isRemote | |
| 151 | 161 | } | |
| 152 | 162 | ||
| 153 | 163 | // 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 remote | 139 | // 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, isRemote | 160 | + | 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: | |||
| 144 | 144 | // GetRemoteClient returns a remote client if targeting a remote instance, | |
| 145 | 145 | // or nil if running locally. | |
| 146 | 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 | 148 | if !isRemote { | |
| 149 | 149 | 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 | 164 | return client, true | |
| 154 | 165 | } | |
| 155 | 166 | ||
| @@ -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, false | 149 | 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, true | 164 | return client, true | ||
| 154 | } | 165 | } | ||
| 155 | 166 | ||||
M internal/adapters/in/http/admin/middleware.go +6 -0
| @@ -106,6 +106,12 @@ func AuthMiddleware( | |||
| 106 | 106 | ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject) | |
| 107 | 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 | 115 | next.ServeHTTP(w, r.WithContext(ctx)) | |
| 110 | 116 | }) | |
| 111 | 117 | } | |
| @@ -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) { | |||
| 83 | 83 | Subject: "admin", | |
| 84 | 84 | Scopes: []string{"admin:*:*"}, | |
| 85 | 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 | 89 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 88 | 90 | w.WriteHeader(http.StatusOK) | |
| @@ -110,6 +112,8 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |||
| 110 | 112 | Subject: "admin", | |
| 111 | 113 | Scopes: []string{"admin:*:*"}, | |
| 112 | 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 | 118 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 115 | 119 | w.WriteHeader(http.StatusOK) | |
| @@ -247,6 +251,8 @@ func TestAuthMiddleware_Success(t *testing.T) { | |||
| 247 | 251 | Subject: "admin", | |
| 248 | 252 | Scopes: []string{"admin:*:*"}, | |
| 249 | 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 | 257 | var capturedCtx context.Context | |
| 252 | 258 | 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.Context | 257 | 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 | |||
| 170 | 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 | 177 | // Tests for checkScopeAccess function | |
| 174 | 178 | ||
| 175 | 179 | 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 function | 177 | // 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 { | |||
| 79 | 79 | ||
| 80 | 80 | // tokenMetadata holds the non-sensitive token information. | |
| 81 | 81 | 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"` | |
| 88 | 89 | } | |
| 89 | 90 | ||
| 90 | 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 | 106 | // Store metadata | |
| 106 | 107 | 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, | |
| 113 | 115 | } | |
| 114 | 116 | metaJSON, err := json.Marshal(meta) | |
| 115 | 117 | if err != nil { | |
| @@ -173,12 +175,13 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma | |||
| 173 | 175 | } | |
| 174 | 176 | ||
| 175 | 177 | 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, | |
| 182 | 185 | } | |
| 183 | 186 | ||
| 184 | 187 | // Cache the token | |
| @@ -321,6 +324,12 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) | |||
| 321 | 324 | 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 | 333 | // DeleteToken removes token from pass. | |
| 325 | 334 | func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |
| 326 | 335 | 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 metadata | 106 | // 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 token | 187 | // Cache the token | ||
| @@ -321,6 +324,12 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) | |||||
| 321 | return revoked, nil | 324 | 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 | |||
| 73 | 73 | data := unsafeTokenData{ | |
| 74 | 74 | JWT: jwt, | |
| 75 | 75 | 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, | |
| 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 | 127 | 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, | |
| 133 | 135 | } | |
| 134 | 136 | ||
| 135 | 137 | return data.JWT, token, nil | |
| @@ -168,12 +170,13 @@ func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) { | |||
| 168 | 170 | } | |
| 169 | 171 | ||
| 170 | 172 | 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, | |
| 177 | 180 | } | |
| 178 | 181 | ||
| 179 | 182 | tokens = append(tokens, token) | |
| @@ -240,6 +243,12 @@ func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) | |||
| 240 | 243 | 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 | 252 | // DeleteToken removes token file. | |
| 244 | 253 | func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error { | |
| 245 | 254 | // 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 metadata | 77 | + | 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, nil | 137 | 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, nil | 243 | 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 traversal | 254 | // SECURITY: Use sanitized filename to prevent path traversal | ||
M internal/boundaries/in/auth.go +5 -0
| @@ -46,4 +46,9 @@ type AuthService interface { | |||
| 46 | 46 | // GetAuthStatus returns authentication status from context. | |
| 47 | 47 | // Extracts and validates token claims that were set by auth middleware. | |
| 48 | 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 | } | |
| @@ -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. | |||
| 714 | 714 | _c.Call.Return(run) | |
| 715 | 715 | 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 | + | } | |
| @@ -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 _c | 715 | 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, | |||
| 354 | 354 | 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 | 416 | // SaveToken provides a mock function for the type MockTokenStore | |
| 358 | 417 | func (_mock *MockTokenStore) SaveToken(ctx context.Context, token *domain.Token, jwt string) error { | |
| 359 | 418 | ret := _mock.Called(ctx, token, jwt) | |
| @@ -354,6 +354,65 @@ func (_c *MockTokenStore_Revoke_Call) RunAndReturn(run func(ctx context.Context, | |||||
| 354 | return _c | 354 | 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 MockTokenStore | 416 | // 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 { | |||
| 26 | 26 | ||
| 27 | 27 | // DeleteToken removes token from store. | |
| 28 | 28 | 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 | } | |
| @@ -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) error | 28 | 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 ( | |||
| 20 | 20 | ||
| 21 | 21 | // Token represents a generated authentication token stored in the secrets backend. | |
| 22 | 22 | 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 | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | // 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 string | 23 | + | ID string |
| 24 | - | Subject string | 24 | + | Subject string |
| 25 | - | Scopes []string | 25 | + | Scopes []string |
| 26 | - | IssuedAt time.Time | 26 | + | IssuedAt time.Time |
| 27 | - | ExpiresAt time.Time // Zero value means never expires | 27 | + | ExpiresAt time.Time // Zero value means never expires |
| 28 | - | Revoked bool | 28 | + | 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 ( | |||
| 27 | 27 | MaxAccessTokenLifetime = 5 * time.Minute | |
| 28 | 28 | // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons. | |
| 29 | 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 | 39 | // Config holds the authentication configuration. | |
| @@ -433,6 +440,101 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) { | |||
| 433 | 440 | 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 | 538 | // GeneratePasswordHash generates a bcrypt hash for a password. | |
| 437 | 539 | func (s *Service) GeneratePasswordHash(password string) (string, error) { | |
| 438 | 540 | hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost) | |
| @@ -27,6 +27,13 @@ const ( | |||||
| 27 | MaxAccessTokenLifetime = 5 * time.Minute | 27 | 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, nil | 440 | 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) { | |||
| 816 | 816 | ||
| 817 | 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 | + | } | |
| @@ -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 | + | } | |||