Files
larksuite__cli/internal/auth/uat_client.go
kiraWangRuilong 7a6a4dbdf5 fix: handle credential and config read failures (#2583)
* fix: preserve credential and config read failures

Report corrupt or inaccessible stored credentials as typed errors instead of treating them as missing. Preserve config load failures across auth diagnostics and keep trusted request metadata available when host signal collection is unavailable.

Fixes #1925

* fix(auth): attach re-authorization recovery to corrupt stored token errors

A stored token that fails to decode or fails semantic validation is now
reported as internal/storage but carried no recovery hint, so `api --as
user`, `auth check`, `auth status`, and `doctor` all ended in a dead end.
A new login overwrites the damaged entry, so attach the canonical
user-authorization recovery on the two read-side corruption branches.
The write-side validator stays hint-free: a rejected write leaves nothing
on disk to re-authorize.

`auth status` keeps the storage failure in `note` and appends the recovery
instead of replacing it.

Tests pin the hint on GetStoredToken, GetValidAccessToken, identitydiag
(including reduced-distribution projection when auth login is concealed),
auth check, and auth status, and pin that SetStoredToken replaces a
corrupt entry.

---------
2026-09-09 11:15:53 +08:00

518 lines
16 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptrace"
"os"
"sync/atomic"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/recovery"
)
// UATCallOptions contains options for UAT API calls.
type UATCallOptions struct {
UserOpenId string
AppId string
AppSecret string
Domain core.LarkBrand
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
}
// UATStatus represents the status of a user access token.
type UATStatus struct {
Authorized bool `json:"authorized"`
UserOpenId string `json:"userOpenId"`
Scope string `json:"scope,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
GrantedAt int64 `json:"grantedAt,omitempty"`
TokenStatus string `json:"tokenStatus,omitempty"`
}
// NewUATCallOptions creates UATCallOptions from a CLI config.
func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions {
if errOut == nil {
errOut = os.Stderr
}
return UATCallOptions{
UserOpenId: cfg.UserOpenId,
AppId: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: cfg.Brand,
ErrOut: errOut,
}
}
// GetValidAccessToken obtains a valid access token for the given user.
func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) {
stored, err := GetStoredToken(opts.AppId, opts.UserOpenId)
if err != nil {
return "", err
}
if stored == nil {
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
}
if TokenStatus(stored) == "valid" {
return stored.AccessToken, nil
}
refreshed, err := refreshWithLock(httpClient, opts)
if err != nil {
return "", err
}
if refreshed == nil {
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
}
return refreshed.AccessToken, nil
}
// refreshWithLock serializes the complete refresh transaction with every
// stored-token writer and remover for this account.
func refreshWithLock(httpClient *http.Client, opts UATCallOptions) (*StoredUAToken, error) {
var refreshed *StoredUAToken
err := withTokenStorageLock(opts.AppId, opts.UserOpenId, func() error {
freshStored, err := GetStoredToken(opts.AppId, opts.UserOpenId)
if err != nil {
return err
}
if freshStored == nil {
return nil
}
switch TokenStatus(freshStored) {
case "valid":
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n")
}
refreshed = freshStored
return nil
case "expired":
retained, deleted, err := compareAndDeleteStoredToken(opts.AppId, opts.UserOpenId, freshStored)
if err != nil {
return err
}
if !deleted {
refreshed, err = resolveStoredTokenGenerationConflict(retained, opts.UserOpenId)
return err
}
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
}
return nil
}
if err := ensureTokenStorageWritable(opts.AppId, opts.UserOpenId); err != nil {
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut,
"[lark-cli] [WARN] uat-client: token storage is not writable while refreshing: %v\n",
err)
}
return err
}
refreshed, err = doRefreshToken(httpClient, opts, freshStored)
return err
})
return refreshed, err
}
const refreshMaxAttempts = 2
type refreshRequest struct {
GrantType string `json:"grant_type"`
RefreshToken string `json:"refresh_token"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
// refreshResponse contains the OAuth token fields consumed by the refresh
// flow. Pointers distinguish an omitted numeric field from a real zero value.
type refreshResponse struct {
Code *int `json:"code"`
AccessToken string `json:"access_token"`
ExpiresIn *int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
RefreshTokenExpiresIn *int64 `json:"refresh_token_expires_in"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// refreshAction describes both retry behavior and local token disposition.
type refreshAction uint8
const (
// refreshSaveResponse saves a successful response.
refreshSaveResponse refreshAction = iota
// refreshRetryAndPreserve retries, preserving the stored token if retry fails.
refreshRetryAndPreserve
// refreshRetryAndClear retries, clearing the stored token if retry fails.
refreshRetryAndClear
// refreshStopAndPreserve stops without clearing the stored token.
refreshStopAndPreserve
// refreshStopAndClear stops and clears the stored token.
refreshStopAndClear
)
type refreshResult struct {
action refreshAction
response refreshResponse
err error
}
// doRefreshToken performs the HTTP refresh and applies its storage result.
// The caller must hold the account's token storage lock.
func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
errOut := opts.ErrOut
if errOut == nil {
errOut = os.Stderr
}
if time.Now().UnixMilli() >= stored.RefreshExpiresAt {
fmt.Fprintf(errOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
retained, deleted, err := compareAndDeleteStoredToken(opts.AppId, opts.UserOpenId, stored)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove expired token: %v\n", err)
return nil, err
}
if !deleted {
return resolveStoredTokenGenerationConflict(retained, opts.UserOpenId)
}
return nil, nil
}
endpoint := ResolveOAuthEndpoints(opts.Domain).Token
uncertain := false
for attempt := 1; attempt <= refreshMaxAttempts; attempt++ {
result := refreshOnce(httpClient, endpoint, opts, stored)
if result.action == refreshSaveResponse {
return saveRefreshResponse(opts, stored, result.response)
}
switch result.action {
case refreshRetryAndPreserve, refreshRetryAndClear:
if result.action == refreshRetryAndClear {
uncertain = true
}
if attempt < refreshMaxAttempts {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh attempt %d/%d failed for %s: %v; retrying\n",
attempt, refreshMaxAttempts, opts.UserOpenId, result.err)
continue
}
case refreshStopAndPreserve, refreshStopAndClear:
default:
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"unrecognized token refresh action %d", result.action)
}
clearAfterUncertainResult := result.action == refreshRetryAndClear ||
(result.action == refreshRetryAndPreserve && uncertain)
clearToken := result.action == refreshStopAndClear || clearAfterUncertainResult
if !clearToken {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh failed for %s, preserving token: %v\n",
opts.UserOpenId, result.err)
return nil, result.err
}
retained, deleted, err := compareAndDeleteStoredToken(opts.AppId, opts.UserOpenId, stored)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
return nil, err
}
if !deleted {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: stored token changed during refresh for %s, preserving current token\n",
opts.UserOpenId)
return resolveStoredTokenGenerationConflict(retained, opts.UserOpenId)
}
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh failed for %s, token cleared: %v\n",
opts.UserOpenId, result.err)
// Preserve a precise, terminal refresh-token classification after
// deletion. Other failures surface the resulting missing-token state and
// retain the refresh failure as a cause.
if problem, ok := errs.ProblemOf(result.err); ok &&
problem.Category == errs.CategoryAuthentication && !problem.Retryable {
return nil, result.err
}
return nil, newNeedUserAuthorizationError(
opts.UserOpenId,
result.err,
recovery.Join("", recovery.Command(
recovery.TargetAuthLogin,
"refresh state is unrecoverable because the stored token was cleared; run `lark-cli auth login` to re-authorize",
)).WithFallback(
"refresh state is unrecoverable because the stored token was cleared; re-authorize through this distribution's supported authorization flow",
),
)
}
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"token refresh exhausted attempts without a result")
}
func refreshOnce(httpClient *http.Client, endpoint string, opts UATCallOptions, stored *StoredUAToken) refreshResult {
payload, err := json.Marshal(refreshRequest{
GrantType: "refresh_token",
RefreshToken: stored.RefreshToken,
ClientID: opts.AppId,
ClientSecret: opts.AppSecret,
})
if err != nil {
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeSDKError,
"failed to encode token refresh request: %v", err).
WithCause(err),
}
}
var wroteRequest atomic.Bool
trace := &httptrace.ClientTrace{
WroteRequest: func(httptrace.WroteRequestInfo) {
wroteRequest.Store(true)
},
}
ctx := httptrace.WithClientTrace(context.Background(), trace)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeSDKError,
"failed to create token refresh request: %v", err).
WithCause(err),
}
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
action := refreshRetryAndPreserve
problem, typed := errs.ProblemOf(err)
if typed && problem.Category == errs.CategoryPolicy {
action = refreshStopAndPreserve
} else if wroteRequest.Load() {
action = refreshRetryAndClear
}
if !typed {
err = errs.NewNetworkError(errs.SubtypeNetworkTransport,
"token refresh request failed: %v", err).
WithRetryable().
WithCause(err)
}
return refreshResult{
action: action,
err: err,
}
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewNetworkError(errs.SubtypeNetworkTransport,
"token refresh response read failed: %v", err).
WithRetryable().
WithCause(err),
}
}
var parsed refreshResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh returned invalid JSON: %v", err).
WithRetryable().
WithCause(err),
}
}
if parsed.Code == nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh response is missing required field code").
WithRetryable(),
}
}
code := *parsed.Code
if code != 0 {
meta, knownCode := errclass.LookupCodeMeta(code)
if knownCode && meta.Category == errs.CategoryPolicy {
var policyFields struct {
ChallengeURL string `json:"challenge_url"`
CLIHint string `json:"cli_hint"`
}
_ = json.Unmarshal(body, &policyFields)
return refreshResult{
action: refreshStopAndPreserve,
err: &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: parsed.ErrorDescription,
Hint: policyFields.CLIHint,
},
ChallengeURL: policyFields.ChallengeURL,
},
}
}
message := parsed.ErrorDescription
if message == "" {
message = parsed.Error
}
if message == "" {
message = fmt.Sprintf("token refresh failed with code %d", code)
}
action := refreshActionForCode(code)
if knownCode && meta.Category == errs.CategoryAuthentication {
authErr := errs.NewAuthenticationError(meta.Subtype, "%s", message).
WithCode(code).
WithUserOpenID(opts.UserOpenId)
if meta.Retryable {
authErr.WithRetryable()
}
return refreshResult{action: action, err: authErr}
}
apiErr := errs.NewAPIError(errs.SubtypeUnknown, "%s", message).
WithCode(code)
if action == refreshRetryAndPreserve || action == refreshRetryAndClear {
apiErr.WithRetryable()
}
return refreshResult{action: action, err: apiErr}
}
if parsed.RefreshToken == "" {
parsed.RefreshToken = stored.RefreshToken
}
if parsed.AccessToken == "" {
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh response is missing required field access_token").
WithRetryable(),
}
}
if parsed.ExpiresIn == nil || *parsed.ExpiresIn <= 0 {
parsed.ExpiresIn = new(int64)
*parsed.ExpiresIn = 7200 // 2 hours
}
if parsed.RefreshTokenExpiresIn == nil || *parsed.RefreshTokenExpiresIn <= 0 {
parsed.RefreshTokenExpiresIn = new(int64)
if stored.RefreshExpiresAt <= 0 {
*parsed.RefreshTokenExpiresIn = 2592000 // 30 days
} else {
now := time.Now().UnixMilli()
*parsed.RefreshTokenExpiresIn = (stored.RefreshExpiresAt - now) / 1000
}
}
return refreshResult{action: refreshSaveResponse, response: parsed}
}
func refreshActionForCode(code int) refreshAction {
meta, ok := errclass.LookupCodeMeta(code)
if ok && meta.Retryable {
return refreshRetryAndPreserve
}
// Retryability is opt-in. Unknown and known non-retryable codes
// deliberately stop and clear; doRefreshToken then reports the resulting
// missing-credential state while retaining the API error as a cause.
return refreshStopAndClear
}
// saveRefreshResponse persists a successful refresh response. The caller must
// hold the account's token storage lock.
func saveRefreshResponse(opts UATCallOptions, stored *StoredUAToken, response refreshResponse) (*StoredUAToken, error) {
now := time.Now().UnixMilli()
scope := response.Scope
if scope == "" {
scope = stored.Scope
}
updated := &StoredUAToken{
UserOpenId: stored.UserOpenId,
AppId: opts.AppId,
AccessToken: response.AccessToken,
RefreshToken: response.RefreshToken,
ExpiresAt: now + *response.ExpiresIn*1000,
RefreshExpiresAt: now + *response.RefreshTokenExpiresIn*1000,
Scope: scope,
GrantedAt: stored.GrantedAt,
}
current, swapped, err := compareAndSwapStoredToken(opts.AppId, opts.UserOpenId, stored, updated)
if err != nil {
return nil, err
}
if !swapped {
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut,
"[lark-cli] [WARN] uat-client: stored token changed during refresh for %s, preserving current token\n",
opts.UserOpenId)
}
return resolveStoredTokenGenerationConflict(current, opts.UserOpenId)
}
return updated, nil
}
func resolveStoredTokenGenerationConflict(current *StoredUAToken, userOpenId string) (*StoredUAToken, error) {
if current == nil {
return nil, nil
}
if TokenStatus(current) == "valid" {
return current, nil
}
return nil, errs.NewInternalError(errs.SubtypeStorage,
"stored refresh token changed while refreshing user %q", userOpenId).
WithRetryable().
WithHint("retry the command")
}
func ensureTokenStorageWritable(appID, userOpenID string) error {
if appID == "" || userOpenID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"cannot validate refresh token storage without user identity").
WithParam("app-id/user-open-id")
}
probeUserOpenID := fmt.Sprintf("%s:%s:refresh-storage-probe", appID, userOpenID)
probeToken := &StoredUAToken{
AppId: appID,
UserOpenId: probeUserOpenID,
AccessToken: "refresh-storage-probe",
Scope: "",
}
if err := SetStoredToken(probeToken); err != nil {
return err
}
if err := RemoveStoredToken(appID, probeUserOpenID); err != nil {
return err
}
return nil
}