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.
237 lines
7.4 KiB
Go
237 lines
7.4 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
larkauth "github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
type loginScopeSummary struct {
|
|
Requested []string
|
|
NewlyGranted []string
|
|
AlreadyGranted []string
|
|
Granted []string
|
|
Missing []string
|
|
}
|
|
|
|
type loginScopeIssue struct {
|
|
Message string
|
|
Hint string
|
|
Summary *loginScopeSummary
|
|
}
|
|
|
|
// ensureRequestedScopesGranted checks whether all requested scopes were granted
|
|
// and returns a structured issue when any requested scope is missing.
|
|
func ensureRequestedScopesGranted(requestedScope, grantedScope string, msg *loginMsg, summary *loginScopeSummary) *loginScopeIssue {
|
|
requested := uniqueScopeList(requestedScope)
|
|
if len(requested) == 0 {
|
|
return nil
|
|
}
|
|
|
|
missing := larkauth.MissingScopes(grantedScope, requested)
|
|
if len(missing) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if summary == nil {
|
|
summary = &loginScopeSummary{
|
|
Requested: requested,
|
|
Granted: strings.Fields(grantedScope),
|
|
Missing: missing,
|
|
}
|
|
}
|
|
return &loginScopeIssue{
|
|
Message: fmt.Sprintf(msg.ScopeMismatch, strings.Join(missing, " ")),
|
|
Hint: msg.ScopeHint,
|
|
Summary: summary,
|
|
}
|
|
}
|
|
|
|
// loadLoginScopeSummary builds a scope summary by comparing the requested scopes,
|
|
// previously stored scopes, and the newly granted scopes from the current login.
|
|
func loadLoginScopeSummary(appID, openId, requestedScope, grantedScope string) *loginScopeSummary {
|
|
previousScope := ""
|
|
if previous := larkauth.GetStoredToken(appID, openId); previous != nil {
|
|
previousScope = previous.Scope
|
|
}
|
|
return buildLoginScopeSummary(requestedScope, previousScope, grantedScope)
|
|
}
|
|
|
|
// buildLoginScopeSummary classifies requested scopes into newly granted,
|
|
// already granted, and missing buckets while preserving the final granted list.
|
|
func buildLoginScopeSummary(requestedScope, previousScope, grantedScope string) *loginScopeSummary {
|
|
requested := uniqueScopeList(requestedScope)
|
|
previous := uniqueScopeList(previousScope)
|
|
granted := uniqueScopeList(grantedScope)
|
|
previousSet := make(map[string]bool, len(previous))
|
|
for _, scope := range previous {
|
|
previousSet[scope] = true
|
|
}
|
|
grantedSet := make(map[string]bool, len(granted))
|
|
for _, scope := range granted {
|
|
grantedSet[scope] = true
|
|
}
|
|
|
|
summary := &loginScopeSummary{
|
|
Requested: requested,
|
|
Granted: granted,
|
|
}
|
|
for _, scope := range requested {
|
|
if !grantedSet[scope] {
|
|
summary.Missing = append(summary.Missing, scope)
|
|
continue
|
|
}
|
|
if previousSet[scope] {
|
|
summary.AlreadyGranted = append(summary.AlreadyGranted, scope)
|
|
continue
|
|
}
|
|
summary.NewlyGranted = append(summary.NewlyGranted, scope)
|
|
}
|
|
return summary
|
|
}
|
|
|
|
// uniqueScopeList splits a scope string into a de-duplicated ordered slice.
|
|
func uniqueScopeList(scope string) []string {
|
|
seen := make(map[string]bool)
|
|
var result []string
|
|
for _, item := range strings.Fields(scope) {
|
|
if seen[item] {
|
|
continue
|
|
}
|
|
seen[item] = true
|
|
result = append(result, item)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// formatScopeList joins scopes for display and falls back to the provided empty
|
|
// label when the input slice is empty.
|
|
func formatScopeList(scopes []string, empty string) string {
|
|
if len(scopes) == 0 {
|
|
return empty
|
|
}
|
|
return strings.Join(scopes, " ")
|
|
}
|
|
|
|
// emptyIfNil normalizes nil slices to empty slices for stable JSON output.
|
|
func emptyIfNil(s []string) []string {
|
|
if s == nil {
|
|
return []string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// writeLoginScopeBreakdown renders the requested/newly granted scope
|
|
// breakdown to stderr.
|
|
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
|
|
if summary == nil {
|
|
summary = &loginScopeSummary{}
|
|
}
|
|
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
|
|
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
|
|
}
|
|
|
|
// writeLoginSuccess emits the successful login payload in either JSON or text
|
|
// format together with the computed scope breakdown.
|
|
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) {
|
|
if summary == nil {
|
|
summary = &loginScopeSummary{}
|
|
}
|
|
if opts.JSON {
|
|
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil))
|
|
fmt.Fprintln(f.IOStreams.Out, string(b))
|
|
return
|
|
}
|
|
|
|
fmt.Fprintln(f.IOStreams.ErrOut)
|
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
|
writeLoginScopeBreakdown(f.IOStreams, msg, summary)
|
|
if len(summary.Missing) == 0 && msg.StatusHint != "" {
|
|
fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint)
|
|
}
|
|
}
|
|
|
|
// handleLoginScopeIssue prints or returns a structured missing-scope result
|
|
// while preserving a successful login outcome when authorization completed.
|
|
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error {
|
|
if issue == nil {
|
|
return nil
|
|
}
|
|
loginSucceeded := openId != ""
|
|
if opts.JSON {
|
|
if loginSucceeded {
|
|
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
|
|
fmt.Fprintln(f.IOStreams.Out, string(b))
|
|
return output.ErrBare(output.ExitAuth)
|
|
}
|
|
detail := map[string]interface{}{
|
|
"requested": issue.Summary.Requested,
|
|
"granted": issue.Summary.Granted,
|
|
"missing": issue.Summary.Missing,
|
|
}
|
|
// Legacy *output.ExitError producer: this literal predates the typed
|
|
// error contract introduced by errs/. New code MUST NOT construct
|
|
// *output.ExitError directly — missing-scope signals should move to
|
|
// *errs.PermissionError (with MissingScopes/ConsoleURL as typed
|
|
// extension fields) when the login flow migrates to typed errors.
|
|
return &output.ExitError{
|
|
Code: output.ExitAuth,
|
|
Detail: &output.ErrDetail{
|
|
Type: "missing_scope",
|
|
Message: issue.Message,
|
|
Hint: issue.Hint,
|
|
Detail: detail,
|
|
},
|
|
}
|
|
}
|
|
|
|
fmt.Fprintln(f.IOStreams.ErrOut)
|
|
if loginSucceeded {
|
|
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
|
if msg.AuthorizedUser != "" {
|
|
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
|
|
}
|
|
} else {
|
|
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
|
}
|
|
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
|
|
if issue.Hint != "" {
|
|
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
|
|
}
|
|
return output.ErrBare(output.ExitAuth)
|
|
}
|
|
|
|
// authorizationCompletePayload builds the JSON payload for a completed login,
|
|
// optionally attaching a warning when requested scopes are missing.
|
|
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} {
|
|
if summary == nil {
|
|
summary = &loginScopeSummary{}
|
|
}
|
|
payload := map[string]interface{}{
|
|
"event": "authorization_complete",
|
|
"user_open_id": openId,
|
|
"user_name": userName,
|
|
"scope": strings.Join(summary.Granted, " "),
|
|
"requested": emptyIfNil(summary.Requested),
|
|
"newly_granted": emptyIfNil(summary.NewlyGranted),
|
|
"already_granted": emptyIfNil(summary.AlreadyGranted),
|
|
"missing": emptyIfNil(summary.Missing),
|
|
"granted": emptyIfNil(summary.Granted),
|
|
}
|
|
if issue != nil {
|
|
payload["warning"] = map[string]interface{}{
|
|
"type": "missing_scope",
|
|
"message": issue.Message,
|
|
"hint": issue.Hint,
|
|
}
|
|
}
|
|
return payload
|
|
}
|