mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Two issues caught in review of #1132 that the existing tests missed because they constructed RuntimeContext/CliConfig directly, bypassing the credential edge where the bug lives. P1 — Lang dropped at credential boundary credential.Account had no Lang field, so AccountFromCliConfig and ToCliConfig silently dropped cfg.Lang. The production Factory builds CliConfig via acct.ToCliConfig() (factory_default.go Phase 3), which meant RuntimeContext.Lang() always returned "" in production and shortcuts/mail/mail_signature.go always fell back to zh_cn — defeating the whole point of persisting --lang. Fix: add Lang i18n.Lang to Account and copy it in both directions. Regression test: TestFullChain_LangSurvivesProductionPath walks the real path (SaveMultiAppConfig -> DefaultAccountProvider.ResolveAccount -> ToCliConfig) and asserts Lang survives, so any future field added to CliConfig forces the same audit. P2 — priorLang ignored CurrentApp in multi-profile workspaces priorLang scanned all Apps and returned the first non-empty Lang. If a user had multiple profiles and the active one disagreed with Apps[0], a re-bind without --lang would silently inherit the wrong profile's preference. Fix: read multi.CurrentAppConfig("").Lang instead. Regression tests cover CurrentApp wins over Apps[0], single-app fallback, and malformed bytes. Change-Id: If7a276605f84f398cec329c2c942b471b4c32749
180 lines
5.3 KiB
Go
180 lines
5.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package credential
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
extcred "github.com/larksuite/cli/extension/credential"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/i18n"
|
|
)
|
|
|
|
// Account is the credential-layer view of the active runtime account.
|
|
// It intentionally mirrors only the resolved fields needed by runtime auth
|
|
// and identity selection, without exposing core.CliConfig as a dependency.
|
|
type Account struct {
|
|
ProfileName string
|
|
AppID string
|
|
AppSecret string
|
|
Brand core.LarkBrand
|
|
DefaultAs core.Identity
|
|
UserOpenId string
|
|
UserName string
|
|
Lang i18n.Lang
|
|
SupportedIdentities uint8
|
|
}
|
|
|
|
const runtimePlaceholderAppSecret = "__LARKSUITE_CLI_TOKEN_ONLY__"
|
|
|
|
// HasRealAppSecret reports whether secret is an actual app secret rather than
|
|
// an empty/token-only marker or the internal runtime placeholder.
|
|
func HasRealAppSecret(secret string) bool {
|
|
return secret != "" && secret != runtimePlaceholderAppSecret
|
|
}
|
|
|
|
// RuntimeAppSecret returns the SDK-compatible app secret used at runtime.
|
|
// Token-only sources intentionally have no real secret; this helper injects a
|
|
// private placeholder so downstream SDK validation can proceed while callers
|
|
// still distinguish real secrets with HasRealAppSecret.
|
|
func RuntimeAppSecret(secret string) string {
|
|
if HasRealAppSecret(secret) {
|
|
return secret
|
|
}
|
|
return runtimePlaceholderAppSecret
|
|
}
|
|
|
|
func normalizeAccountAppSecret(secret string) string {
|
|
if HasRealAppSecret(secret) {
|
|
return secret
|
|
}
|
|
return extcred.NoAppSecret
|
|
}
|
|
|
|
// AccountFromCliConfig copies the resolved config view into a credential.Account.
|
|
func AccountFromCliConfig(cfg *core.CliConfig) *Account {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
return &Account{
|
|
ProfileName: cfg.ProfileName,
|
|
AppID: cfg.AppID,
|
|
AppSecret: normalizeAccountAppSecret(cfg.AppSecret),
|
|
Brand: cfg.Brand,
|
|
DefaultAs: cfg.DefaultAs,
|
|
UserOpenId: cfg.UserOpenId,
|
|
UserName: cfg.UserName,
|
|
Lang: cfg.Lang,
|
|
SupportedIdentities: cfg.SupportedIdentities,
|
|
}
|
|
}
|
|
|
|
// ToCliConfig copies the credential-layer account into the downstream config shape.
|
|
func (a *Account) ToCliConfig() *core.CliConfig {
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
return &core.CliConfig{
|
|
ProfileName: a.ProfileName,
|
|
AppID: a.AppID,
|
|
AppSecret: normalizeAccountAppSecret(a.AppSecret),
|
|
Brand: a.Brand,
|
|
DefaultAs: a.DefaultAs,
|
|
UserOpenId: a.UserOpenId,
|
|
UserName: a.UserName,
|
|
Lang: a.Lang,
|
|
SupportedIdentities: a.SupportedIdentities,
|
|
}
|
|
}
|
|
|
|
// AccountProvider resolves app credentials.
|
|
// Returns nil, nil to indicate "I don't handle this, try next provider".
|
|
type AccountProvider interface {
|
|
ResolveAccount(ctx context.Context) (*Account, error)
|
|
}
|
|
|
|
// TokenType distinguishes UAT from TAT.
|
|
// Uses string constants matching extension/credential.TokenType for zero-cost conversion.
|
|
type TokenType string
|
|
|
|
const (
|
|
TokenTypeUAT TokenType = "uat" // User Access Token
|
|
TokenTypeTAT TokenType = "tat" // Tenant Access Token
|
|
)
|
|
|
|
func (t TokenType) String() string { return string(t) }
|
|
|
|
// ParseTokenType converts a string to TokenType.
|
|
func ParseTokenType(s string) (TokenType, bool) {
|
|
switch strings.ToLower(s) {
|
|
case "uat":
|
|
return TokenTypeUAT, true
|
|
case "tat":
|
|
return TokenTypeTAT, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// TokenSpec is the input to TokenProvider.ResolveToken.
|
|
type TokenSpec struct {
|
|
Type TokenType
|
|
AppID string // identifies which app (multi-account); not sensitive
|
|
}
|
|
|
|
// TokenResult is the output of TokenProvider.ResolveToken.
|
|
type TokenResult struct {
|
|
Token string
|
|
Scopes string // optional, space-separated; empty = skip scope pre-check
|
|
}
|
|
|
|
// IdentityHint is credential-layer guidance for resolving the effective identity.
|
|
type IdentityHint struct {
|
|
DefaultAs core.Identity
|
|
AutoAs core.Identity
|
|
}
|
|
|
|
// TokenUnavailableError reports that no usable token was available.
|
|
type TokenUnavailableError struct {
|
|
Source string
|
|
Type TokenType
|
|
}
|
|
|
|
func (e *TokenUnavailableError) Error() string {
|
|
if e.Source != "" {
|
|
return fmt.Sprintf("no %s available from credential source %q", e.Type, e.Source)
|
|
}
|
|
return fmt.Sprintf("no credential provider returned a token for %s", e.Type)
|
|
}
|
|
|
|
// MalformedTokenResultError reports that a source returned an invalid token payload.
|
|
type MalformedTokenResultError struct {
|
|
Source string
|
|
Type TokenType
|
|
Reason string
|
|
}
|
|
|
|
func (e *MalformedTokenResultError) Error() string {
|
|
return fmt.Sprintf("credential source %q returned malformed %s token: %s", e.Source, e.Type, e.Reason)
|
|
}
|
|
|
|
// TokenProvider resolves a runtime access token.
|
|
// Top-level resolvers should return a non-nil token or an error.
|
|
// Chain participants may use nil, nil internally to indicate "try next source".
|
|
type TokenProvider interface {
|
|
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
|
}
|
|
|
|
// NewTokenSpec returns a TokenSpec with the token type automatically
|
|
// selected based on identity: TAT for bot, UAT for user.
|
|
func NewTokenSpec(identity core.Identity, appID string) TokenSpec {
|
|
t := TokenTypeUAT
|
|
if identity.IsBot() {
|
|
t = TokenTypeTAT
|
|
}
|
|
return TokenSpec{Type: t, AppID: appID}
|
|
}
|