Files
larksuite-cli/internal/auth/uat_client.go
evandance 99e314fe0b feat(errs): typed envelope contract for auth-domain errors (#1135)
Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:

  - a fixed nine-category taxonomy on the wire, each mapped to a
    stable shell exit code (authentication/authorization/config = 3,
    network = 4, internal = 5, policy = 6, confirmation = 10)
  - identity-aware detail fields (missing_scopes, requested_scopes,
    granted_scopes, console_url, log_id, retryable, hint) carried
    uniformly on the envelope
  - a single canonical policy envelope at exit 6; the legacy
    auth_error carve-out is retired
  - per-subtype canonical message + hint that preserves Lark's
    diagnostic phrasing and routes recovery to the right actor:
    app developer (app_scope_not_applied), user (missing_scope,
    token_scope_insufficient, user_unauthorized), or tenant admin
    (app_unavailable, app_disabled)
  - wrong app credentials classify as config/invalid_client whether
    surfaced by the Open API endpoint (99991543) or the tenant
    access-token mint endpoint (10003 / 10014), instead of
    collapsing to a transport error or api/unknown
  - local shortcut scope preflight emits the same
    authorization/missing_scope envelope (identity + deterministic
    missing-scope set) used by the post-call permission path, so AI
    consumers read the same structured shape from precheck and from
    server-returned permission denial
  - streaming download/upload failures keep the same network subtype
    split (timeout / TLS / DNS / transport) as the non-stream path
    instead of collapsing every cause to a generic transport failure
  - console_url is carried only on the bot-perspective
    app_scope_not_applied envelope (where the recovery action is
    "developer applies the scope at the developer console"); the
    user-perspective missing_scope envelope drops the field, since
    the only actionable user recovery is `lark-cli auth login --scope`
    and pointing an end user at a console they cannot modify is
    misleading
  - bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
    Type tags to wire 'config' with the original module name kept
    as a metric label

All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
2026-05-30 19:08:41 +08:00

318 lines
9.7 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/gofrs/flock"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/vfs"
)
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, "_")
}
// 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,
}
}
var refreshLocks sync.Map
// GetValidAccessToken obtains a valid access token for the given user.
func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) {
stored := GetStoredToken(opts.AppId, opts.UserOpenId)
if stored == nil {
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
}
status := TokenStatus(stored)
if status == "valid" {
return stored.AccessToken, nil
}
if status == "needs_refresh" {
refreshed, err := refreshWithLock(httpClient, opts, stored)
if err != nil {
return "", err
}
if refreshed == nil {
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
}
return refreshed.AccessToken, nil
}
// expired
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
} else {
fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
}
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)
// 1. Process-level lock (prevents multiple goroutines in the same process)
done := make(chan struct{})
if existing, loaded := refreshLocks.LoadOrStore(key, done); loaded {
// Another goroutine is already refreshing; wait for it
if ch, ok := existing.(chan struct{}); ok {
<-ch
} else {
// fallback in case of unexpected type
refreshLocks.Delete(key)
}
return GetStoredToken(opts.AppId, opts.UserOpenId), nil
}
// We own the process lock; done is the channel stored in the map
defer func() {
close(done)
refreshLocks.Delete(key)
}()
// 2. Cross-process lock using flock
// We use the same underlying storage directory resolution as keychain_other.go
// to ensure locks are isolated properly alongside other sensitive data.
configDir := core.GetConfigDir()
lockDir := filepath.Join(configDir, "locks")
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create lock directory: %w", err)
}
safeAppId := sanitizeID(opts.AppId)
safeUserOpenId := sanitizeID(opts.UserOpenId)
lockFile := filepath.Join(lockDir, fmt.Sprintf("refresh_%s_%s.lock", safeAppId, safeUserOpenId))
fileLock := flock.New(lockFile)
// Try to acquire the lock, wait if necessary
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
locked, err := fileLock.TryLockContext(ctx, 500*time.Millisecond)
if err != nil {
return nil, fmt.Errorf("failed to acquire cross-process lock: %w", err)
}
if !locked {
return nil, fmt.Errorf("timeout waiting for cross-process lock")
}
defer fileLock.Unlock()
// 3. Double-checked locking: Check if another process has already refreshed the token
freshStored := GetStoredToken(opts.AppId, opts.UserOpenId)
if freshStored != nil {
status := TokenStatus(freshStored)
if status == "valid" {
// Another process refreshed it, we can just use the new token
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n")
}
return freshStored, nil
}
}
// 4. Actually perform the refresh
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 {
errOut = os.Stderr
}
now := time.Now().UnixMilli()
if now >= stored.RefreshExpiresAt {
fmt.Fprintf(errOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove expired token: %v\n", err)
}
return nil, nil
}
endpoints := ResolveOAuthEndpoints(opts.Domain)
callEndpoint := func() (map[string]interface{}, error) {
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", stored.RefreshToken)
form.Set("client_id", opts.AppId)
form.Set("client_secret", opts.AppSecret)
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("token refresh read error: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("token refresh parse error: %w", err)
}
return data, nil
}
data, err := callEndpoint()
if err != nil {
return nil, err
}
code := getInt(data, "code", -1)
meta, metaOK := errclass.LookupCodeMeta(code)
if metaOK && meta.Category == errs.CategoryPolicy {
challengeUrl := getStr(data, "challenge_url")
cliHint := getStr(data, "cli_hint")
msg := getStr(data, "error_description")
return nil, &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: msg,
Hint: cliHint,
},
ChallengeURL: challengeUrl,
}
}
errStr := getStr(data, "error")
if (code != -1 && code != 0) || errStr != "" {
// Retryable server error: retry once, then clear token on second failure.
if metaOK && meta.Category == errs.CategoryAuthentication && meta.Retryable {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId)
data, err = callEndpoint()
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh retry network error for %s, clearing token\n", opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
code = getInt(data, "code", -1)
errStr = getStr(data, "error")
if (code != -1 && code != 0) || errStr != "" {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed after retry (code=%d) for %s, clearing token\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
// Retry succeeded, fall through to parse token below.
} else {
// All other errors: clear token, require re-authorization.
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed (code=%d), clearing token for %s\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
}
accessToken := getStr(data, "access_token")
if accessToken == "" {
return nil, fmt.Errorf("Token refresh returned no access_token")
}
refreshToken := getStr(data, "refresh_token")
if refreshToken == "" {
refreshToken = stored.RefreshToken
}
expiresIn := getInt(data, "expires_in", 7200)
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 0)
refreshExpiresAt := stored.RefreshExpiresAt
if refreshExpiresIn > 0 {
refreshExpiresAt = now + int64(refreshExpiresIn)*1000
}
scope := getStr(data, "scope")
if scope == "" {
scope = stored.Scope
}
updated := &StoredUAToken{
UserOpenId: stored.UserOpenId,
AppId: opts.AppId,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: now + int64(expiresIn)*1000,
RefreshExpiresAt: refreshExpiresAt,
Scope: scope,
GrantedAt: stored.GrantedAt,
}
if err := SetStoredToken(updated); err != nil {
return nil, err
}
return updated, nil
}