Unified · Split
| @@ -8,7 +8,6 @@ import ( · 7 unmodified lines | ||
| 8 | 8 | "net/http" |
| 9 | 9 | "os" |
| 10 | 10 | "path/filepath" |
| 11 | − "reflect" | |
| 12 | 11 | "strings" |
| 13 | 12 | "sync" |
| 14 | 13 | "time" |
| @@ -76,7 +75,7 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str · 61 unmodified lines | ||
| 76 | 75 | if existing, errRead := os.ReadFile(path); errRead == nil { |
| 77 | 76 | // Use metadataEqualIgnoringTimestamps to skip writes when only timestamp fields change. |
| 78 | 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 | 79 | return path, nil |
| 81 | 80 | } |
| 82 | 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 | 299 | return s.baseDir |
| 301 | 300 | } |
| 302 | 301 | |
| 303 | −// metadataEqualIgnoringTimestamps compares two metadata JSON blobs, ignoring volatile fields that | |
| 304 | −// change on every refresh but don't affect authentication logic. | |
| 305 | −func metadataEqualIgnoringTimestamps(a, b []byte) bool { | |
| 306 | − var objA map[string]any | |
| 307 | − var objB map[string]any | |
| 308 | − if errUnmarshalA := json.Unmarshal(a, &objA); errUnmarshalA != nil { | |
| 302 | +// DEPRECATED: Use metadataEqualIgnoringTimestamps for comparing auth metadata. | |
| 303 | +// This function is kept for backward compatibility but can cause refresh loops. | |
| 304 | +func jsonEqual(a, b []byte) bool { | |
| 305 | + var objA any | |
| 306 | + var objB any | |
| 307 | + if err := json.Unmarshal(a, &objA); err != nil { | |
| 309 | 308 | return false |
| 310 | 309 | } |
| 311 | − if errUnmarshalB := json.Unmarshal(b, &objB); errUnmarshalB != nil { | |
| 310 | + if err := json.Unmarshal(b, &objB); err != nil { | |
| 312 | 311 | return false |
| 313 | 312 | } |
| 314 | − stripVolatileMetadataFields(objA) | |
| 315 | − stripVolatileMetadataFields(objB) | |
| 316 | − return reflect.DeepEqual(objA, objB) | |
| 313 | + return deepEqualJSON(objA, objB) | |
| 317 | 314 | } |
| 318 | 315 | |
| 319 | −func stripVolatileMetadataFields(metadata map[string]any) { | |
| 320 | − if metadata == nil { | |
| 321 | − return | |
| 316 | +// metadataEqualIgnoringTimestamps compares two metadata JSON blobs, | |
| 317 | +// ignoring fields that change on every refresh but don't affect functionality. | |
| 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. | |
| 324 | − for _, field := range []string{"timestamp", "expired", "expires_in", "last_refresh", "access_token"} { | |
| 325 | − delete(metadata, field) | |
| 347 | + | |
| 348 | + return deepEqualJSON(objA, objB) | |
| 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 | } |