mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.
Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift
Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.
Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.
At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.
First PR in the feat/error-contract-* series.
239 lines
6.4 KiB
Go
239 lines
6.4 KiB
Go
// 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/util"
|
|
)
|
|
|
|
// 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 util.FallbackTransport()
|
|
}
|
|
|
|
// 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
|
|
}
|