Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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},
})
Expand Down
6 changes: 4 additions & 2 deletions internal/auth/app_registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions internal/auth/app_registration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
38 changes: 38 additions & 0 deletions internal/auth/auth_response_log.go
Original file line number Diff line number Diff line change
@@ -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"))
}
9 changes: 7 additions & 2 deletions internal/auth/device_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -258,13 +260,15 @@ 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
}
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 {
Expand All @@ -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) {
Expand Down
144 changes: 144 additions & 0 deletions internal/auth/device_flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -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" {
Expand All @@ -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)
}
Comment thread
JackZhao10086 marked this conversation as resolved.
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)
}
}
3 changes: 3 additions & 0 deletions internal/auth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -44,13 +45,15 @@ 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)
}
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
}
23 changes: 23 additions & 0 deletions internal/auth/paths.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions internal/auth/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"
)

// TestMissingScopes tests the calculation of missing scopes.
func TestMissingScopes(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/auth/token_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading