mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
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.
318 lines
9.7 KiB
Go
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
|
|
}
|