-
-
Notifications
You must be signed in to change notification settings - Fork 294
fix: invalidate auth cache when rotating user API key #883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fa78847
50b3311
d77b99c
a3889cd
5e0f801
2b18dbe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,8 +65,13 @@ func (repository *gormUserRepository) RotateAPIKey(ctx context.Context, userID e | |
| } | ||
|
|
||
| user := new(entities.User) | ||
| var oldAPIKey string | ||
| err = crdbgorm.ExecuteTx(ctx, repository.db, nil, | ||
| func(tx *gorm.DB) error { | ||
| if err := tx.WithContext(ctx).Where("id = ?", userID).First(user).Error; err != nil { | ||
| return err | ||
| } | ||
| oldAPIKey = user.APIKey | ||
| return tx.WithContext(ctx).Model(user). | ||
| Clauses(clause.Returning{}). | ||
| Where("id = ?", userID). | ||
|
|
@@ -78,6 +83,13 @@ func (repository *gormUserRepository) RotateAPIKey(ctx context.Context, userID e | |
| return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) | ||
| } | ||
|
|
||
| if err == nil && oldAPIKey != "" { | ||
| // Flush pending ristretto Set operations before Del to avoid a | ||
| // buffered Set re-adding the entry after removal. | ||
| repository.cache.Wait() | ||
| repository.cache.Del(oldAPIKey) | ||
| } | ||
|
|
||
| return user, nil | ||
|
Comment on lines
+86
to
93
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — this is actually pre-existing behavior from the original code (the error handling was unchanged). However, it is worth noting that |
||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import ( | |
| "bytes" | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "strings" | ||
|
|
@@ -213,6 +214,90 @@ func TestSendSMS_RateLimit(t *testing.T) { | |
| } | ||
| } | ||
|
|
||
| func TestRotateAPIKey_InvalidatesCache(t *testing.T) { | ||
| ctx := context.Background() | ||
|
|
||
| // Use a dedicated test user so we don't mutate the shared userAPIKey | ||
| rotateUserAPIKey := "rotate-test-api-key" | ||
| rotateUserID := "rotate-test-user-id" | ||
|
|
||
| // 1) Confirm the dedicated user's API key works and warm the cache | ||
| meURL := apiBaseURL + "/v1/users/me" | ||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil) | ||
| require.NoError(t, err) | ||
| req.Header.Set("x-api-key", rotateUserAPIKey) | ||
|
|
||
|
Comment on lines
+217
to
+229
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 50b3311 — the test now uses a dedicated |
||
| resp, err := http.DefaultClient.Do(req) | ||
| require.NoError(t, err) | ||
| defer resp.Body.Close() | ||
|
|
||
| body, err := io.ReadAll(resp.Body) | ||
| require.NoError(t, err) | ||
| require.Equal(t, http.StatusOK, resp.StatusCode, "initial auth failed: %s", string(body)) | ||
|
|
||
| // Parse the current API key from the response | ||
| var meResp struct { | ||
| Data struct { | ||
| ID string `json:"id"` | ||
| APIKey string `json:"api_key"` | ||
| } `json:"data"` | ||
| } | ||
| require.NoError(t, json.Unmarshal(body, &meResp)) | ||
| require.Equal(t, rotateUserID, meResp.Data.ID) | ||
| oldAPIKey := meResp.Data.APIKey | ||
| require.NotEmpty(t, oldAPIKey) | ||
| t.Logf("user ID: %s, old API key prefix: %s...", rotateUserID, oldAPIKey[:10]) | ||
|
|
||
| // 2) Rotate the API key | ||
| rotateURL := fmt.Sprintf("%s/v1/users/%s/api-keys", apiBaseURL, rotateUserID) | ||
| req, err = http.NewRequestWithContext(ctx, http.MethodDelete, rotateURL, nil) | ||
| require.NoError(t, err) | ||
| req.Header.Set("x-api-key", rotateUserAPIKey) | ||
|
|
||
| resp, err = http.DefaultClient.Do(req) | ||
| require.NoError(t, err) | ||
| defer resp.Body.Close() | ||
|
|
||
| body, err = io.ReadAll(resp.Body) | ||
| require.NoError(t, err) | ||
| require.Equal(t, http.StatusOK, resp.StatusCode, "rotate failed: %s", string(body)) | ||
|
|
||
| // Parse new API key from rotate response | ||
| var rotateResp struct { | ||
| Data struct { | ||
| APIKey string `json:"api_key"` | ||
| } `json:"data"` | ||
| } | ||
| require.NoError(t, json.Unmarshal(body, &rotateResp)) | ||
| newAPIKey := rotateResp.Data.APIKey | ||
| require.NotEmpty(t, newAPIKey) | ||
| require.NotEqual(t, oldAPIKey, newAPIKey, "API key should have changed after rotation") | ||
| t.Logf("new API key prefix: %s...", newAPIKey[:10]) | ||
|
|
||
| // 3) Old API key should immediately fail (401) — this is the bug regression check | ||
| req, err = http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil) | ||
| require.NoError(t, err) | ||
| req.Header.Set("x-api-key", oldAPIKey) | ||
|
|
||
| resp, err = http.DefaultClient.Do(req) | ||
| require.NoError(t, err) | ||
| defer resp.Body.Close() | ||
| assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "old API key should return 401 after rotation") | ||
|
|
||
| // 4) New API key should work | ||
| req, err = http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil) | ||
| require.NoError(t, err) | ||
| req.Header.Set("x-api-key", newAPIKey) | ||
|
|
||
| resp, err = http.DefaultClient.Do(req) | ||
| require.NoError(t, err) | ||
| defer resp.Body.Close() | ||
|
|
||
| body, err = io.ReadAll(resp.Body) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, http.StatusOK, resp.StatusCode, "new API key should work: %s", string(body)) | ||
| } | ||
|
|
||
| func TestSendSMS_OutstandingFlow(t *testing.T) { | ||
| ctx := context.Background() | ||
| phone := setupPhone(ctx, t, 60) | ||
|
Comment on lines
214
to
303
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The test needs to either (a) restore the original key at the end via
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 50b3311 — added a dedicated |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
repository.cacheis a ristretto in-process*ristretto.Cache, socache.Del(oldAPIKey)only removes the entry from the cache of the single API server instance that handled the rotate request. In a horizontally-scaled deployment with multiple replicas, every other instance will continue to serve the old key as valid for up to the 2-hour TTL. The fix completely resolves the issue on a single-instance setup, but is only a partial mitigation when running more than one replica.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Acknowledged — this is an inherent limitation of in-process ristretto caches in multi-instance deployments and is pre-existing behavior (same applies to the phone API key cache). The issue specifically targets single-instance self-hosted setups where this fix fully resolves the problem. A distributed cache invalidation mechanism would be a separate enhancement.