bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
317 lines
9.9 KiB
Go
317 lines
9.9 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/internal/core"
|
||
)
|
||
|
||
// Terminal registration outcomes, exposed for typed classification by callers.
|
||
var (
|
||
ErrRegistrationDenied = errors.New("app registration denied by user")
|
||
ErrRegistrationExpired = errors.New("device code expired, please try again")
|
||
ErrRegistrationTimedOut = errors.New("app registration timed out, please try again")
|
||
)
|
||
|
||
// Protocol defaults, mirroring the official SDK registration flow.
|
||
const (
|
||
registrationBootstrapBrand = core.BrandFeishu
|
||
defaultPollIntervalSeconds = 5
|
||
defaultExpireInSeconds = 600
|
||
beginRequestTimeout = 30 * time.Second
|
||
maxPollIntervalSeconds = 60
|
||
)
|
||
|
||
// normalizedInterval clamps a non-positive poll interval to the protocol default.
|
||
func normalizedInterval(v int) int {
|
||
if v <= 0 {
|
||
return defaultPollIntervalSeconds
|
||
}
|
||
return v
|
||
}
|
||
|
||
// normalizedExpireIn clamps a non-positive expiry budget to the protocol default.
|
||
func normalizedExpireIn(v int) int {
|
||
if v <= 0 {
|
||
return defaultExpireInSeconds
|
||
}
|
||
return v
|
||
}
|
||
|
||
// registrationContextError maps a done context to its terminal reason, keeping the cause.
|
||
func registrationContextError(ctx context.Context) error {
|
||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||
return fmt.Errorf("%w: %w", ErrRegistrationTimedOut, ctx.Err())
|
||
}
|
||
return fmt.Errorf("app registration cancelled: %w", ctx.Err())
|
||
}
|
||
|
||
// 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"
|
||
}
|
||
|
||
// appRegistrationEndpoint returns the brand's accounts registration endpoint.
|
||
func appRegistrationEndpoint(brand core.LarkBrand) string {
|
||
return core.ResolveEndpoints(brand).Accounts + PathAppRegistration
|
||
}
|
||
|
||
// RequestAppRegistration initiates the device flow. The registration protocol
|
||
// always bootstraps on Feishu; brand selects the user-facing verification host.
|
||
// The request is bounded by ctx and a begin timeout.
|
||
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
|
||
if errOut == nil {
|
||
errOut = io.Discard
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(ctx, beginRequestTimeout)
|
||
defer cancel()
|
||
|
||
ep := core.ResolveEndpoints(brand)
|
||
endpoint := appRegistrationEndpoint(registrationBootstrapBrand)
|
||
|
||
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.NewRequestWithContext(ctx, "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, 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: %w", 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)
|
||
}
|
||
|
||
// The protocol field is expire_in; accept the legacy expires_in spelling,
|
||
// then normalize to protocol defaults.
|
||
expiresIn := getInt(data, "expire_in", 0)
|
||
if expiresIn <= 0 {
|
||
expiresIn = getInt(data, "expires_in", 0)
|
||
}
|
||
expiresIn = normalizedExpireIn(expiresIn)
|
||
interval := normalizedInterval(getInt(data, "interval", 0))
|
||
|
||
deviceCode := getStr(data, "device_code")
|
||
if deviceCode == "" {
|
||
return nil, fmt.Errorf("app registration failed: response missing device_code")
|
||
}
|
||
|
||
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: deviceCode,
|
||
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"
|
||
}
|
||
|
||
// pollOnce performs one ctx-bound poll request and decodes the payload.
|
||
func pollOnce(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string) (map[string]interface{}, error) {
|
||
form := url.Values{}
|
||
form.Set("action", "poll")
|
||
form.Set("device_code", deviceCode)
|
||
|
||
req, err := http.NewRequestWithContext(ctx, "POST", appRegistrationEndpoint(brand), strings.NewReader(form.Encode()))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("poll request: %w", err)
|
||
}
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("poll network error: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
logHTTPResponse(resp)
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("poll read error: %w", err)
|
||
}
|
||
var data map[string]interface{}
|
||
if err := json.Unmarshal(body, &data); err != nil {
|
||
return nil, fmt.Errorf("poll parse error: %w", err)
|
||
}
|
||
return data, nil
|
||
}
|
||
|
||
// RegisterAppWithDiscovery polls for credentials, mirroring the official SDK
|
||
// flow: the first poll and the (at most one) cross-brand switch are immediate,
|
||
// non-error responses without complete credentials keep polling, and one
|
||
// deadline from the begin expiry bounds all waits and in-flight requests.
|
||
// The returned brand is the one the credentials were issued on.
|
||
func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp *AppRegistrationResponse, errOut io.Writer) (*AppRegistrationResult, core.LarkBrand, error) {
|
||
if errOut == nil {
|
||
errOut = io.Discard
|
||
}
|
||
|
||
// Interval and expiry arrive normalized from begin-response parsing
|
||
// (normalizedInterval floors them there); the loop trusts them as-is.
|
||
interval := resp.Interval
|
||
ctx, cancel := context.WithDeadline(ctx,
|
||
time.Now().Add(time.Duration(resp.ExpiresIn)*time.Second))
|
||
defer cancel()
|
||
|
||
currentBrand := registrationBootstrapBrand
|
||
effectiveBrand := currentBrand
|
||
switched := false
|
||
waitBeforePoll := false
|
||
|
||
for {
|
||
if waitBeforePoll {
|
||
select {
|
||
case <-time.After(time.Duration(interval) * time.Second):
|
||
case <-ctx.Done():
|
||
return nil, effectiveBrand, registrationContextError(ctx)
|
||
}
|
||
}
|
||
waitBeforePoll = true
|
||
if ctx.Err() != nil {
|
||
return nil, effectiveBrand, registrationContextError(ctx)
|
||
}
|
||
|
||
data, err := pollOnce(ctx, httpClient, currentBrand, resp.DeviceCode)
|
||
if err != nil {
|
||
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: %v\n", err)
|
||
interval = minInt(interval+1, maxPollIntervalSeconds)
|
||
continue
|
||
}
|
||
|
||
// A cross-brand tenant report switches the polled domain (once,
|
||
// immediately) regardless of the accompanying status — the signal can
|
||
// arrive alongside authorization_pending, mirroring the official SDK.
|
||
if !switched {
|
||
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
|
||
if tb := getStr(userInfoRaw, "tenant_brand"); tb != "" {
|
||
if actual := core.ParseBrand(tb); actual != currentBrand {
|
||
currentBrand = actual
|
||
effectiveBrand = actual
|
||
switched = true
|
||
waitBeforePoll = false
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
errStr := getStr(data, "error")
|
||
if errStr == "" {
|
||
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"),
|
||
}
|
||
}
|
||
|
||
if result.ClientID != "" && result.ClientSecret != "" {
|
||
// The issuing domain is authoritative; a contradictory final
|
||
// tenant report is a protocol violation, not a brand override.
|
||
if result.UserInfo != nil && result.UserInfo.TenantBrand != "" &&
|
||
core.ParseBrand(result.UserInfo.TenantBrand) != effectiveBrand {
|
||
return nil, effectiveBrand, fmt.Errorf("app registration returned credentials with a contradictory tenant brand %q", result.UserInfo.TenantBrand)
|
||
}
|
||
return result, effectiveBrand, nil
|
||
}
|
||
// Incomplete credentials without an error: keep polling.
|
||
continue
|
||
}
|
||
|
||
switch errStr {
|
||
case "authorization_pending":
|
||
continue
|
||
case "slow_down":
|
||
interval = minInt(interval+5, maxPollIntervalSeconds)
|
||
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", interval)
|
||
continue
|
||
case "access_denied":
|
||
return nil, effectiveBrand, ErrRegistrationDenied
|
||
case "expired_token", "invalid_grant":
|
||
return nil, effectiveBrand, ErrRegistrationExpired
|
||
}
|
||
|
||
desc := getStr(data, "error_description")
|
||
if desc == "" {
|
||
desc = errStr
|
||
}
|
||
return nil, effectiveBrand, fmt.Errorf("app registration failed: %s", desc)
|
||
}
|
||
}
|