Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions cmd/config/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
func configInitRun(opts *ConfigInitOptions) error {
f := opts.Factory

keychain.SuppressKeychainReadErrorTracking(true)
defer keychain.SuppressKeychainReadErrorTracking(false)

// Read secret from stdin if --app-secret-stdin is set
if opts.AppSecretStdin {
scanner := bufio.NewScanner(f.IOStreams.In)
Expand Down
99 changes: 99 additions & 0 deletions cmd/error_auth_hint.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@
package cmd

import (
"errors"
"fmt"
"strings"
"time"

internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/tracking"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
Expand All @@ -20,10 +24,14 @@
// enrichMissingScopeError preserves the original need_user_authorization
// message and appends a scope hint when the current command declares the
// required scopes locally.
// It also logs the auth failure reason using tracking.LogAuthError.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
}

logAuthFailureReason(exitErr)

if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}
Expand All @@ -41,6 +49,97 @@
exitErr.Detail.Hint += "\n" + scopeHint
}

// logSecurityPolicyError logs a security policy error using tracking.LogAuthError.
func logSecurityPolicyError(spErr *internalauth.SecurityPolicyError) {
codeStr := securityPolicyCodeString(spErr.Code)
errMsg := fmt.Sprintf("reason=security_policy code=%s message=%q", codeStr, spErr.Message)
tracking.LogAuthError(tracking.AuthComponentAuth, tracking.AuthOpSecurityPolicy, fmt.Errorf(errMsg))

Check warning on line 56 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L53-L56

Added lines #L53 - L56 were not covered by tests
}

// logRawAuthFailure logs auth-related failures for Raw errors (e.g. from `api` command).
// This preserves the original API error detail while still logging auth failures.
func logRawAuthFailure(exitErr *output.ExitError) {
if exitErr.Detail == nil {
return

Check warning on line 63 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L63

Added line #L63 was not covered by tests
}

if exitErr.Detail.Type == "permission" {
errMsg := fmt.Sprintf("reason=permission_denied code=%d message=%q", exitErr.Detail.Code, exitErr.Detail.Message)
tracking.LogAuthError(tracking.AuthComponentAuth, tracking.AuthOpPermissionDenied, fmt.Errorf(errMsg))
return
}

if exitErr.Detail.Type == "auth" {
errMsg := fmt.Sprintf("reason=auth_error message=%q", exitErr.Detail.Message)
tracking.LogAuthError(tracking.AuthComponentAuth, tracking.AuthOpAuthError, fmt.Errorf(errMsg))

Check warning on line 74 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L73-L74

Added lines #L73 - L74 were not covered by tests
}
}

// logAuthFailureReason extracts authorization-related errors from exitErr and logs
// the failure reason using tracking.LogAuthError.
func logAuthFailureReason(exitErr *output.ExitError) {
if exitErr.Detail == nil {
return

Check warning on line 82 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L82

Added line #L82 was not covered by tests
}

// Handle NeedAuthorizationError first
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(exitErr.Err, &needAuthErr) {
errMsg := buildAuthFailureErrorMessage(needAuthErr)
tracking.LogAuthError(tracking.AuthComponentAuth, tracking.AuthOpNeedAuthorization, fmt.Errorf(errMsg))
return
}

// Handle TokenUnavailableError
var unavailableErr *credential.TokenUnavailableError
if errors.As(exitErr.Err, &unavailableErr) {
errMsg := fmt.Sprintf("reason=no_token source=%s type=%s", unavailableErr.Source, unavailableErr.Type)
tracking.LogAuthError(tracking.AuthComponentAuth, tracking.AuthOpTokenUnavailable, fmt.Errorf(errMsg))
return

Check warning on line 98 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L96-L98

Added lines #L96 - L98 were not covered by tests
}

// Handle general auth errors (type "auth")
if exitErr.Detail.Type == "auth" {
errMsg := fmt.Sprintf("reason=auth_error message=%q", exitErr.Detail.Message)
tracking.LogAuthError(tracking.AuthComponentAuth, tracking.AuthOpAuthError, fmt.Errorf(errMsg))

Check warning on line 104 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L103-L104

Added lines #L103 - L104 were not covered by tests
}
}

