chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
// jsonResponse builds a canned registration response (transport fakes reuse
|
||||
// roundTripFunc from device_flow_test.go).
|
||||
func jsonResponse(body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
// Test_BuildVerificationURL verifies that tracking parameters are correctly appended.
|
||||
func Test_BuildVerificationURL(t *testing.T) {
|
||||
t.Run("URL不含问号则添加?分隔符", func(t *testing.T) {
|
||||
result := BuildVerificationURL("https://example.com/verify", "1.0.0")
|
||||
convey.Convey("should add ? separator", t, func() {
|
||||
convey.So(result, convey.ShouldContainSubstring, "?lpv=1.0.0")
|
||||
convey.So(result, convey.ShouldContainSubstring, "&ocv=1.0.0")
|
||||
convey.So(result, convey.ShouldContainSubstring, "&from=cli")
|
||||
convey.So(result, convey.ShouldStartWith, "https://example.com/verify?")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("URL已含问号则添加&分隔符", func(t *testing.T) {
|
||||
result := BuildVerificationURL("https://example.com/verify?code=abc", "2.0.0")
|
||||
convey.Convey("should add & separator", t, func() {
|
||||
convey.So(result, convey.ShouldContainSubstring, "&lpv=2.0.0")
|
||||
convey.So(result, convey.ShouldContainSubstring, "&ocv=2.0.0")
|
||||
convey.So(result, convey.ShouldContainSubstring, "&from=cli")
|
||||
convey.So(result, convey.ShouldNotContainSubstring, "?lpv=")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppRegistrationEndpoint(t *testing.T) {
|
||||
cases := []struct {
|
||||
brand core.LarkBrand
|
||||
want string
|
||||
}{
|
||||
{core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration},
|
||||
{core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := appRegistrationEndpoint(c.brand); got != c.want {
|
||||
t.Errorf("brand %q: endpoint = %q, want %q", c.brand, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBrand(t *testing.T) {
|
||||
cases := []struct {
|
||||
brand core.LarkBrand
|
||||
verificationHost string
|
||||
}{
|
||||
{core.BrandFeishu, "open.feishu.cn"},
|
||||
{core.BrandLark, "open.larksuite.com"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(string(c.brand), func(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if got, want := r.URL.Host, "accounts.feishu.cn"; got != want {
|
||||
t.Errorf("begin host = %q, want bootstrap host %q", got, want)
|
||||
}
|
||||
return jsonResponse(`{"device_code":"d","user_code":"TEST-CODE","expire_in":60,"interval":5}`), nil
|
||||
})}
|
||||
resp, err := RequestAppRegistration(context.Background(), client, c.brand, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestAppRegistration(%q) error = %v", c.brand, err)
|
||||
}
|
||||
if !strings.HasPrefix(resp.VerificationUriComplete, "https://"+c.verificationHost+"/page/cli?") {
|
||||
t.Errorf("verification URL = %q, want host %q", resp.VerificationUriComplete, c.verificationHost)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Full Lark routing contract: Lark selects the Lark verification page, while
|
||||
// registration bootstraps on Feishu and switches only after the tenant signal.
|
||||
// The Lark credential response omits user_info, so the effective domain must
|
||||
// still determine the saved brand.
|
||||
func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) {
|
||||
var calls []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("parse form: %v", err)
|
||||
}
|
||||
action := r.Form.Get("action")
|
||||
calls = append(calls, action+"@"+r.URL.Host)
|
||||
if action == "begin" {
|
||||
return jsonResponse(`{"device_code":"device","user_code":"TEST-CODE","expire_in":60,"interval":0}`), nil
|
||||
}
|
||||
switch r.URL.Host {
|
||||
case "accounts.feishu.cn":
|
||||
return jsonResponse(`{"user_info":{"open_id":"ou_x","tenant_brand":"lark"}}`), nil
|
||||
case "accounts.larksuite.com":
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
||||
}
|
||||
t.Errorf("unexpected host polled: %s", r.URL.Host)
|
||||
return jsonResponse(`{}`), nil
|
||||
})}
|
||||
resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestAppRegistration error = %v", err)
|
||||
}
|
||||
if got, want := resp.VerificationUriComplete, "https://open.larksuite.com/page/cli?user_code=TEST-CODE"; got != want {
|
||||
t.Errorf("verification URL = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
||||
}
|
||||
if finalBrand != core.BrandLark {
|
||||
t.Errorf("finalBrand = %q, want %q (credentials were issued on the lark domain)", finalBrand, core.BrandLark)
|
||||
}
|
||||
if result.ClientID != "cli_x" || result.ClientSecret != "test-secret" {
|
||||
t.Errorf("credentials = (%q, %q), want (cli_x, test-secret)", result.ClientID, result.ClientSecret)
|
||||
}
|
||||
want := []string{"begin@accounts.feishu.cn", "poll@accounts.feishu.cn", "poll@accounts.larksuite.com"}
|
||||
if len(calls) != len(want) {
|
||||
t.Fatalf("calls = %v, want %v", calls, want)
|
||||
}
|
||||
for i := range want {
|
||||
if calls[i] != want[i] {
|
||||
t.Errorf("calls = %v, want %v", calls, want)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plain path: the bootstrap domain can return complete Feishu credentials in
|
||||
// one poll, even when user_info is absent.
|
||||
func TestRegisterAppWithDiscovery_BootstrapBrandSinglePoll(t *testing.T) {
|
||||
polls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
polls++
|
||||
if got, want := r.URL.Host, "accounts.feishu.cn"; got != want {
|
||||
t.Errorf("poll host = %q, want %q", got, want)
|
||||
}
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
||||
|
||||
_, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
||||
}
|
||||
if finalBrand != core.BrandFeishu {
|
||||
t.Errorf("finalBrand = %q, want %q", finalBrand, core.BrandFeishu)
|
||||
}
|
||||
if polls != 1 {
|
||||
t.Errorf("polls = %d, want 1", polls)
|
||||
}
|
||||
}
|
||||
|
||||
// The discovery deadline must cancel in-flight requests: the fake transport
|
||||
// hangs until the request context is done.
|
||||
func TestRegisterAppWithDiscovery_DeadlineBoundsInFlightRequests(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
<-r.Context().Done()
|
||||
return nil, r.Context().Err()
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 1}
|
||||
|
||||
start := time.Now()
|
||||
_, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timed out") {
|
||||
t.Errorf("error = %v, want a timed-out terminal reason", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||
t.Errorf("discovery not bounded by its deadline: took %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty payloads and incomplete same-brand responses are not terminal.
|
||||
func TestRegisterAppWithDiscovery_PollsUntilCredentials(t *testing.T) {
|
||||
responses := []string{
|
||||
`{}`,
|
||||
`{"client_id":"cli_x","user_info":{"open_id":"ou_x","tenant_brand":"feishu"}}`,
|
||||
`{"client_id":"cli_x","client_secret":"test-secret","user_info":{"open_id":"ou_x","tenant_brand":"feishu"}}`,
|
||||
}
|
||||
polls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
body := responses[polls]
|
||||
polls++
|
||||
return jsonResponse(body), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
||||
|
||||
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
||||
}
|
||||
if polls != 3 {
|
||||
t.Errorf("polls = %d, want 3", polls)
|
||||
}
|
||||
if result.ClientSecret != "test-secret" || finalBrand != core.BrandFeishu {
|
||||
t.Errorf("result = (%q, %q), want (test-secret, feishu)", result.ClientSecret, finalBrand)
|
||||
}
|
||||
}
|
||||
|
||||
// Neither the first poll nor the cross-brand switch waits out the interval
|
||||
// (a 5s interval would blow the elapsed bound).
|
||||
func TestRegisterAppWithDiscovery_ImmediateFirstPollAndSwitch(t *testing.T) {
|
||||
var polledHosts []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
polledHosts = append(polledHosts, r.URL.Host)
|
||||
if r.URL.Host == "accounts.feishu.cn" {
|
||||
return jsonResponse(`{"user_info":{"open_id":"ou_x","tenant_brand":"lark"}}`), nil
|
||||
}
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 5, ExpiresIn: 60}
|
||||
|
||||
start := time.Now()
|
||||
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Errorf("discovery waited an interval somewhere: took %v", elapsed)
|
||||
}
|
||||
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
|
||||
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
|
||||
}
|
||||
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
|
||||
if len(polledHosts) != 2 || polledHosts[0] != want[0] || polledHosts[1] != want[1] {
|
||||
t.Errorf("polled hosts = %v, want %v", polledHosts, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Denial and expiry map to sentinels; cancellation preserves its cause.
|
||||
func TestRegisterAppWithDiscovery_TerminalSentinels(t *testing.T) {
|
||||
deny := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(`{"error":"access_denied"}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
||||
_, _, err := RegisterAppWithDiscovery(context.Background(), deny, resp, io.Discard)
|
||||
if !errors.Is(err, ErrRegistrationDenied) {
|
||||
t.Errorf("denied err = %v, want ErrRegistrationDenied", err)
|
||||
}
|
||||
|
||||
expired := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(`{"error":"expired_token"}`), nil
|
||||
})}
|
||||
_, _, err = RegisterAppWithDiscovery(context.Background(), expired, resp, io.Discard)
|
||||
if !errors.Is(err, ErrRegistrationExpired) {
|
||||
t.Errorf("expired err = %v, want ErrRegistrationExpired", err)
|
||||
}
|
||||
|
||||
cancelledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, _, err = RegisterAppWithDiscovery(cancelledCtx, deny, resp, io.Discard)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("cancelled err = %v, want a context.Canceled cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Begin parsing: expire_in (legacy expires_in fallback), normalization, and
|
||||
// required device_code.
|
||||
func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
|
||||
serve := func(body string) *http.Client {
|
||||
return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(body), nil
|
||||
})}
|
||||
}
|
||||
|
||||
resp, err := RequestAppRegistration(context.Background(),
|
||||
serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("begin error = %v", err)
|
||||
}
|
||||
if resp.ExpiresIn != 60 || resp.Interval != 3 {
|
||||
t.Errorf("parsed (expire=%d, interval=%d), want (60, 3)", resp.ExpiresIn, resp.Interval)
|
||||
}
|
||||
|
||||
resp, err = RequestAppRegistration(context.Background(),
|
||||
serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy begin error = %v", err)
|
||||
}
|
||||
if resp.ExpiresIn != 45 || resp.Interval != 5 {
|
||||
t.Errorf("legacy parsed (expire=%d, interval=%d), want (45, 5 — normalized default)", resp.ExpiresIn, resp.Interval)
|
||||
}
|
||||
|
||||
resp, err = RequestAppRegistration(context.Background(),
|
||||
serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("defaults begin error = %v", err)
|
||||
}
|
||||
if resp.ExpiresIn != 600 || resp.Interval != 5 {
|
||||
t.Errorf("defaults parsed (expire=%d, interval=%d), want (600, 5)", resp.ExpiresIn, resp.Interval)
|
||||
}
|
||||
|
||||
if _, err := RequestAppRegistration(context.Background(),
|
||||
serve(`{"interval":5}`), core.BrandFeishu, io.Discard); err == nil {
|
||||
t.Error("missing device_code: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// A tenant signal arriving alongside authorization_pending must still switch
|
||||
// the polled domain (the official SDK checks the signal before the error).
|
||||
func TestRegisterAppWithDiscovery_PendingWithTenantSignalSwitches(t *testing.T) {
|
||||
var polledHosts []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
polledHosts = append(polledHosts, r.URL.Host)
|
||||
if r.URL.Host == "accounts.feishu.cn" {
|
||||
return jsonResponse(`{"error":"authorization_pending","user_info":{"tenant_brand":"lark"}}`), nil
|
||||
}
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
||||
|
||||
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
||||
}
|
||||
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
|
||||
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
|
||||
}
|
||||
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
|
||||
if len(polledHosts) != 2 || polledHosts[0] != want[0] || polledHosts[1] != want[1] {
|
||||
t.Errorf("polled hosts = %v, want %v", polledHosts, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Polling has no attempt cap: only the expiry budget terminates the loop.
|
||||
func TestRegisterAppWithDiscovery_NoAttemptCap(t *testing.T) {
|
||||
polls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
polls++
|
||||
if polls <= 250 {
|
||||
return jsonResponse(`{"error":"authorization_pending"}`), nil
|
||||
}
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 30}
|
||||
|
||||
result, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil (no attempts cap)", err)
|
||||
}
|
||||
if polls != 251 || result.ClientSecret != "test-secret" {
|
||||
t.Errorf("polls = %d (want 251), secret = %q", polls, result.ClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// A final tenant report contradicting the issuing domain is a protocol
|
||||
// violation, not a brand override: the saved brand must never diverge from
|
||||
// the domain that issued the credentials.
|
||||
func TestRegisterAppWithDiscovery_ContradictoryFinalBrandFails(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host == "accounts.feishu.cn" {
|
||||
return jsonResponse(`{"error":"authorization_pending","user_info":{"tenant_brand":"lark"}}`), nil
|
||||
}
|
||||
// The lark domain issues credentials but reports a feishu tenant.
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret","user_info":{"tenant_brand":"feishu"}}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
||||
|
||||
_, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "contradictory tenant brand") {
|
||||
t.Errorf("err = %v, want contradictory-tenant-brand protocol error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A cancelled body read during begin must keep its context cause so the
|
||||
// command layer classifies it as a cancellation, not an API failure.
|
||||
func TestRequestAppRegistration_BodyReadCancelKeepsCause(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(&errReader{err: context.Canceled}),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
_, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, io.Discard)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("err = %v, want a context.Canceled cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
type errReader struct{ err error }
|
||||
|
||||
func (r *errReader) Read([]byte) (int, error) { return 0, r.err }
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// logHTTPResponse logs the HTTP response details for an authentication request.
|
||||
// It extracts the request path, status code, and x-tt-logid from the given HTTP response.
|
||||
func logHTTPResponse(resp *http.Response) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
path := "missing"
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
path = resp.Request.URL.Path
|
||||
}
|
||||
|
||||
keychain.LogAuthResponse(path, resp.StatusCode, resp.Header.Get("x-tt-logid"))
|
||||
}
|
||||
|
||||
// logSDKResponse logs the SDK response details for an authentication request.
|
||||
// It extracts the status code and x-tt-logid from the given API response object.
|
||||
func logSDKResponse(path string, apiResp *larkcore.ApiResp) {
|
||||
if path == "" {
|
||||
path = "missing"
|
||||
}
|
||||
|
||||
if apiResp == nil {
|
||||
keychain.LogAuthResponse(path, 0, "")
|
||||
return
|
||||
}
|
||||
|
||||
keychain.LogAuthResponse(path, apiResp.StatusCode, apiResp.Header.Get("x-tt-logid"))
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// DeviceAuthResponse is the response from the device authorization endpoint.
|
||||
type DeviceAuthResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationUri string `json:"verification_uri"`
|
||||
VerificationUriComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// DeviceFlowTokenData contains the token data from a successful device flow.
|
||||
type DeviceFlowTokenData struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresIn int
|
||||
RefreshExpiresIn int
|
||||
Scope string
|
||||
}
|
||||
|
||||
// DeviceFlowResult is the result of polling the token endpoint.
|
||||
type DeviceFlowResult struct {
|
||||
OK bool
|
||||
Token *DeviceFlowTokenData
|
||||
Error string
|
||||
Message string
|
||||
}
|
||||
|
||||
// OAuthEndpoints contains the OAuth endpoint URLs.
|
||||
type OAuthEndpoints struct {
|
||||
DeviceAuthorization string
|
||||
Revoke string
|
||||
Token string
|
||||
}
|
||||
|
||||
// ResolveOAuthEndpoints resolves OAuth endpoint URLs based on brand.
|
||||
func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
return OAuthEndpoints{
|
||||
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
|
||||
Revoke: ep.Accounts + PathOAuthRevoke,
|
||||
Token: ep.Open + PathOAuthTokenV2,
|
||||
}
|
||||
}
|
||||
|
||||
// RequestDeviceAuthorization requests a device authorization code.
|
||||
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
endpoints := ResolveOAuthEndpoints(brand)
|
||||
|
||||
if !strings.Contains(scope, "offline_access") {
|
||||
if scope != "" {
|
||||
scope = scope + " offline_access"
|
||||
} else {
|
||||
scope = "offline_access"
|
||||
}
|
||||
}
|
||||
|
||||
basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", appId)
|
||||
form.Set("scope", scope)
|
||||
|
||||
req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth)
|
||||
|
||||
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("Device authorization failed: read body: %v", err)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, fmt.Errorf("Device authorization 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("Device authorization failed: %s", msg)
|
||||
}
|
||||
|
||||
expiresIn := getInt(data, "expires_in", 240)
|
||||
interval := getInt(data, "interval", 5)
|
||||
|
||||
verificationUri := getStr(data, "verification_uri")
|
||||
verificationUriComplete := getStr(data, "verification_uri_complete")
|
||||
if verificationUriComplete == "" {
|
||||
verificationUriComplete = verificationUri
|
||||
}
|
||||
|
||||
return &DeviceAuthResponse{
|
||||
DeviceCode: getStr(data, "device_code"),
|
||||
UserCode: getStr(data, "user_code"),
|
||||
VerificationUri: verificationUri,
|
||||
VerificationUriComplete: verificationUriComplete,
|
||||
ExpiresIn: expiresIn,
|
||||
Interval: interval,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PollDeviceToken polls the token endpoint until authorization completes or times out.
|
||||
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
if interval < 1 {
|
||||
interval = 5
|
||||
}
|
||||
|
||||
const maxPollInterval = 60
|
||||
const maxPollAttempts = 600
|
||||
|
||||
endpoints := ResolveOAuthEndpoints(brand)
|
||||
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 &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Duration(currentInterval) * time.Second):
|
||||
case <-ctx.Done():
|
||||
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"}
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
|
||||
form.Set("device_code", deviceCode)
|
||||
form.Set("client_id", appId)
|
||||
form.Set("client_secret", appSecret)
|
||||
|
||||
req, err := http.NewRequest("POST", endpoints.Token, 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] device-flow: 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] device-flow: 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] device-flow: poll parse error: %v\n", err)
|
||||
currentInterval = minInt(currentInterval+1, maxPollInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
errStr := getStr(data, "error")
|
||||
|
||||
if errStr == "" && getStr(data, "access_token") != "" {
|
||||
fmt.Fprintf(errOut, "[lark-cli] device-flow: token response received\n")
|
||||
refreshToken := getStr(data, "refresh_token")
|
||||
tokenExpiresIn := getInt(data, "expires_in", 7200)
|
||||
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 604800)
|
||||
if refreshToken == "" {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: no refresh_token in response\n")
|
||||
refreshExpiresIn = tokenExpiresIn
|
||||
}
|
||||
return &DeviceFlowResult{
|
||||
OK: true,
|
||||
Token: &DeviceFlowTokenData{
|
||||
AccessToken: getStr(data, "access_token"),
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: tokenExpiresIn,
|
||||
RefreshExpiresIn: refreshExpiresIn,
|
||||
Scope: getStr(data, "scope"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
switch errStr {
|
||||
case "authorization_pending":
|
||||
continue
|
||||
case "slow_down":
|
||||
currentInterval = minInt(currentInterval+5, maxPollInterval)
|
||||
fmt.Fprintf(errOut, "[lark-cli] device-flow: slow_down, interval increased to %ds\n", currentInterval)
|
||||
continue
|
||||
case "access_denied":
|
||||
msg := getStr(data, "error_description")
|
||||
if msg == "" {
|
||||
msg = "Authorization denied by user"
|
||||
}
|
||||
return &DeviceFlowResult{OK: false, Error: "access_denied", Message: msg}
|
||||
case "expired_token", "invalid_grant":
|
||||
msg := getStr(data, "error_description")
|
||||
if msg == "" {
|
||||
msg = "Device code expired, please try again"
|
||||
}
|
||||
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: msg}
|
||||
}
|
||||
|
||||
desc := getStr(data, "error_description")
|
||||
if desc == "" {
|
||||
desc = errStr
|
||||
}
|
||||
if desc == "" {
|
||||
desc = "Unknown error"
|
||||
}
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: unexpected error: error=%s, desc=%s\n", errStr, desc)
|
||||
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: desc}
|
||||
}
|
||||
|
||||
if attempts >= maxPollAttempts {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: max poll attempts (%d) reached\n", maxPollAttempts)
|
||||
}
|
||||
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Authorization timed out, please try again"}
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
// minInt returns the smaller of a or b.
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// getStr retrieves a string value from a map, returning an empty string if not found or not a string.
|
||||
func getStr(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getInt retrieves an integer value from a map, returning a fallback value if not found or not a number.
|
||||
func getInt(m map[string]interface{}, key string, fallback int) int {
|
||||
if v, ok := m[key]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand.
|
||||
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveOAuthEndpoints(core.BrandFeishu)
|
||||
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOAuthEndpoints_Lark validates endpoints for the Lark brand.
|
||||
func TestResolveOAuthEndpoints_Lark(t *testing.T) {
|
||||
ep := ResolveOAuthEndpoints(core.BrandLark)
|
||||
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestDeviceAuthorization_LogsResponse checks if API responses are logged correctly.
|
||||
func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Tt-Logid": []string{"device-log-id"},
|
||||
},
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
|
||||
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
|
||||
}, func() []string {
|
||||
return []string{"lark-cli", "auth", "login", "--device-code", "device-code-secret", "--app-secret=top-secret"}
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "time=2026-04-02T03:04:05Z") {
|
||||
t.Fatalf("expected time in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "path=missing") {
|
||||
t.Fatalf("expected path in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "status=200") {
|
||||
t.Fatalf("expected status=200 in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "x-tt-logid=device-log-id") {
|
||||
t.Fatalf("expected x-tt-logid in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "cmdline=lark-cli auth login ...") {
|
||||
t.Fatalf("expected cmdline in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
|
||||
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
|
||||
got := keychain.FormatAuthCmdline([]string{
|
||||
"lark-cli",
|
||||
"auth",
|
||||
"login",
|
||||
"--device-code", "device-code-secret",
|
||||
"--app-secret=top-secret",
|
||||
"--scope", "contact:read",
|
||||
})
|
||||
|
||||
want := "lark-cli auth login ..."
|
||||
if got != want {
|
||||
t.Fatalf("formatAuthCmdline() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogAuthResponse_IgnoresTypedNilHTTPResponse tests that a typed nil HTTP response is ignored gracefully.
|
||||
func TestLogAuthResponse_IgnoresTypedNilHTTPResponse(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), nil, nil)
|
||||
t.Cleanup(restore)
|
||||
|
||||
var resp *http.Response
|
||||
logHTTPResponse(resp)
|
||||
|
||||
if got := buf.String(); got != "" {
|
||||
t.Fatalf("expected no log output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogAuthResponse_HandlesNilSDKResponse verifies that a nil SDK response is handled without panicking.
|
||||
func TestLogAuthResponse_HandlesNilSDKResponse(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
|
||||
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
|
||||
}, func() []string {
|
||||
return []string{"lark-cli", "auth", "status", "--verify"}
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
logSDKResponse(PathUserInfoV1, nil)
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "path="+PathUserInfoV1) {
|
||||
t.Fatalf("expected sdk path in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "status=0") {
|
||||
t.Fatalf("expected zero status in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogAuthError_RecordsStructuredEntry(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
|
||||
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
|
||||
}, func() []string {
|
||||
return []string{"lark-cli", "auth", "login", "--device-code", "secret"}
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
keychain.LogAuthError("keychain", "Set", fmt.Errorf("keychain Set error: %w", http.ErrUseLastResponse))
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "auth-error") {
|
||||
t.Fatalf("expected auth-error log entry, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "component=keychain") {
|
||||
t.Fatalf("expected component in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "op=Set") {
|
||||
t.Fatalf("expected op in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "error=\"keychain Set error: net/http: use last response\"") {
|
||||
t.Fatalf("expected quoted error in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "cmdline=lark-cli auth login ...") {
|
||||
t.Fatalf("expected truncated cmdline in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requests atomic.Int32
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requests.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
|
||||
if result == nil {
|
||||
t.Fatal("PollDeviceToken() returned nil result")
|
||||
}
|
||||
if result.Message != "Polling was cancelled" {
|
||||
t.Fatalf("PollDeviceToken() message = %q, want polling cancellation", result.Message)
|
||||
}
|
||||
if got := requests.Load(); got != 0 {
|
||||
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const (
|
||||
needUserAuthorizationMarker = "need_user_authorization"
|
||||
)
|
||||
|
||||
// TokenRetryCodes contains error codes that allow retry after token refresh.
|
||||
var TokenRetryCodes = map[int]bool{
|
||||
output.LarkErrTokenInvalid: true,
|
||||
output.LarkErrTokenExpired: true,
|
||||
}
|
||||
|
||||
// NeedAuthorizationError is the sentinel preserved in the Cause chain of the
|
||||
// typed missing-UAT error so existing errors.As(&NeedAuthorizationError{})
|
||||
// consumers keep matching after the construction site moved to the typed
|
||||
// taxonomy. It is never surfaced on the wire on its own.
|
||||
type NeedAuthorizationError struct {
|
||||
UserOpenId string
|
||||
}
|
||||
|
||||
// Error returns the error message for NeedAuthorizationError.
|
||||
func (e *NeedAuthorizationError) Error() string {
|
||||
return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId)
|
||||
}
|
||||
|
||||
// NewNeedUserAuthorizationError builds the typed *errs.AuthenticationError
|
||||
// returned when no valid UAT exists for userOpenID. The Message keeps the
|
||||
// need_user_authorization marker, the Hint converges on the same auth-login
|
||||
// recovery vocabulary as the token-missing surface in internal/client, and the
|
||||
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
|
||||
// errors.As / errors.Is traversal.
|
||||
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
|
||||
WithUserOpenID(userOpenID).
|
||||
WithHint("run: lark-cli auth login to re-authorize").
|
||||
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
|
||||
}
|
||||
|
||||
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
|
||||
// failure. It matches the legacy *NeedAuthorizationError sentinel, which is
|
||||
// preserved in the Cause chain of the typed missing-UAT error, so errors.As
|
||||
// traverses into the typed *errs.AuthenticationError as well.
|
||||
func IsNeedUserAuthorizationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var needAuthErr *NeedAuthorizationError
|
||||
return errors.As(err, &needAuthErr)
|
||||
}
|
||||
|
||||
// SecurityPolicyError is preserved as a Go type alias so existing
|
||||
// errors.As(&SecurityPolicyError{}) consumers (cmd/root.go etc.) keep working.
|
||||
// The concrete struct lives in errs/types.go.
|
||||
type SecurityPolicyError = errs.SecurityPolicyError
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestIsNeedUserAuthorizationError(t *testing.T) {
|
||||
t.Run("nil error", func(t *testing.T) {
|
||||
if IsNeedUserAuthorizationError(nil) {
|
||||
t.Fatal("expected nil error not to match")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct auth error", func(t *testing.T) {
|
||||
if !IsNeedUserAuthorizationError(&NeedAuthorizationError{UserOpenId: "u_1"}) {
|
||||
t.Fatal("expected direct NeedAuthorizationError to match")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed missing-UAT error carries sentinel in cause", func(t *testing.T) {
|
||||
// The typed constructor preserves the legacy sentinel in the Cause
|
||||
// chain, so errors.As traverses into it.
|
||||
if !IsNeedUserAuthorizationError(NewNeedUserAuthorizationError("u_1")) {
|
||||
t.Fatal("expected typed missing-UAT error to match via its cause chain")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other error", func(t *testing.T) {
|
||||
err := errs.NewNetworkError(errs.SubtypeNetworkTransport, "API call failed: timeout")
|
||||
if IsNeedUserAuthorizationError(err) {
|
||||
t.Fatal("expected unrelated error not to match")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
// Common authentication paths used for logging and API calls.
|
||||
const (
|
||||
// PathDeviceAuthorization is the endpoint for device authorization.
|
||||
PathDeviceAuthorization = "/oauth/v1/device_authorization"
|
||||
// PathOAuthRevoke is the endpoint for revoking an OAuth token.
|
||||
PathOAuthRevoke = "/oauth/v1/revoke"
|
||||
// PathAppRegistration is the endpoint for application registration.
|
||||
PathAppRegistration = "/oauth/v1/app/registration"
|
||||
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).
|
||||
PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"
|
||||
// PathUserInfoV1 is the endpoint for fetching user information.
|
||||
PathUserInfoV1 = "/open-apis/authen/v1/user_info"
|
||||
// PathApplicationInfoV6Prefix is the prefix endpoint for fetching application info.
|
||||
PathApplicationInfoV6Prefix = "/open-apis/application/v6/applications/"
|
||||
)
|
||||
|
||||
// ApplicationInfoPath returns the full API path for querying an application's information.
|
||||
func ApplicationInfoPath(appId string) string {
|
||||
return PathApplicationInfoV6Prefix + appId
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// RevokeToken revokes a previously issued OAuth token.
|
||||
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
|
||||
endpoints := ResolveOAuthEndpoints(brand)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", appId)
|
||||
form.Set("client_secret", appSecret)
|
||||
form.Set("token", token)
|
||||
if tokenTypeHint != "" {
|
||||
form.Set("token_type_hint", tokenTypeHint)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return revokeHTTPStatusError(resp.StatusCode, body)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if code := getInt(data, "code", 0); code != 0 {
|
||||
msg := getStr(data, "msg")
|
||||
if msg == "" {
|
||||
msg = getStr(data, "message")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "unknown error"
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
|
||||
WithCode(code).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
if errStr := getStr(data, "error"); errStr != "" {
|
||||
msg := getStr(data, "error_description")
|
||||
if msg == "" {
|
||||
msg = errStr
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeHTTPStatusError(status int, body []byte) error {
|
||||
msg := formatOAuthErrorBody(body)
|
||||
cause := errors.New(strings.TrimSpace(string(body)))
|
||||
if strings.TrimSpace(string(body)) == "" {
|
||||
cause = errors.New(msg)
|
||||
}
|
||||
if status >= http.StatusInternalServerError {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithRetryable().
|
||||
WithCause(cause)
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if status == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithCause(cause)
|
||||
}
|
||||
|
||||
func formatOAuthErrorBody(body []byte) string {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return "empty response"
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if msg := getStr(data, "error_description"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "msg"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "message"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "error"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
type errReadCloser struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r errReadCloser) Read(_ []byte) (int, error) {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
func (r errReadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_a" &&
|
||||
values.Get("client_secret") == "secret_b" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeToken() error = %v", err)
|
||||
}
|
||||
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
|
||||
sentinel := errors.New("transport down")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, sentinel
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %#v, want network/transport", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"error": "invalid_token"},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 400 {
|
||||
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_token") {
|
||||
t.Fatalf("expected invalid_token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"code": 12345,
|
||||
"msg": "invalid revoke state",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 12345 {
|
||||
t.Fatalf("problem = %#v, want api error with code 12345", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid revoke state") {
|
||||
t.Fatalf("expected oauth error message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"error": "invalid_token",
|
||||
"error_description": "token already expired",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("problem = %#v, want api error", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token already expired") {
|
||||
t.Fatalf("expected oauth error_description, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
|
||||
sentinel := errors.New("read failed")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: errReadCloser{err: sentinel},
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token revoke read error") {
|
||||
t.Fatalf("expected read error message, got %v", err)
|
||||
}
|
||||
if _, ok := err.(*errs.InternalError); !ok {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import "strings"
|
||||
|
||||
// MissingScopes returns the elements of required that are absent from storedScope.
|
||||
// storedScope is a space-separated list of granted scope strings (as stored in the token).
|
||||
func MissingScopes(storedScope string, required []string) []string {
|
||||
granted := make(map[string]bool)
|
||||
for _, s := range strings.Fields(storedScope) {
|
||||
granted[s] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, s := range required {
|
||||
if !granted[s] {
|
||||
missing = append(missing, s)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMissingScopes tests the calculation of missing scopes.
|
||||
func TestMissingScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
storedScope string
|
||||
required []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "all matched",
|
||||
storedScope: "a b c",
|
||||
required: []string{"a", "b"},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "partial missing",
|
||||
storedScope: "a b",
|
||||
required: []string{"a", "c"},
|
||||
expected: []string{"c"},
|
||||
},
|
||||
{
|
||||
name: "all missing",
|
||||
storedScope: "a b",
|
||||
required: []string{"x", "y"},
|
||||
expected: []string{"x", "y"},
|
||||
},
|
||||
{
|
||||
name: "empty storedScope",
|
||||
storedScope: "",
|
||||
required: []string{"a"},
|
||||
expected: []string{"a"},
|
||||
},
|
||||
{
|
||||
name: "empty required",
|
||||
storedScope: "a b",
|
||||
required: []string{},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "extra whitespace in storedScope",
|
||||
storedScope: " a b c ",
|
||||
required: []string{"b"},
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := MissingScopes(tt.storedScope, tt.required)
|
||||
if !sliceEqual(got, tt.expected) {
|
||||
t.Errorf("MissingScopes(%q, %v) = %v, want %v", tt.storedScope, tt.required, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sliceEqual compares two string slices for equality.
|
||||
func sliceEqual(a, b []string) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
// StoredUAToken represents a stored user access token.
|
||||
type StoredUAToken struct {
|
||||
UserOpenId string `json:"userOpenId"`
|
||||
AppId string `json:"appId"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
ExpiresAt int64 `json:"expiresAt"` // Unix ms
|
||||
RefreshExpiresAt int64 `json:"refreshExpiresAt"` // Unix ms
|
||||
Scope string `json:"scope"`
|
||||
GrantedAt int64 `json:"grantedAt"` // Unix ms
|
||||
}
|
||||
|
||||
const refreshAheadMs = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
// accountKey generates a unique key for an account based on its AppID and UserOpenID.
|
||||
func accountKey(appId, userOpenId string) string {
|
||||
return fmt.Sprintf("%s:%s", appId, userOpenId)
|
||||
}
|
||||
|
||||
// MaskToken masks a token for safe logging.
|
||||
func MaskToken(token string) string {
|
||||
if len(token) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return "****" + token[len(token)-4:]
|
||||
}
|
||||
|
||||
// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair.
|
||||
func GetStoredToken(appId, userOpenId string) *StoredUAToken {
|
||||
jsonStr, err := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
|
||||
if err != nil || jsonStr == "" {
|
||||
return nil
|
||||
}
|
||||
var token StoredUAToken
|
||||
if err := json.Unmarshal([]byte(jsonStr), &token); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &token
|
||||
}
|
||||
|
||||
// SetStoredToken persists a UAT.
|
||||
func SetStoredToken(token *StoredUAToken) error {
|
||||
key := accountKey(token.AppId, token.UserOpenId)
|
||||
data, err := json.Marshal(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return keychain.Set(keychain.LarkCliService, key, string(data))
|
||||
}
|
||||
|
||||
// RemoveStoredToken removes a stored UAT.
|
||||
func RemoveStoredToken(appId, userOpenId string) error {
|
||||
return keychain.Remove(keychain.LarkCliService, accountKey(appId, userOpenId))
|
||||
}
|
||||
|
||||
// TokenStatus determines the freshness of a stored token.
|
||||
func TokenStatus(token *StoredUAToken) string {
|
||||
now := time.Now().UnixMilli()
|
||||
if now < token.ExpiresAt-refreshAheadMs {
|
||||
return "valid"
|
||||
}
|
||||
if now < token.RefreshExpiresAt {
|
||||
return "needs_refresh"
|
||||
}
|
||||
return "expired"
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||
// and checks for security policy errors.
|
||||
type SecurityPolicyTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
// base returns the underlying RoundTripper or http.DefaultTransport if nil.
|
||||
func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base().RoundTrip(req)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if resp == nil || resp.Body == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Only process JSON responses to avoid memory spikes on large files
|
||||
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Read up to 64KB of the body to check for security policy errors
|
||||
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("failed to read response body in security transport: %w", err)
|
||||
}
|
||||
|
||||
// Restore the body so it can be read by the caller, preserving streaming capability
|
||||
resp.Body = struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
io.MultiReader(bytes.NewReader(bodyBytes), resp.Body),
|
||||
resp.Body,
|
||||
}
|
||||
|
||||
// Try to parse it as JSON
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 1. Try to handle as MCP (JSON-RPC) format first
|
||||
if err := t.tryHandleMCPResponse(result); err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Try to handle as OpenAPI error format
|
||||
if err := t.tryHandleOAPIResponse(result); err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error
|
||||
// response coming back from a remote server (this transport is installed on
|
||||
// lark-cli's outbound HTTP client; the bodies it inspects are produced by the
|
||||
// remote, not by lark-cli itself).
|
||||
//
|
||||
// Observed production shape from the MCP gateway — Lark code in the outer
|
||||
// `error.code` slot, hint under `data.cli_hint`:
|
||||
//
|
||||
// {"jsonrpc": "2.0", "id": 1,
|
||||
// "error": {"code": 21000, "message": "...",
|
||||
// "data": {"challenge_url": "...", "cli_hint": "..."}}}
|
||||
//
|
||||
// The parser also accepts a JSON-RPC-canonical shape (outer `error.code`
|
||||
// carrying the JSON-RPC status like -32603, Lark code under `error.data.code`,
|
||||
// hint under `data.hint`) so a future server-side migration to that layout
|
||||
// would not silently drop policy detection. The Lark code is looked up in the
|
||||
// central code registry; the hint key is read from `data.hint` first and
|
||||
// falls back to `data.cli_hint`.
|
||||
func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error {
|
||||
errMap, ok := result["error"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
dataMap, _ := errMap["data"].(map[string]interface{})
|
||||
|
||||
// Try data.code first (shape B); fall back to outer error.code (shape A).
|
||||
code := 0
|
||||
if dataMap != nil {
|
||||
code = getInt(dataMap, "code", 0)
|
||||
}
|
||||
if code == 0 {
|
||||
code = getInt(errMap, "code", 0)
|
||||
}
|
||||
meta, ok := errclass.LookupCodeMeta(code)
|
||||
if !ok || meta.Category != errs.CategoryPolicy {
|
||||
return nil
|
||||
}
|
||||
|
||||
if dataMap == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clean up backticks and spaces from challenge_url
|
||||
challengeUrl := strings.Trim(getStr(dataMap, "challenge_url"), " `")
|
||||
// Read `hint` first; fall back to `cli_hint` so either spelling surfaces.
|
||||
cliHint := getStr(dataMap, "hint")
|
||||
if cliHint == "" {
|
||||
cliHint = getStr(dataMap, "cli_hint")
|
||||
}
|
||||
msg := getStr(errMap, "message")
|
||||
|
||||
if challengeUrl != "" || cliHint != "" {
|
||||
// Security validation for challengeUrl
|
||||
if challengeUrl != "" && !isValidChallengeURL(challengeUrl) {
|
||||
challengeUrl = ""
|
||||
}
|
||||
|
||||
if challengeUrl != "" || cliHint != "" {
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Hint: cliHint,
|
||||
},
|
||||
ChallengeURL: challengeUrl,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryHandleOAPIResponse attempts to parse a standard Lark OpenAPI formatted error response.
|
||||
func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interface{}) error {
|
||||
// 1. Extract code
|
||||
code := getInt(result, "code", 0)
|
||||
|
||||
// If code is 0, check if it's already in our error format {"error": {"code": 21000, ...}, "ok": false}
|
||||
if code == 0 {
|
||||
if errMap, ok := result["error"].(map[string]interface{}); ok {
|
||||
code = getInt(errMap, "code", 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if it's a security policy error (consult central code registry)
|
||||
meta, ok := errclass.LookupCodeMeta(code)
|
||||
if !ok || meta.Category != errs.CategoryPolicy {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. Extract details
|
||||
var challengeUrl, cliHint, msg string
|
||||
if dataMap, ok := result["data"].(map[string]interface{}); ok {
|
||||
// Standard OAPI format
|
||||
challengeUrl = getStr(dataMap, "challenge_url")
|
||||
cliHint = getStr(dataMap, "cli_hint")
|
||||
msg = getStr(result, "msg")
|
||||
} else if errMap, ok := result["error"].(map[string]interface{}); ok {
|
||||
// Already formatted error format (e.g. from internal API or CLI output)
|
||||
challengeUrl = getStr(errMap, "challenge_url")
|
||||
cliHint = getStr(errMap, "hint")
|
||||
msg = getStr(errMap, "message")
|
||||
}
|
||||
|
||||
// 4. Print and exit if we have enough info
|
||||
if msg != "" || challengeUrl != "" || cliHint != "" {
|
||||
// Security validation for challengeUrl
|
||||
if challengeUrl != "" && !isValidChallengeURL(challengeUrl) {
|
||||
challengeUrl = ""
|
||||
}
|
||||
|
||||
if msg != "" || challengeUrl != "" || cliHint != "" {
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Hint: cliHint,
|
||||
},
|
||||
ChallengeURL: challengeUrl,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidChallengeURL checks if the given URL is a valid challenge URL.
|
||||
func isValidChallengeURL(rawURL string) bool {
|
||||
if rawURL == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. Must be https
|
||||
if u.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestTryHandleMCPResponse_RecognisesDataCode pins the parser's primary path:
|
||||
// when the outer `error.code` carries a JSON-RPC status (e.g. -32603) and the
|
||||
// Lark numeric code lives in `error.data.code`, the transport reads `data.code`
|
||||
// to look up the codeMeta and converts the response into *errs.SecurityPolicyError.
|
||||
// This shape is forward-compat for a future server-side migration to the
|
||||
// JSON-RPC-canonical layout; see also TestTryHandleMCPResponse_FallsBackToOuterCode
|
||||
// for the shape observed in production today.
|
||||
func TestTryHandleMCPResponse_RecognisesDataCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32603, // JSON-RPC internal error
|
||||
"message": "challenge required",
|
||||
"data": map[string]interface{}{
|
||||
"code": 21000, // Lark code for challenge_required
|
||||
"type": "policy",
|
||||
"subtype": "challenge_required",
|
||||
"challenge_url": "https://example.com/challenge",
|
||||
"hint": "please complete the challenge in your browser",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := transport.tryHandleMCPResponse(result)
|
||||
var spErr *errs.SecurityPolicyError
|
||||
if !errors.As(got, &spErr) {
|
||||
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
|
||||
}
|
||||
if spErr.Code != 21000 {
|
||||
t.Errorf("Code = %d, want 21000", spErr.Code)
|
||||
}
|
||||
if spErr.Subtype != errs.SubtypeChallengeRequired {
|
||||
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeChallengeRequired)
|
||||
}
|
||||
if spErr.ChallengeURL != "https://example.com/challenge" {
|
||||
t.Errorf("ChallengeURL = %q", spErr.ChallengeURL)
|
||||
}
|
||||
if spErr.Hint != "please complete the challenge in your browser" {
|
||||
t.Errorf("Hint = %q", spErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryHandleMCPResponse_FallsBackToOuterCode pins the inbound shape observed
|
||||
// in production from the MCP gateway: the Lark code sits in the outer
|
||||
// `error.code` slot (no `data.code`), and the hint surfaces as `data.cli_hint`.
|
||||
// The transport's outer-code fallback path must recognise the policy code and
|
||||
// surface the typed error with the hint promoted.
|
||||
func TestTryHandleMCPResponse_FallsBackToOuterCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"code": 21001, // outer slot carries the Lark code
|
||||
"message": "access denied",
|
||||
"data": map[string]interface{}{
|
||||
"challenge_url": "https://example.com/c",
|
||||
"cli_hint": "contact admin",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := transport.tryHandleMCPResponse(result)
|
||||
var spErr *errs.SecurityPolicyError
|
||||
if !errors.As(got, &spErr) {
|
||||
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
|
||||
}
|
||||
if spErr.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeAccessDenied)
|
||||
}
|
||||
// `cli_hint` must surface when `hint` is absent.
|
||||
if spErr.Hint != "contact admin" {
|
||||
t.Errorf("Hint = %q, want fallback from cli_hint", spErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryHandleMCPResponse_NonPolicyCodeIgnored verifies the transport returns
|
||||
// nil (passes through) when the Lark code does not classify as
|
||||
// CategoryPolicy — keeps regular API errors out of the security-policy path.
|
||||
func TestTryHandleMCPResponse_NonPolicyCodeIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"code": -32603,
|
||||
"message": "permission denied",
|
||||
"data": map[string]interface{}{
|
||||
"code": 99991672, // app_scope_not_enabled — Authorization, not Policy
|
||||
"type": "authorization",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := transport.tryHandleMCPResponse(result); err != nil {
|
||||
t.Fatalf("expected nil (non-policy code), got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// 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 "", NewNeedUserAuthorizationError(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 "", NewNeedUserAuthorizationError(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 "", NewNeedUserAuthorizationError(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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestNewUATCallOptions validates the extraction of options from CLI config.
|
||||
func TestNewUATCallOptions(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "app123",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandLark,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
errOut := &bytes.Buffer{}
|
||||
|
||||
opts := NewUATCallOptions(cfg, errOut)
|
||||
|
||||
if opts.AppId != "app123" {
|
||||
t.Errorf("AppId = %q, want app123", opts.AppId)
|
||||
}
|
||||
if opts.AppSecret != "secret" {
|
||||
t.Errorf("AppSecret = %q, want secret", opts.AppSecret)
|
||||
}
|
||||
if opts.Domain != core.BrandLark {
|
||||
t.Errorf("Domain = %q, want lark", opts.Domain)
|
||||
}
|
||||
if opts.UserOpenId != "ou_test" {
|
||||
t.Errorf("UserOpenId = %q, want ou_test", opts.UserOpenId)
|
||||
}
|
||||
if opts.ErrOut != errOut {
|
||||
t.Error("ErrOut not set correctly")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// VerifyUserToken calls /authen/v1/user_info to confirm the token is accepted server-side.
|
||||
// Returns nil on success or an error describing why the server rejected the token.
|
||||
func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string) error {
|
||||
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: PathUserInfoV1,
|
||||
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser},
|
||||
}, larkcore.WithUserAccessToken(accessToken))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logSDKResponse(PathUserInfoV1, apiResp)
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return fmt.Errorf("[%d] %s", resp.Code, resp.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestVerifyUserToken_TransportError verifies handling of underlying transport errors.
|
||||
func TestVerifyUserToken_TransportError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
// Register no stubs — any request will fail with "no stub" error
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(httpmock.NewClient(reg)),
|
||||
)
|
||||
|
||||
err := VerifyUserToken(context.Background(), sdk, "test-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error from transport failure, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyUserToken validates normal and error response paths of the user token validation.
|
||||
func TestVerifyUserToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body interface{}
|
||||
wantErr bool
|
||||
errSubstr string
|
||||
wantLog bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
wantErr: false,
|
||||
wantLog: true,
|
||||
},
|
||||
{
|
||||
name: "token invalid",
|
||||
body: map[string]interface{}{"code": 99991668, "msg": "invalid token"},
|
||||
wantErr: true,
|
||||
errSubstr: "[99991668]",
|
||||
wantLog: true,
|
||||
},
|
||||
{
|
||||
name: "non-JSON response",
|
||||
body: "not json",
|
||||
wantErr: true,
|
||||
errSubstr: "invalid character",
|
||||
wantLog: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: PathUserInfoV1,
|
||||
Body: tt.body,
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Tt-Logid": []string{"verify-log-id"},
|
||||
},
|
||||
})
|
||||
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(httpmock.NewClient(reg)),
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
|
||||
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
|
||||
}, func() []string {
|
||||
return []string{"lark-cli", "auth", "status"}
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
err := VerifyUserToken(context.Background(), sdk, "test-token")
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tt.errSubstr)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
got := buf.String()
|
||||
if tt.wantLog {
|
||||
if !strings.Contains(got, "path="+PathUserInfoV1) {
|
||||
t.Fatalf("expected path in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "status=200") {
|
||||
t.Fatalf("expected status=200 in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "x-tt-logid=verify-log-id") {
|
||||
t.Fatalf("expected x-tt-logid in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "cmdline=lark-cli auth status") {
|
||||
t.Fatalf("expected cmdline in log, got %q", got)
|
||||
}
|
||||
} else if got != "" {
|
||||
t.Fatalf("expected no log output, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user