Files
larksuite__cli/internal/auth/app_registration.go
shanglei d7a83dfc79 fix(config): align error contract on the app-registration paths
Address review feedback (non-blockers), keeping the blocking and non-blocking
initiate paths consistent:

- Classify a transport failure from RequestAppRegistration as a typed
  NetworkError (SubtypeNetworkTransport) at the boundary, and pass it through
  unchanged from both initiate paths instead of mislabeling it invalid_client.
- resume: pass an already-typed poll error (e.g. the missing-credentials
  ConfigError) through unchanged instead of downgrading it to
  authentication/unknown.
- drift check: surface a genuine config-load failure (permission/corruption)
  as a typed storage error instead of silently reading it as config drift; a
  missing config (first-time setup) is still fine.
- test: assert the non-terminal hint names both --no-wait and --device-code.
2026-06-18 15:35:16 +08:00

244 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// Sentinel errors returned by PollAppRegistration so callers can classify a
// failure (e.g. to decide whether a cached device code should be discarded)
// via errors.Is without parsing message strings.
var (
// ErrAppRegDenied means the user rejected the app registration.
ErrAppRegDenied = errors.New("app registration denied by user")
// ErrAppRegExpired means the device code is no longer valid.
ErrAppRegExpired = errors.New("device code expired")
// ErrAppRegCancelled means polling was cancelled via the context.
ErrAppRegCancelled = errors.New("polling was cancelled")
// ErrAppRegTimeout means the local polling deadline elapsed.
ErrAppRegTimeout = errors.New("app registration timed out")
)
// AppRegistrationResponse is the response from the app registration begin endpoint.
type AppRegistrationResponse struct {
DeviceCode string
UserCode string
VerificationUri string
VerificationUriComplete string
ExpiresIn int
Interval int
}
// AppRegistrationResult is the result of a successful app registration poll.
type AppRegistrationResult struct {
ClientID string
ClientSecret string
UserInfo *AppRegUserInfo
}
// AppRegUserInfo contains user info returned from app registration.
type AppRegUserInfo struct {
OpenID string
TenantBrand string // "feishu" or "lark"
}
// RequestAppRegistration initiates the app registration device flow.
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
errOut = io.Discard
}
ep := core.ResolveEndpoints(brand)
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
endpoint := regEp.Accounts + PathAppRegistration
form := url.Values{}
form.Set("action", "begin")
form.Set("archetype", "PersonalAgent")
form.Set("auth_method", "client_secret")
form.Set("request_user_info", "open_id tenant_brand")
req, err := http.NewRequest("POST", endpoint, 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, errs.NewNetworkError(errs.SubtypeNetworkTransport, "app registration request failed: %v", err).WithCause(err)
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("app registration failed: read body: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("app registration failed: HTTP %d – response not JSON", resp.StatusCode)
}
_, hasError := data["error"]
if resp.StatusCode >= 400 || hasError {
msg := getStr(data, "error_description")
if msg == "" {
msg = getStr(data, "error")
}
if msg == "" {
msg = "Unknown error"
}
return nil, fmt.Errorf("app registration failed: %s", msg)
}
expiresIn := getInt(data, "expires_in", 300)
interval := getInt(data, "interval", 5)
userCode := getStr(data, "user_code")
verificationUri := getStr(data, "verification_uri")
verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)
return &AppRegistrationResponse{
DeviceCode: getStr(data, "device_code"),
UserCode: getStr(data, "user_code"),
VerificationUri: verificationUri,
VerificationUriComplete: verificationUriComplete,
ExpiresIn: expiresIn,
Interval: interval,
}, nil
}
// BuildVerificationURL appends CLI tracking parameters to the verification URL.
func BuildVerificationURL(baseURL, cliVersion string) string {
sep := "&"
if !strings.Contains(baseURL, "?") {
sep = "?"
}
return baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) +
"&ocv=" + url.QueryEscape(cliVersion) +
"&from=cli"
}
// PollAppRegistration polls the app registration endpoint until the app is created or the flow times out.
// If the result has ClientSecret == "" and UserInfo.TenantBrand == "lark", the caller should
// retry with BrandLark to get the secret from accounts.larksuite.com.
func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) (*AppRegistrationResult, error) {
if errOut == nil {
errOut = io.Discard
}
const maxPollInterval = 60
const maxPollAttempts = 200
ep := core.ResolveEndpoints(brand)
endpoint := ep.Accounts + PathAppRegistration
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
currentInterval := interval
attempts := 0
for time.Now().Before(deadline) && attempts < maxPollAttempts {
attempts++
if ctx.Err() != nil {
return nil, ErrAppRegCancelled
}
select {
case <-time.After(time.Duration(currentInterval) * time.Second):
case <-ctx.Done():
return nil, ErrAppRegCancelled
}
form := url.Values{}
form.Set("action", "poll")
form.Set("device_code", deviceCode)
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll network error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll read error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll parse error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
errStr := getStr(data, "error")
// Success: client_id present
if errStr == "" && getStr(data, "client_id") != "" {
result := &AppRegistrationResult{
ClientID: getStr(data, "client_id"),
ClientSecret: getStr(data, "client_secret"),
}
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
result.UserInfo = &AppRegUserInfo{
OpenID: getStr(userInfoRaw, "open_id"),
TenantBrand: getStr(userInfoRaw, "tenant_brand"),
}
}
return result, nil
}
switch errStr {
case "authorization_pending":
continue
case "slow_down":
currentInterval = minInt(currentInterval+5, maxPollInterval)
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval)
continue
case "access_denied":
return nil, ErrAppRegDenied
case "expired_token", "invalid_grant":
return nil, fmt.Errorf("%w, please try again", ErrAppRegExpired)
}
desc := getStr(data, "error_description")
if desc == "" {
desc = errStr
}
if desc == "" {
desc = "Unknown error"
}
return nil, fmt.Errorf("app registration failed: %s", desc)
}
if attempts >= maxPollAttempts {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: max poll attempts (%d) reached\n", maxPollAttempts)
}
return nil, fmt.Errorf("%w, please try again", ErrAppRegTimeout)
}