// buildAuthFailureErrorMessage constructs a detailed error message for auth failure logging.
func buildAuthFailureErrorMessage(err *internalauth.NeedAuthorizationError) string {
if err == nil {
return "unknown auth failure"

Check warning on line 111 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L111

Added line #L111 was not covered by tests
}

var parts []string
parts = append(parts, fmt.Sprintf("user=%s", err.UserOpenId))

switch err.Reason {
case internalauth.ReasonNoToken:
parts = append(parts, "reason=no_token")
case internalauth.ReasonTokenExpired:
parts = append(parts, "reason=token_expired")
case internalauth.ReasonRefreshExpired:
parts = append(parts, "reason=refresh_expired")
if err.GrantedAt > 0 {
grantedTime := time.UnixMilli(err.GrantedAt).Format(time.RFC3339)
parts = append(parts, fmt.Sprintf("refresh_token_granted_at=%s", grantedTime))

Check warning on line 126 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L118-L126

Added lines #L118 - L126 were not covered by tests
}
case internalauth.ReasonRefreshFailed:
parts = append(parts, "reason=refresh_failed")
if err.GrantedAt > 0 {
grantedTime := time.UnixMilli(err.GrantedAt).Format(time.RFC3339)
parts = append(parts, fmt.Sprintf("refresh_token_granted_at=%s", grantedTime))

Check warning on line 132 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L128-L132

Added lines #L128 - L132 were not covered by tests
}
case internalauth.ReasonPermissionDenied:
parts = append(parts, "reason=permission_denied")

Check warning on line 135 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L134-L135

Added lines #L134 - L135 were not covered by tests
default:
parts = append(parts, fmt.Sprintf("reason=%s", err.Reason))
}

return strings.Join(parts, " ")
}

// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
// current command for the resolved identity, checking shortcuts first and then
// service methods from local registry metadata.
Expand Down
47 changes: 36 additions & 11 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/tracking"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -207,15 +208,18 @@
// that differs from the standard ErrDetail, so it's handled separately.
var spErr *internalauth.SecurityPolicyError
if errors.As(err, &spErr) {
logSecurityPolicyError(spErr)

Check warning on line 211 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L211

Added line #L211 was not covered by tests
writeSecurityPolicyError(errOut, spErr)
return 1
}

// All other structured errors normalize to ExitError.
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
if exitErr.Raw {
// Raw errors (e.g. from `api` command) preserve the original API
// error detail; skip enrichment which would clear it.
// error detail; skip enrichment but still log auth failures.
logRawAuthFailure(exitErr)
} else {
enrichMissingScopeError(f, exitErr)
enrichPermissionError(f, exitErr)
}
Expand All @@ -235,27 +239,37 @@
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}

var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
return output.ErrAuth("authentication required: %s", err)

Check warning on line 245 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L245

Added line #L245 was not covered by tests
}

var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
}

// securityPolicyCodeString maps a security policy numeric code to a human-readable string.
func securityPolicyCodeString(code int) string {
switch code {
case internalauth.LarkErrBlockByPolicyTryAuth:
return "challenge_required"
case internalauth.LarkErrBlockByPolicy:
return "access_denied"
default:
return strconv.Itoa(code)

Check warning on line 263 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L256-L263

Added lines #L256 - L263 were not covered by tests
}
}

// writeSecurityPolicyError writes the security-policy-specific JSON envelope to w.
// This format intentionally differs from the standard ErrDetail envelope:
// it uses string codes ("challenge_required"/"access_denied") and extra fields
// (retryable, challenge_url) for machine-readable policy error handling.
func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyError) {
var codeStr string
switch spErr.Code {
case internalauth.LarkErrBlockByPolicyTryAuth:
codeStr = "challenge_required"
case internalauth.LarkErrBlockByPolicy:
codeStr = "access_denied"
default:
codeStr = strconv.Itoa(spErr.Code)
}
codeStr := securityPolicyCodeString(spErr.Code)

Check warning on line 272 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L272

Added line #L272 was not covered by tests

errData := map[string]interface{}{
"type": "auth_error",
Expand Down Expand Up @@ -423,6 +437,8 @@
isBot := f.ResolvedIdentity.IsBot()

larkCode := exitErr.Detail.Code

var reason internalauth.NeedAuthorizationReason
switch larkCode {
case output.LarkErrUserScopeInsufficient, output.LarkErrUserNotAuthorized:
// User has not authorized the scope → re-authorize
Expand All @@ -433,12 +449,14 @@
exitErr.Detail.Hint = fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended)
}
exitErr.Detail.ConsoleURL = consoleURL
reason = internalauth.ReasonPermissionDenied

Check warning on line 452 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L452

Added line #L452 was not covered by tests

case output.LarkErrAppScopeNotEnabled:
// App has not enabled the API scope → admin console
exitErr.Detail.Message = fmt.Sprintf("App scope not enabled: required scope %s [%d]", recommended, larkCode)
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
exitErr.Detail.ConsoleURL = consoleURL
reason = internalauth.ReasonPermissionDenied

default:
// Other permission errors (matched by keyword)
Expand All @@ -450,6 +468,13 @@
"enable scope in console (see console_url), or run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended)
}
exitErr.Detail.ConsoleURL = consoleURL
reason = internalauth.ReasonPermissionDenied

