Unified · Split
| @@ -8,7 +8,6 @@ import ( · 7 unmodified lines | |||
| 8 | "net/http" | 8 | "net/http" |
| 9 | "os" | 9 | "os" |
| 10 | "path/filepath" | 10 | "path/filepath" |
| 11 | "reflect" | ||
| 12 | "strings" | 11 | "strings" |
| 13 | "sync" | 12 | "sync" |
| 14 | "time" | 13 | "time" |
| @@ -76,7 +75,7 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str · 61 unmodified lines | |||
| 76 | if existing, errRead := os.ReadFile(path); errRead == nil { | 75 | if existing, errRead := os.ReadFile(path); errRead == nil { |
| 77 | // Use metadataEqualIgnoringTimestamps to skip writes when only timestamp fields change. | 76 | // Use metadataEqualIgnoringTimestamps to skip writes when only timestamp fields change. |
| 78 | // This prevents the token refresh loop caused by timestamp/expired/expires_in changes. | 77 | // This prevents the token refresh loop caused by timestamp/expired/expires_in changes. |
| 79 | if metadataEqualIgnoringTimestamps(existing, raw) { | 78 | if metadataEqualIgnoringTimestamps(existing, raw, auth.Provider) { |
| 80 | return path, nil | 79 | return path, nil |
| 81 | } | 80 | } |
| 82 | file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) | 81 | file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) |
| @@ -300,28 +299,101 @@ func (s *FileTokenStore) baseDirSnapshot() string { · 217 unmodified lines | |||
| 300 | return s.baseDir | 299 | return s.baseDir |
| 301 | } | 300 | } |
| 302 | 301 | ||
| 303 | // metadataEqualIgnoringTimestamps compares two metadata JSON blobs, ignoring volatile fields that | 302 | // DEPRECATED: Use metadataEqualIgnoringTimestamps for comparing auth metadata. |
| 304 | // change on every refresh but don't affect authentication logic. | 303 | // This function is kept for backward compatibility but can cause refresh loops. |
| 305 | func metadataEqualIgnoringTimestamps(a, b []byte) bool { | 304 | func jsonEqual(a, b []byte) bool { |
| 306 | var objA map[string]any | 305 | var objA any |
| 307 | var objB map[string]any | 306 | var objB any |
| 308 | if errUnmarshalA := json.Unmarshal(a, &objA); errUnmarshalA != nil { | 307 | if err := json.Unmarshal(a, &objA); err != nil { |
| 309 | return false | 308 | return false |
| 310 | } | 309 | } |
| 311 | if errUnmarshalB := json.Unmarshal(b, &objB); errUnmarshalB != nil { | 310 | if err := json.Unmarshal(b, &objB); err != nil { |
| 312 | return false | 311 | return false |
| 313 | } | 312 | } |
| 314 | stripVolatileMetadataFields(objA) | 313 | return deepEqualJSON(objA, objB) |
| 315 | stripVolatileMetadataFields(objB) | ||
| 316 | return reflect.DeepEqual(objA, objB) | ||
| 317 | } | 314 | } |
| 318 | 315 | ||
| 319 | func stripVolatileMetadataFields(metadata map[string]any) { | 316 | // metadataEqualIgnoringTimestamps compares two metadata JSON blobs, |
| 320 | if metadata == nil { | 317 | // ignoring fields that change on every refresh but don't affect functionality. |
| 321 | return | 318 | // This prevents unnecessary file writes that would trigger watcher events and |
| 319 | // create refresh loops. | ||
| 320 | // The provider parameter controls whether access_token is ignored: providers like | ||
| 321 | // Google OAuth (gemini, gemini-cli) can re-fetch tokens when needed, while others | ||
| 322 | // like iFlow require the refreshed token to be persisted. | ||
| 323 | func metadataEqualIgnoringTimestamps(a, b []byte, provider string) bool { | ||
| 324 | var objA, objB map[string]any | ||
| 325 | if err := json.Unmarshal(a, &objA); err != nil { | ||
| 326 | return false | ||
| 327 | } | ||
| 328 | if err := json.Unmarshal(b, &objB); err != nil { | ||
| 329 | return false | ||
| 330 | } | ||
| 331 | |||
| 332 | // Fields to ignore: these change on every refresh but don't affect authentication logic. | ||
| 333 | // - timestamp, expired, expires_in, last_refresh: time-related fields that change on refresh | ||
| 334 | ignoredFields := []string{"timestamp", "expired", "expires_in", "last_refresh"} | ||
| 335 | |||
| 336 | // For providers that can re-fetch tokens when needed (e.g., Google OAuth), | ||
| 337 | // we ignore access_token to avoid unnecessary file writes. | ||
| 338 | switch provider { | ||
| 339 | case "gemini", "gemini-cli": | ||
| 340 | ignoredFields = append(ignoredFields, "access_token") | ||
| 341 | } | ||
| 342 | |||
| 343 | for _, field := range ignoredFields { | ||
| 344 | delete(objA, field) | ||
| 345 | delete(objB, field) | ||
| 322 | } | 346 | } |
| 323 | // These fields change on refresh and would otherwise trigger watcher reload loops. | 347 | |
| 324 | for _, field := range []string{"timestamp", "expired", "expires_in", "last_refresh", "access_token"} { | 348 | return deepEqualJSON(objA, objB) |
| 325 | delete(metadata, field) | 349 | } |
| 350 | |||
| 351 | func deepEqualJSON(a, b any) bool { | ||
| 352 | switch valA := a.(type) { | ||
| 353 | case map[string]any: | ||
| 354 | valB, ok := b.(map[string]any) | ||
| 355 | if !ok || len(valA) != len(valB) { | ||
| 356 | return false | ||
| 357 | } | ||
| 358 | for key, subA := range valA { | ||
| 359 | subB, ok1 := valB[key] | ||
| 360 | if !ok1 || !deepEqualJSON(subA, subB) { | ||
| 361 | return false | ||
| 362 | } | ||
| 363 | } | ||
| 364 | return true | ||
| 365 | case []any: | ||
| 366 | sliceB, ok := b.([]any) | ||
| 367 | if !ok || len(valA) != len(sliceB) { | ||
| 368 | return false | ||
| 369 | } | ||
| 370 | for i := range valA { | ||
| 371 | if !deepEqualJSON(valA[i], sliceB[i]) { | ||
| 372 | return false | ||
| 373 | } | ||
| 374 | } | ||
| 375 | return true | ||
| 376 | case float64: | ||
| 377 | valB, ok := b.(float64) | ||
| 378 | if !ok { | ||
| 379 | return false | ||
| 380 | } | ||
| 381 | return valA == valB | ||
| 382 | case string: | ||
| 383 | valB, ok := b.(string) | ||
| 384 | if !ok { | ||
| 385 | return false | ||
| 386 | } | ||
| 387 | return valA == valB | ||
| 388 | case bool: | ||
| 389 | valB, ok := b.(bool) | ||
| 390 | if !ok { | ||
| 391 | return false | ||
| 392 | } | ||
| 393 | return valA == valB | ||
| 394 | case nil: | ||
| 395 | return b == nil | ||
| 396 | default: | ||
| 397 | return false | ||
| 326 | } | 398 | } |
| 327 | } | 399 | } |