mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
d7a83dfc79
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.
244 lines
7.5 KiB
Go
244 lines
7.5 KiB
Go
// 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)
|
||
}
|