Check warning on line 471 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L471

Added line #L471 was not covered by tests
}

// Log permission error
if reason != "" {
errMsg := fmt.Sprintf("user=%s reason=%s scopes=%v", cfg.UserOpenId, reason, scopes)
tracking.LogAuthError(tracking.AuthComponentAuth, tracking.AuthOpPermissionDenied, fmt.Errorf(errMsg))
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/auth/app_registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
logAuthResponse(responsePath(resp), resp.StatusCode, resp.Header.Get("x-tt-logid"))

Check warning on line 69 in internal/auth/app_registration.go

View check run for this annotation

Codecov / codecov/patch

internal/auth/app_registration.go#L69

Added line #L69 was not covered by tests

body, err := io.ReadAll(resp.Body)
if err != nil {
Expand Down Expand Up @@ -163,7 +163,7 @@
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
logHTTPResponse(resp)
logAuthResponse(responsePath(resp), resp.StatusCode, resp.Header.Get("x-tt-logid"))

Check warning on line 166 in internal/auth/app_registration.go

View check run for this annotation

Codecov / codecov/patch

internal/auth/app_registration.go#L166

Added line #L166 was not covered by tests

body, err := io.ReadAll(resp.Body)
resp.Body.Close()
Expand Down
36 changes: 14 additions & 22 deletions internal/auth/auth_response_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,28 @@
import (
"net/http"

"github.com/larksuite/cli/internal/keychain"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/tracking"
)

// 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
func logAuthResponse(path string, statusCode int, logID string) {
if path == "" {
path = "missing"

Check warning on line 14 in internal/auth/auth_response_log.go

View check run for this annotation

Codecov / codecov/patch

internal/auth/auth_response_log.go#L14

Added line #L14 was not covered by tests
}

path := "missing"
if resp.Request != nil && resp.Request.URL != nil {
path = resp.Request.URL.Path
if shouldSkipAuthResponseLog(path, statusCode) {
return
}

keychain.LogAuthResponse(path, resp.StatusCode, resp.Header.Get("x-tt-logid"))
tracking.LogAuthResponse(path, statusCode, 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
func responsePath(resp *http.Response) string {
if resp != nil && resp.Request != nil && resp.Request.URL != nil {
return resp.Request.URL.Path

Check warning on line 26 in internal/auth/auth_response_log.go

View check run for this annotation

Codecov / codecov/patch

internal/auth/auth_response_log.go#L26

Added line #L26 was not covered by tests
}
return "missing"
}

keychain.LogAuthResponse(path, apiResp.StatusCode, apiResp.Header.Get("x-tt-logid"))
func shouldSkipAuthResponseLog(path string, statusCode int) bool {
return path == "missing" || statusCode == 400
}
4 changes: 2 additions & 2 deletions internal/auth/device_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
logAuthResponse(responsePath(resp), resp.StatusCode, resp.Header.Get("x-tt-logid"))

body, err := io.ReadAll(resp.Body)
if err != nil {
Expand Down Expand Up @@ -184,7 +184,7 @@
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
logHTTPResponse(resp)
logAuthResponse(responsePath(resp), resp.StatusCode, resp.Header.Get("x-tt-logid"))

Check warning on line 187 in internal/auth/device_flow.go

View check run for this annotation

Codecov / codecov/patch

internal/auth/device_flow.go#L187

Added line #L187 was not covered by tests

body, err := io.ReadAll(resp.Body)
resp.Body.Close()
Expand Down
Loading
Loading