diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index f8a4f1c89..a7672cc53 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -14,6 +14,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/spf13/cobra" + larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" ) @@ -48,7 +49,7 @@ type userInfoResponse struct { func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, name string, err error) { apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{ HttpMethod: http.MethodGet, - ApiPath: "/open-apis/authen/v1/user_info", + ApiPath: larkauth.PathUserInfoV1, SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}, }, larkcore.WithUserAccessToken(accessToken)) if err != nil { @@ -109,7 +110,7 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{ HttpMethod: http.MethodGet, - ApiPath: "/open-apis/application/v6/applications/" + appId, + ApiPath: larkauth.ApplicationInfoPath(appId), QueryParams: queryParams, SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}, }) diff --git a/internal/auth/app_registration.go b/internal/auth/app_registration.go index 819863d61..2bdf50fba 100644 --- a/internal/auth/app_registration.go +++ b/internal/auth/app_registration.go @@ -47,7 +47,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu ep := core.ResolveEndpoints(brand) regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu - endpoint := regEp.Accounts + "/oauth/v1/app/registration" + endpoint := regEp.Accounts + PathAppRegistration form := url.Values{} form.Set("action", "begin") @@ -66,6 +66,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu return nil, err } defer resp.Body.Close() + logHTTPResponse(resp) body, err := io.ReadAll(resp.Body) if err != nil { @@ -129,7 +130,7 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor const maxPollAttempts = 200 ep := core.ResolveEndpoints(brand) - endpoint := ep.Accounts + "/oauth/v1/app/registration" + endpoint := ep.Accounts + PathAppRegistration deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) currentInterval := interval attempts := 0 @@ -162,6 +163,7 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor currentInterval = minInt(currentInterval+1, maxPollInterval) continue } + logHTTPResponse(resp) body, err := io.ReadAll(resp.Body) resp.Body.Close() diff --git a/internal/auth/app_registration_test.go b/internal/auth/app_registration_test.go index e706a8621..34466a48f 100644 --- a/internal/auth/app_registration_test.go +++ b/internal/auth/app_registration_test.go @@ -9,6 +9,7 @@ import ( "github.com/smartystreets/goconvey/convey" ) +// Test_BuildVerificationURL verifies that tracking parameters are correctly appended. func Test_BuildVerificationURL(t *testing.T) { t.Run("URL不含问号则添加?分隔符", func(t *testing.T) { result := BuildVerificationURL("https://example.com/verify", "1.0.0") diff --git a/internal/auth/auth_response_log.go b/internal/auth/auth_response_log.go new file mode 100644 index 000000000..0695027e5 --- /dev/null +++ b/internal/auth/auth_response_log.go @@ -0,0 +1,38 @@ +package auth + +import ( + "net/http" + + "github.com/larksuite/cli/internal/keychain" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// logHTTPResponse logs the HTTP response details for an authentication request. +// It extracts the request path, status code, and x-tt-logid from the given HTTP response. +func logHTTPResponse(resp *http.Response) { + if resp == nil { + return + } + + path := "missing" + if resp.Request != nil && resp.Request.URL != nil { + path = resp.Request.URL.Path + } + + keychain.LogAuthResponse(path, resp.StatusCode, resp.Header.Get("x-tt-logid")) +} + +// logSDKResponse logs the SDK response details for an authentication request. +// It extracts the status code and x-tt-logid from the given API response object. +func logSDKResponse(path string, apiResp *larkcore.ApiResp) { + if path == "" { + path = "missing" + } + + if apiResp == nil { + keychain.LogAuthResponse(path, 0, "") + return + } + + keychain.LogAuthResponse(path, apiResp.StatusCode, apiResp.Header.Get("x-tt-logid")) +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index c79aaa224..7d2c8ca41 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -54,8 +54,8 @@ type OAuthEndpoints struct { func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints { ep := core.ResolveEndpoints(brand) return OAuthEndpoints{ - DeviceAuthorization: ep.Accounts + "/oauth/v1/device_authorization", - Token: ep.Open + "/open-apis/authen/v2/oauth/token", + DeviceAuthorization: ep.Accounts + PathDeviceAuthorization, + Token: ep.Open + PathOAuthTokenV2, } } @@ -93,6 +93,7 @@ func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string return nil, err } defer resp.Body.Close() + logHTTPResponse(resp) body, err := io.ReadAll(resp.Body) if err != nil { @@ -179,6 +180,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec currentInterval = minInt(currentInterval+1, maxPollInterval) continue } + logHTTPResponse(resp) body, err := io.ReadAll(resp.Body) resp.Body.Close() @@ -258,6 +260,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec // helpers +// minInt returns the smaller of a or b. func minInt(a, b int) int { if a < b { return a @@ -265,6 +268,7 @@ func minInt(a, b int) int { return b } +// getStr retrieves a string value from a map, returning an empty string if not found or not a string. func getStr(m map[string]interface{}, key string) string { if v, ok := m[key]; ok { if s, ok := v.(string); ok { @@ -274,6 +278,7 @@ func getStr(m map[string]interface{}, key string) string { return "" } +// getInt retrieves an integer value from a map, returning a fallback value if not found or not a number. func getInt(m map[string]interface{}, key string, fallback int) int { if v, ok := m[key]; ok { switch n := v.(type) { diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 3cd5dad70..5493220af 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -4,11 +4,20 @@ package auth import ( + "bytes" + "fmt" + "log" + "net/http" + "strings" "testing" + "time" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/keychain" ) +// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand. func TestResolveOAuthEndpoints_Feishu(t *testing.T) { ep := ResolveOAuthEndpoints(core.BrandFeishu) if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" { @@ -19,6 +28,7 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) { } } +// TestResolveOAuthEndpoints_Lark validates endpoints for the Lark brand. func TestResolveOAuthEndpoints_Lark(t *testing.T) { ep := ResolveOAuthEndpoints(core.BrandLark) if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" { @@ -28,3 +38,137 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) { t.Errorf("Token = %q", ep.Token) } } + +// TestRequestDeviceAuthorization_LogsResponse checks if API responses are logged correctly. +func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) { + reg := &httpmock.Registry{} + t.Cleanup(func() { reg.Verify(t) }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: PathDeviceAuthorization, + Body: map[string]interface{}{ + "device_code": "device-code", + "user_code": "user-code", + "verification_uri": "https://example.com/verify", + "verification_uri_complete": "https://example.com/verify?code=123", + "expires_in": 240, + "interval": 5, + }, + Headers: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Tt-Logid": []string{"device-log-id"}, + }, + }) + + var buf bytes.Buffer + restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time { + return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC) + }, func() []string { + return []string{"lark-cli", "auth", "login", "--device-code", "device-code-secret", "--app-secret=top-secret"} + }) + t.Cleanup(restore) + + _, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil) + if err != nil { + t.Fatalf("RequestDeviceAuthorization() error: %v", err) + } + + got := buf.String() + if !strings.Contains(got, "time=2026-04-02T03:04:05Z") { + t.Fatalf("expected time in log, got %q", got) + } + if !strings.Contains(got, "path=missing") { + t.Fatalf("expected path in log, got %q", got) + } + if !strings.Contains(got, "status=200") { + t.Fatalf("expected status=200 in log, got %q", got) + } + if !strings.Contains(got, "x-tt-logid=device-log-id") { + t.Fatalf("expected x-tt-logid in log, got %q", got) + } + if !strings.Contains(got, "cmdline=lark-cli auth login ...") { + t.Fatalf("expected cmdline in log, got %q", got) + } +} + +// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated. +func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) { + got := keychain.FormatAuthCmdline([]string{ + "lark-cli", + "auth", + "login", + "--device-code", "device-code-secret", + "--app-secret=top-secret", + "--scope", "contact:read", + }) + + want := "lark-cli auth login ..." + if got != want { + t.Fatalf("formatAuthCmdline() = %q, want %q", got, want) + } +} + +// TestLogAuthResponse_IgnoresTypedNilHTTPResponse tests that a typed nil HTTP response is ignored gracefully. +func TestLogAuthResponse_IgnoresTypedNilHTTPResponse(t *testing.T) { + var buf bytes.Buffer + restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), nil, nil) + t.Cleanup(restore) + + var resp *http.Response + logHTTPResponse(resp) + + if got := buf.String(); got != "" { + t.Fatalf("expected no log output, got %q", got) + } +} + +// TestLogAuthResponse_HandlesNilSDKResponse verifies that a nil SDK response is handled without panicking. +func TestLogAuthResponse_HandlesNilSDKResponse(t *testing.T) { + var buf bytes.Buffer + restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time { + return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC) + }, func() []string { + return []string{"lark-cli", "auth", "status", "--verify"} + }) + t.Cleanup(restore) + + logSDKResponse(PathUserInfoV1, nil) + + got := buf.String() + if !strings.Contains(got, "path="+PathUserInfoV1) { + t.Fatalf("expected sdk path in log, got %q", got) + } + if !strings.Contains(got, "status=0") { + t.Fatalf("expected zero status in log, got %q", got) + } +} + +func TestLogAuthError_RecordsStructuredEntry(t *testing.T) { + var buf bytes.Buffer + restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time { + return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC) + }, func() []string { + return []string{"lark-cli", "auth", "login", "--device-code", "secret"} + }) + t.Cleanup(restore) + + keychain.LogAuthError("keychain", "Set", fmt.Errorf("keychain Set error: %w", http.ErrUseLastResponse)) + + got := buf.String() + if !strings.Contains(got, "auth-error") { + t.Fatalf("expected auth-error log entry, got %q", got) + } + if !strings.Contains(got, "component=keychain") { + t.Fatalf("expected component in log, got %q", got) + } + if !strings.Contains(got, "op=Set") { + t.Fatalf("expected op in log, got %q", got) + } + if !strings.Contains(got, "error=\"keychain Set error: net/http: use last response\"") { + t.Fatalf("expected quoted error in log, got %q", got) + } + if !strings.Contains(got, "cmdline=lark-cli auth login ...") { + t.Fatalf("expected truncated cmdline in log, got %q", got) + } +} diff --git a/internal/auth/errors.go b/internal/auth/errors.go index 76bd5995f..2a61eeab2 100644 --- a/internal/auth/errors.go +++ b/internal/auth/errors.go @@ -31,6 +31,7 @@ type NeedAuthorizationError struct { UserOpenId string } +// Error returns the error message for NeedAuthorizationError. func (e *NeedAuthorizationError) Error() string { return fmt.Sprintf("need_user_authorization (user: %s)", e.UserOpenId) } @@ -44,6 +45,7 @@ type SecurityPolicyError struct { Err error } +// Error returns the error message for SecurityPolicyError. func (e *SecurityPolicyError) Error() string { if e.Err != nil { return fmt.Sprintf("security policy error [%d]: %s: %v", e.Code, e.Message, e.Err) @@ -51,6 +53,7 @@ func (e *SecurityPolicyError) Error() string { return fmt.Sprintf("security policy error [%d]: %s", e.Code, e.Message) } +// Unwrap returns the underlying error. func (e *SecurityPolicyError) Unwrap() error { return e.Err } diff --git a/internal/auth/paths.go b/internal/auth/paths.go new file mode 100644 index 000000000..26441c640 --- /dev/null +++ b/internal/auth/paths.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +// Common authentication paths used for logging and API calls. +const ( + // PathDeviceAuthorization is the endpoint for device authorization. + PathDeviceAuthorization = "/oauth/v1/device_authorization" + // PathAppRegistration is the endpoint for application registration. + PathAppRegistration = "/oauth/v1/app/registration" + // PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2). + PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token" + // PathUserInfoV1 is the endpoint for fetching user information. + PathUserInfoV1 = "/open-apis/authen/v1/user_info" + // PathApplicationInfoV6Prefix is the prefix endpoint for fetching application info. + PathApplicationInfoV6Prefix = "/open-apis/application/v6/applications/" +) + +// ApplicationInfoPath returns the full API path for querying an application's information. +func ApplicationInfoPath(appId string) string { + return PathApplicationInfoV6Prefix + appId +} diff --git a/internal/auth/scope_test.go b/internal/auth/scope_test.go index b58d0b987..86f669f37 100644 --- a/internal/auth/scope_test.go +++ b/internal/auth/scope_test.go @@ -7,6 +7,7 @@ import ( "testing" ) +// TestMissingScopes tests the calculation of missing scopes. func TestMissingScopes(t *testing.T) { tests := []struct { name string @@ -62,6 +63,7 @@ func TestMissingScopes(t *testing.T) { } } +// sliceEqual compares two string slices for equality. func sliceEqual(a, b []string) bool { if len(a) == 0 && len(b) == 0 { return true diff --git a/internal/auth/token_store.go b/internal/auth/token_store.go index 7e52f6704..bbf4b9875 100644 --- a/internal/auth/token_store.go +++ b/internal/auth/token_store.go @@ -25,6 +25,7 @@ type StoredUAToken struct { const refreshAheadMs = 5 * 60 * 1000 // 5 minutes +// accountKey generates a unique key for an account based on its AppID and UserOpenID. func accountKey(appId, userOpenId string) string { return fmt.Sprintf("%s:%s", appId, userOpenId) } diff --git a/internal/auth/transport.go b/internal/auth/transport.go index 2a1670499..4864cd23e 100644 --- a/internal/auth/transport.go +++ b/internal/auth/transport.go @@ -19,6 +19,7 @@ type SecurityPolicyTransport struct { Base http.RoundTripper } +// base returns the underlying RoundTripper or http.DefaultTransport if nil. func (t *SecurityPolicyTransport) base() http.RoundTripper { if t.Base != nil { return t.Base @@ -82,6 +83,7 @@ func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, return resp, nil } +// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error response. func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error { // MCP (JSON-RPC) response format: // { @@ -130,6 +132,7 @@ func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interfa return nil } +// tryHandleOAPIResponse attempts to parse a standard Lark OpenAPI formatted error response. func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interface{}) error { // 1. Extract code code := getInt(result, "code", 0) @@ -180,6 +183,7 @@ func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interf return nil } +// isValidChallengeURL checks if the given URL is a valid challenge URL. func isValidChallengeURL(rawURL string) bool { if rawURL == "" { return false diff --git a/internal/auth/uat_client.go b/internal/auth/uat_client.go index 133c9c7e1..f04b61440 100644 --- a/internal/auth/uat_client.go +++ b/internal/auth/uat_client.go @@ -23,6 +23,7 @@ import ( var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) +// sanitizeID replaces empty IDs with "default" to prevent file path issues. func sanitizeID(id string) string { return safeIDChars.ReplaceAllString(id, "_") } @@ -98,6 +99,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId} } +// refreshWithLock acquires a file lock before attempting to refresh the token. func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) { key := fmt.Sprintf("%s:%s", opts.AppId, opts.UserOpenId) @@ -165,6 +167,7 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store return doRefreshToken(httpClient, opts, stored) } +// doRefreshToken performs the actual HTTP request to refresh the token. func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) { errOut := opts.ErrOut if errOut == nil { @@ -200,6 +203,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored return nil, err } defer resp.Body.Close() + logHTTPResponse(resp) body, err := io.ReadAll(resp.Body) if err != nil { diff --git a/internal/auth/uat_client_options_test.go b/internal/auth/uat_client_options_test.go index 2c7d26c16..d1f825773 100644 --- a/internal/auth/uat_client_options_test.go +++ b/internal/auth/uat_client_options_test.go @@ -10,6 +10,7 @@ import ( "github.com/larksuite/cli/internal/core" ) +// TestNewUATCallOptions validates the extraction of options from CLI config. func TestNewUATCallOptions(t *testing.T) { cfg := &core.CliConfig{ AppID: "app123", diff --git a/internal/auth/verify.go b/internal/auth/verify.go index c10c9f81b..403ad4481 100644 --- a/internal/auth/verify.go +++ b/internal/auth/verify.go @@ -18,12 +18,13 @@ import ( func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string) error { apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{ HttpMethod: http.MethodGet, - ApiPath: "/open-apis/authen/v1/user_info", + ApiPath: PathUserInfoV1, SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}, }, larkcore.WithUserAccessToken(accessToken)) if err != nil { return err } + logSDKResponse(PathUserInfoV1, apiResp) var resp struct { Code int `json:"code"` diff --git a/internal/auth/verify_test.go b/internal/auth/verify_test.go index 507d221f3..d513a66a3 100644 --- a/internal/auth/verify_test.go +++ b/internal/auth/verify_test.go @@ -4,16 +4,22 @@ package auth import ( + "bytes" "context" + "log" + "net/http" "strings" "testing" + "time" + "github.com/larksuite/cli/internal/keychain" lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/internal/httpmock" ) +// TestVerifyUserToken_TransportError verifies handling of underlying transport errors. func TestVerifyUserToken_TransportError(t *testing.T) { reg := &httpmock.Registry{} // Register no stubs — any request will fail with "no stub" error @@ -28,29 +34,34 @@ func TestVerifyUserToken_TransportError(t *testing.T) { } } +// TestVerifyUserToken validates normal and error response paths of the user token validation. func TestVerifyUserToken(t *testing.T) { tests := []struct { name string body interface{} wantErr bool errSubstr string + wantLog bool }{ { name: "success", body: map[string]interface{}{"code": 0, "msg": "ok"}, wantErr: false, + wantLog: true, }, { name: "token invalid", body: map[string]interface{}{"code": 99991668, "msg": "invalid token"}, wantErr: true, errSubstr: "[99991668]", + wantLog: true, }, { name: "non-JSON response", body: "not json", wantErr: true, errSubstr: "invalid character", + wantLog: false, }, } @@ -61,8 +72,12 @@ func TestVerifyUserToken(t *testing.T) { reg.Register(&httpmock.Stub{ Method: "GET", - URL: "/open-apis/authen/v1/user_info", + URL: PathUserInfoV1, Body: tt.body, + Headers: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Tt-Logid": []string{"verify-log-id"}, + }, }) sdk := lark.NewClient("test-app", "test-secret", @@ -70,6 +85,14 @@ func TestVerifyUserToken(t *testing.T) { lark.WithHttpClient(httpmock.NewClient(reg)), ) + var buf bytes.Buffer + restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time { + return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC) + }, func() []string { + return []string{"lark-cli", "auth", "status"} + }) + t.Cleanup(restore) + err := VerifyUserToken(context.Background(), sdk, "test-token") if tt.wantErr { if err == nil { @@ -83,6 +106,23 @@ func TestVerifyUserToken(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + got := buf.String() + if tt.wantLog { + if !strings.Contains(got, "path="+PathUserInfoV1) { + t.Fatalf("expected path in log, got %q", got) + } + if !strings.Contains(got, "status=200") { + t.Fatalf("expected status=200 in log, got %q", got) + } + if !strings.Contains(got, "x-tt-logid=verify-log-id") { + t.Fatalf("expected x-tt-logid in log, got %q", got) + } + if !strings.Contains(got, "cmdline=lark-cli auth status") { + t.Fatalf("expected cmdline in log, got %q", got) + } + } else if got != "" { + t.Fatalf("expected no log output, got %q", got) + } }) } } diff --git a/internal/keychain/auth_log.go b/internal/keychain/auth_log.go new file mode 100644 index 000000000..64558bd93 --- /dev/null +++ b/internal/keychain/auth_log.go @@ -0,0 +1,159 @@ +package keychain + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +var ( + authResponseLogger *log.Logger + authResponseLoggerOnce = &sync.Once{} + + authResponseLogNow = time.Now + authResponseLogArgs = func() []string { return os.Args } +) + +func authLogDir() string { + if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" { + return filepath.Join(dir, "logs") + } + + home, err := os.UserHomeDir() + if err != nil || home == "" { + fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) + } + + return filepath.Join(home, ".lark-cli", "logs") +} + +func initAuthLogger() { + authResponseLoggerOnce.Do(func() { + if authResponseLogger != nil { + return + } + + dir := authLogDir() + now := authResponseLogNow() + if err := os.MkdirAll(dir, 0700); err != nil { + return + } + + logName := fmt.Sprintf("auth-%s.log", now.Format("2006-01-02")) + logPath := filepath.Join(dir, logName) + if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600); err == nil { + authResponseLogger = log.New(f, "", 0) + cleanupOldLogs(dir, now) + } + }) +} + +func FormatAuthCmdline(args []string) string { + if len(args) == 0 { + return "" + } + + if len(args) <= 3 { + return strings.Join(args, " ") + } + + return strings.Join(args[:3], " ") + " ..." +} + +func LogAuthResponse(path string, status int, logID string) { + initAuthLogger() + if authResponseLogger == nil { + return + } + + authResponseLogger.Printf( + "[lark-cli] auth-response: time=%s path=%s status=%d x-tt-logid=%s cmdline=%s", + authResponseLogNow().Format(time.RFC3339Nano), + path, + status, + logID, + FormatAuthCmdline(authResponseLogArgs()), + ) +} + +func LogAuthError(component, op string, err error) { + if err == nil { + return + } + + initAuthLogger() + if authResponseLogger == nil { + return + } + + authResponseLogger.Printf( + "[lark-cli] auth-error: time=%s component=%s op=%s error=%q cmdline=%s", + authResponseLogNow().Format(time.RFC3339Nano), + component, + op, + err.Error(), + FormatAuthCmdline(authResponseLogArgs()), + ) +} + +func SetAuthLogHooksForTest(logger *log.Logger, now func() time.Time, args func() []string) func() { + prevLogger := authResponseLogger + prevNow := authResponseLogNow + prevArgs := authResponseLogArgs + prevOnce := authResponseLoggerOnce + + authResponseLogger = logger + authResponseLoggerOnce = &sync.Once{} + + if now != nil { + authResponseLogNow = now + } + if args != nil { + authResponseLogArgs = args + } + + return func() { + authResponseLogger = prevLogger + authResponseLogNow = prevNow + authResponseLogArgs = prevArgs + authResponseLoggerOnce = prevOnce + } +} + +func cleanupOldLogs(dir string, now time.Time) { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] background log cleanup panicked: %v\n", r) + } + }() + + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + cutoff := now.AddDate(0, 0, -7) + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), "auth-") || !strings.HasSuffix(entry.Name(), ".log") { + continue + } + + dateStr := strings.TrimPrefix(entry.Name(), "auth-") + dateStr = strings.TrimSuffix(dateStr, ".log") + + logDate, err := time.Parse("2006-01-02", dateStr) + if err != nil { + continue + } + + logDate = time.Date(logDate.Year(), logDate.Month(), logDate.Day(), 0, 0, 0, 0, now.Location()) + if logDate.Before(cutoff) { + _ = os.Remove(filepath.Join(dir, entry.Name())) + } + } +} diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index a5dc74b57..187184593 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -12,7 +12,13 @@ import ( "github.com/larksuite/cli/internal/output" ) -var errNotInitialized = errors.New("keychain not initialized") +var ( + // ErrNotFound is returned when the requested credential is not found. + ErrNotFound = errors.New("keychain: item not found") + + // errNotInitialized is an internal error indicating the master key is missing or invalid. + errNotInitialized = errors.New("keychain not initialized") +) const ( // LarkCliService is the unified keychain service name for all secrets @@ -25,9 +31,10 @@ const ( // wrapError is a helper to wrap underlying errors into output.ExitError. // It formats the error message and provides a hint for troubleshooting keychain access issues. func wrapError(op string, err error) error { - if err == nil { - return nil + if err == nil || errors.Is(err, ErrNotFound) { + return err } + msg := fmt.Sprintf("keychain %s failed: %v", op, err) hint := "Check if the OS keychain/credential manager is locked or accessible. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain." @@ -35,6 +42,11 @@ func wrapError(op string, err error) error { hint = "The keychain master key may have been cleaned up or deleted. Please reconfigure the CLI by running `lark-cli config init`." } + func() { + defer func() { recover() }() + LogAuthError("keychain", op, fmt.Errorf("keychain %s error: %w", op, err)) + }() + return output.ErrWithHint(output.ExitAPI, "config", msg, hint) }