mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
103 lines
4.0 KiB
Go
103 lines
4.0 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package credential
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/internal/core"
|
|
)
|
|
|
|
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
|
|
// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the
|
|
// client_credentials grant with client_secret_post authentication. It does not
|
|
// read configuration or keychain, so callers that already hold plaintext
|
|
// credentials (e.g. the post-`config init` probe) can validate them without a
|
|
// second keychain round-trip.
|
|
//
|
|
// A deterministic client-side rejection (e.g. invalid_client) returns the
|
|
// canonical typed error from classifyTATResponseCode — the SAME classification
|
|
// doResolveTAT (and thus every token-resolving command) produces, so callers
|
|
// see one consistent envelope. Transport failures, unreadable/unparseable
|
|
// bodies, and transient server-side failures (5xx / server_error) are returned
|
|
// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a
|
|
// deterministic credential rejection apart from upstream/transport noise.
|
|
//
|
|
// The caller owns the context timeout.
|
|
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
|
|
ep := core.ResolveEndpoints(brand)
|
|
endpoint := ep.Accounts + core.OAuthTokenV3Path
|
|
|
|
form := url.Values{}
|
|
form.Set("grant_type", "client_credentials")
|
|
form.Set("client_id", appID)
|
|
form.Set("client_secret", appSecret)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read TAT response: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Code int `json:"code"`
|
|
AccessToken string `json:"access_token"`
|
|
Error string `json:"error"`
|
|
ErrorDescription string `json:"error_description"`
|
|
Msg string `json:"msg"`
|
|
}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
// An unparseable body is ambiguous (covers non-JSON error pages and
|
|
// truncated payloads); stay untyped so probe callers treat it as noise.
|
|
return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err)
|
|
}
|
|
|
|
if result.Code == 0 && result.AccessToken != "" {
|
|
return result.AccessToken, nil
|
|
}
|
|
|
|
// Transient/server-side failures stay untyped so probe callers stay silent and
|
|
// retryers can back off; only deterministic client rejections are typed. Covers
|
|
// 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error,
|
|
// temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise"
|
|
// behavior so a rate-limited probe is not surfaced as a hard credential error.
|
|
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests ||
|
|
result.Error == "server_error" || result.Error == "temporarily_unavailable" ||
|
|
result.Error == "slow_down" {
|
|
return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s",
|
|
resp.StatusCode, result.Code, result.Error, result.ErrorDescription)
|
|
}
|
|
|
|
// A 2xx with neither token nor error is a malformed success — ambiguous, untyped.
|
|
if result.Code == 0 && result.Error == "" {
|
|
return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode)
|
|
}
|
|
|
|
// Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a
|
|
// gateway-level {code, msg} response (carrying no OAuth fields) still yields a
|
|
// non-empty typed message instead of a bare "API error: [code]".
|
|
desc := result.ErrorDescription
|
|
if desc == "" {
|
|
desc = result.Msg
|
|
}
|
|
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
|
|
}
|