From 25c4fbe089b97d15cd198e3497bfa3a8e05f1d52 Mon Sep 17 00:00:00 2001 From: m1saka Date: Wed, 22 Jul 2026 16:27:24 +0800 Subject: [PATCH] fix: align in-memory authentication semantics --- client.go | 10 +++++-- memory_auth_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/client.go b/client.go index 72c97cc..39bb866 100644 --- a/client.go +++ b/client.go @@ -106,6 +106,9 @@ func NewClientWithAuthData(authData AuthData, options ...ClientOption) (*Client, if err != nil { return nil, err } + if !authData.zero() { + authData = client.authDataWithIdentity(authData) + } client.setAuthData(authData) return client, nil } @@ -131,8 +134,11 @@ func newClient(options ...ClientOption) (*Client, error) { return client, nil } -// WithAuthDataChanged configures serialized persistence for in-memory auth updates. -// The callback runs under login serialization, but never while the auth data lock is held. +// WithAuthDataChanged configures synchronous persistence for in-memory auth updates. +// The callback is serialized by the client's login lock and may acquire unrelated +// application locks, but it must not call Client methods other than AuthData. +// In particular, calling Login or another method that may refresh authentication +// will deadlock. Returning an error leaves the client's authentication unchanged. func WithAuthDataChanged(callback func(AuthData) error) ClientOption { return func(client *Client) error { if callback == nil { diff --git a/memory_auth_test.go b/memory_auth_test.go index 3a4b36f..3978178 100644 --- a/memory_auth_test.go +++ b/memory_auth_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "reflect" "strings" + "sync" "testing" ) @@ -47,6 +48,31 @@ func TestNewClientWithAuthDataRejectsPartialData(t *testing.T) { } } +func TestNewClientWithAuthDataGeneratesMissingIdentityWithoutMutatingInput(t *testing.T) { + input := completeAuthData() + input.DeviceID = "" + input.PassO = "" + + client, err := NewClientWithAuthData(input) + if err != nil { + t.Fatal(err) + } + if input.DeviceID != "" || input.PassO != "" { + t.Fatalf("caller auth data mutated: %#v", input) + } + stored := client.AuthData() + if stored.DeviceID == "" || stored.PassO == "" { + t.Fatalf("stored identity not generated: %#v", stored) + } +} + +func TestWithAuthDataChangedRejectsNil(t *testing.T) { + _, err := NewClientWithAuthData(AuthData{}, WithAuthDataChanged(nil)) + if err == nil || !strings.Contains(err.Error(), "must not be nil") { + t.Fatalf("error = %v, want nil callback error", err) + } +} + func TestNewClientWithAuthDataDoesNotAccessFilesystem(t *testing.T) { home := filepath.Join(t.TempDir(), "must-not-exist") t.Setenv("HOME", home) @@ -110,6 +136,43 @@ func TestMemoryRefreshCallbackCanReadCurrentAuth(t *testing.T) { } } +func TestMemoryRefreshCallbackCanAcquireApplicationLock(t *testing.T) { + var persistenceMu sync.Mutex + client, _, server := newMemoryRefreshClient(t, func(AuthData) error { + persistenceMu.Lock() + defer persistenceMu.Unlock() + return nil + }) + defer server.Close() + + if err := client.refreshToken(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestMemoryRefreshPersistsGeneratedIdentity(t *testing.T) { + initial := completeAuthData() + initial.DeviceID = "" + initial.PassO = "" + var changed AuthData + client, err := NewClientWithAuthData(initial, WithAuthDataChanged(func(authData AuthData) error { + changed = authData.clone() + return nil + })) + if err != nil { + t.Fatal(err) + } + server := configureRefreshServer(t, client) + defer server.Close() + + if err := client.refreshToken(context.Background()); err != nil { + t.Fatal(err) + } + if changed.DeviceID == "" || changed.PassO == "" { + t.Fatalf("persisted identity not generated: %#v", changed) + } +} + func TestFreshMemoryClientQRLoginPersistsThroughCallback(t *testing.T) { var changed AuthData var server *httptest.Server