mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 17:45:15 +08:00
Compare commits
23 Commits
codex/driv
...
feat/sessi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
425122e611 | ||
|
|
80fade789b | ||
|
|
cc5cec32dc | ||
|
|
251de0f609 | ||
|
|
d7156b327e | ||
|
|
a3c97572b6 | ||
|
|
978b711663 | ||
|
|
d22d8473a2 | ||
|
|
4c94bdca27 | ||
|
|
29d5b95971 | ||
|
|
fec11926be | ||
|
|
816a6939f2 | ||
|
|
04edca5717 | ||
|
|
a90715f5a9 | ||
|
|
f13cee23a2 | ||
|
|
e3cea8936e | ||
|
|
f91e8bec54 | ||
|
|
0c5aae42b4 | ||
|
|
bf8c41225d | ||
|
|
0c51792f77 | ||
|
|
e30869d015 | ||
|
|
a3e78daff9 | ||
|
|
8abd39b18e |
@@ -27,6 +27,9 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "View current auth status",
|
||||
Long: `Show OAuth user login, token validity, and granted scopes.
|
||||
For token-validity checks, run lark-cli auth status --json --verify.
|
||||
This is not profile/app selection diagnostics; use lark-cli whoami for the effective app/profile identity used by an invocation.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -13,6 +14,20 @@ import (
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAuthStatusHelpDistinguishesFromWhoami(t *testing.T) {
|
||||
cmd := NewCmdAuthStatus(nil, nil)
|
||||
for _, want := range []string{
|
||||
"OAuth user login",
|
||||
"auth status --json --verify",
|
||||
"not profile/app selection diagnostics",
|
||||
"lark-cli whoami",
|
||||
} {
|
||||
if !strings.Contains(cmd.Long, want) {
|
||||
t.Errorf("auth status --help Long missing %q; got:\n%s", want, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -6,8 +6,10 @@ package cmd
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -26,5 +28,13 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
|
||||
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
||||
return cmdutil.InvocationContext{}, err
|
||||
}
|
||||
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
|
||||
|
||||
profileFromFlag := globals.Profile != ""
|
||||
if !profileFromFlag {
|
||||
globals.Profile = os.Getenv(envvars.CliProfile)
|
||||
}
|
||||
return cmdutil.InvocationContext{
|
||||
Profile: globals.Profile,
|
||||
ProfileFromFlag: profileFromFlag,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
@@ -70,3 +74,45 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapProfileEnvFallback(t *testing.T) {
|
||||
t.Run("flag wins over env", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "tenant_flag", "whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "tenant_flag" {
|
||||
t.Errorf("got %q, want tenant_flag", inv.Profile)
|
||||
}
|
||||
if !inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = false, want true")
|
||||
}
|
||||
})
|
||||
t.Run("env used when flag absent", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||
inv, err := BootstrapInvocationContext([]string{"whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "tenant_env" {
|
||||
t.Errorf("got %q, want tenant_env", inv.Profile)
|
||||
}
|
||||
if inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = true, want false")
|
||||
}
|
||||
})
|
||||
t.Run("empty when neither set", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "")
|
||||
inv, err := BootstrapInvocationContext([]string{"whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Errorf("got %q, want empty", inv.Profile)
|
||||
}
|
||||
if inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = true, want false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage configuration profiles",
|
||||
Long: `Profiles are named app identities managed by lark-cli.
|
||||
|
||||
Profile selection:
|
||||
--profile <name> Use a profile for this command only.
|
||||
LARKSUITE_CLI_PROFILE Use a profile for the current shell / agent session.
|
||||
lark-cli whoami --json Show which identity is actually used.
|
||||
unset LARKSUITE_CLI_PROFILE Clear the session profile and fall back to direct app env or configured default.`,
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
|
||||
@@ -627,6 +627,19 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestProfileHelpHasSelectionSection asserts `profile --help` documents the
|
||||
// per-invocation flag and session-scoped env var for selecting a profile, so
|
||||
// users and AI agents can find LARKSUITE_CLI_PROFILE without reading source.
|
||||
func TestProfileHelpHasSelectionSection(t *testing.T) {
|
||||
cmd := NewCmdProfile(nil)
|
||||
if !strings.Contains(cmd.Long, "Profile selection:") {
|
||||
t.Errorf("profile --help missing Profile selection section")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "LARKSUITE_CLI_PROFILE") {
|
||||
t.Errorf("profile --help missing LARKSUITE_CLI_PROFILE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
|
||||
dir := setupProfileConfigDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -33,6 +34,15 @@ type whoamiResult struct {
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
|
||||
// CredentialSource, Explicit, and DirectCredentialEnv surface the cached
|
||||
// credential.IdentitySelection computed during resolution (not re-inferred
|
||||
// here). CredentialSource can be empty ("") on the non-env
|
||||
// extension-provider path (e.g. sidecar mode), where no selection kind
|
||||
// applies; this is a documented, valid state, not an error.
|
||||
CredentialSource string `json:"credentialSource"`
|
||||
Explicit bool `json:"explicit"`
|
||||
DirectCredentialEnv credential.DirectCredentialEnv `json:"directCredentialEnv"`
|
||||
}
|
||||
|
||||
// delegatedUser is the user a user-identity acts on behalf of.
|
||||
@@ -58,6 +68,10 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status (JSON)",
|
||||
Long: `Show the effective app identity used by this invocation. This is not OAuth login status;
|
||||
use ` + "`lark-cli auth status --json`" + ` for OAuth user/token state.
|
||||
The JSON output includes credentialSource, appId, brand, and whether direct app credential
|
||||
env is present and matches the selected profile.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
},
|
||||
@@ -97,7 +111,17 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
// Read the cached selection computed during resolution; never re-infer it
|
||||
// here. A resolution failure (e.g. under a non-env extension provider that
|
||||
// doesn't populate a selection) degrades to the zero value rather than
|
||||
// regressing whoami's own error/diagnostic path above.
|
||||
var selection credential.IdentitySelection
|
||||
if f.Credential != nil {
|
||||
if sel, err := f.Credential.Selection(ctx); err == nil {
|
||||
selection = sel
|
||||
}
|
||||
}
|
||||
res := buildResult(cfg, as, source, diag, selection)
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
@@ -122,18 +146,23 @@ func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, stri
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
||||
// selection is the cached credential.IdentitySelection from resolution; it is
|
||||
// read as-is, never recomputed.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result, selection credential.IdentitySelection) *whoamiResult {
|
||||
defaultAs := cfg.DefaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = core.AsAuto
|
||||
}
|
||||
res := &whoamiResult{
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
CredentialSource: string(selection.Source),
|
||||
Explicit: selection.Explicit(),
|
||||
DirectCredentialEnv: selection.DirectCredentialEnv,
|
||||
}
|
||||
// Use the diagnosed hint as-is: it is tailored to the credential source, so
|
||||
// it never says "auth login" when that is blocked under an external provider.
|
||||
|
||||
@@ -15,10 +15,13 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
func TestResolveSource(t *testing.T) {
|
||||
@@ -52,7 +55,7 @@ func TestBuildResult_UserValid(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
@@ -77,7 +80,7 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -100,7 +103,7 @@ func TestBuildResult_BotReady(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag)
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default_as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
@@ -121,7 +124,7 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -318,3 +321,94 @@ func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
|
||||
t.Fatalf("hint should explain external management: %q", got.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// noopWhoamiKeychain is a no-op KeychainAccess; the profile below uses a
|
||||
// plaintext secret, so no keychain lookup is actually required.
|
||||
type noopWhoamiKeychain struct{}
|
||||
|
||||
func (noopWhoamiKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (noopWhoamiKeychain) Set(service, account, value string) error { return nil }
|
||||
func (noopWhoamiKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
// credentialSourceSecret is the profile secret written to config for
|
||||
// TestWhoamiIncludesCredentialSource. It must never leak into whoami's output
|
||||
// (security §5.1).
|
||||
const credentialSourceSecret = "test-secret"
|
||||
|
||||
// profileSelectionFactory builds a Factory whose CredentialProvider resolves
|
||||
// an explicit profile ("tenant_a") supplied via the LARKSUITE_CLI_PROFILE env
|
||||
// fallback (not --profile), so Selection().Source resolves to
|
||||
// env:LARKSUITE_CLI_PROFILE and Explicit() is true, with no direct
|
||||
// app-credential env vars present.
|
||||
func profileSelectionFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.PlainSecret(credentialSourceSecret),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return noopWhoamiKeychain{} }, "tenant_a")
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{&envprovider.Provider{}}, defaultAcct, nil, nil)
|
||||
cred.WithProfile("tenant_a", false) // fromFlag=false -> env:LARKSUITE_CLI_PROFILE
|
||||
|
||||
cfg := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: credentialSourceSecret, Brand: core.BrandFeishu}
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
return f, out
|
||||
}
|
||||
|
||||
// TestWhoamiIncludesCredentialSource locks in the diagnostic fields surfaced
|
||||
// from the cached credential.IdentitySelection (Task 6): credentialSource,
|
||||
// explicit, and directCredentialEnv. whoami must read the cached selection
|
||||
// as-is, not re-infer it.
|
||||
func TestWhoamiIncludesCredentialSource(t *testing.T) {
|
||||
f, out := profileSelectionFactory(t)
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
raw := out.String()
|
||||
if strings.Contains(raw, credentialSourceSecret) {
|
||||
t.Fatalf("whoami output leaked the profile secret: %s", raw)
|
||||
}
|
||||
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, raw)
|
||||
}
|
||||
if got.CredentialSource != string(credential.SourceEnvProfile) {
|
||||
t.Fatalf("credentialSource = %q, want %q", got.CredentialSource, credential.SourceEnvProfile)
|
||||
}
|
||||
if !got.Explicit {
|
||||
t.Fatalf("explicit = false, want true")
|
||||
}
|
||||
if got.DirectCredentialEnv.Present {
|
||||
t.Fatalf("directCredentialEnv.present = true, want false: %#v", got.DirectCredentialEnv)
|
||||
}
|
||||
if !strings.Contains(raw, `"credentialSource": "env:LARKSUITE_CLI_PROFILE"`) {
|
||||
t.Fatalf("raw JSON missing credentialSource literal: %s", raw)
|
||||
}
|
||||
if got.DirectCredentialEnv.Present || len(got.DirectCredentialEnv.Keys) != 0 ||
|
||||
got.DirectCredentialEnv.AppID != "" || got.DirectCredentialEnv.Matched || got.DirectCredentialEnv.ConflictsWithProfile {
|
||||
t.Fatalf("directCredentialEnv = %#v, want only present:false set", got.DirectCredentialEnv)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,77 @@ func TestConfigError_MarshalJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigError_ProfileFieldsMarshalJSON(t *testing.T) {
|
||||
ce := NewConfigError(SubtypeAppCredentialIncomplete, "incomplete").
|
||||
WithMissingKeys("LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET").
|
||||
WithProfile("work").
|
||||
WithAppID("cli_abc").
|
||||
WithCredentialSource("flag:--profile")
|
||||
b, err := json.Marshal(ce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"config"`,
|
||||
`"subtype":"app_credential_incomplete"`,
|
||||
`"missing_keys":["LARKSUITE_CLI_APP_ID","LARKSUITE_CLI_APP_SECRET"]`,
|
||||
`"profile":"work"`,
|
||||
`"app_id":"cli_abc"`,
|
||||
`"credential_source":"flag:--profile"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// omitempty: unset fields must not appear on the wire.
|
||||
empty := NewConfigError(SubtypeProfileNotFound, "x")
|
||||
b2, err := json.Marshal(empty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := string(b2)
|
||||
for _, notWant := range []string{`"missing_keys"`, `"profile"`, `"app_id"`, `"credential_source"`} {
|
||||
if strings.Contains(s2, notWant) {
|
||||
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationError_ProfileConflictMarshalJSON(t *testing.T) {
|
||||
ve := NewValidationError(SubtypeProfileAppCredentialConflict, "conflict").
|
||||
WithProfileAppConflict("cli_profile", "cli_env")
|
||||
b, err := json.Marshal(ve)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"validation"`,
|
||||
`"subtype":"profile_app_credential_conflict"`,
|
||||
`"profile_app_id":"cli_profile"`,
|
||||
`"env_app_id":"cli_env"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// omitempty: unset conflict fields must not appear on the wire.
|
||||
empty := NewValidationError(SubtypeInvalidArgument, "x")
|
||||
b2, err := json.Marshal(empty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := string(b2)
|
||||
for _, notWant := range []string{`"profile_app_id"`, `"env_app_id"`} {
|
||||
if strings.Contains(s2, notWant) {
|
||||
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkError_MarshalJSON(t *testing.T) {
|
||||
ne := &NetworkError{
|
||||
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},
|
||||
|
||||
@@ -12,8 +12,9 @@ const (
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeProfileAppCredentialConflict Subtype = "profile_app_credential_conflict" // profile and direct app env both set but app_id differs
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
@@ -41,9 +42,13 @@ const (
|
||||
|
||||
// CategoryConfig subtypes
|
||||
const (
|
||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
||||
SubtypeProfileNotFound Subtype = "profile_not_found" // --profile / LARKSUITE_CLI_PROFILE points to a nonexistent profile
|
||||
SubtypeNoActiveProfile Subtype = "no_active_profile" // no active identity input and no usable default profile
|
||||
SubtypeAppCredentialIncomplete Subtype = "app_credential_incomplete" // direct app env missing app_id or app_secret
|
||||
SubtypeProfileSecretInvalid Subtype = "profile_secret_invalid" // profile exists but its secret cannot be resolved locally
|
||||
)
|
||||
|
||||
// CategoryNetwork subtypes
|
||||
|
||||
@@ -61,9 +61,11 @@ type TypedError interface {
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
ProfileAppID string `json:"profile_app_id,omitempty"`
|
||||
EnvAppID string `json:"env_app_id,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||
@@ -150,6 +152,12 @@ func (e *ValidationError) WithCause(cause error) *ValidationError {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithProfileAppConflict(profileAppID, envAppID string) *ValidationError {
|
||||
e.ProfileAppID = profileAppID
|
||||
e.EnvAppID = envAppID
|
||||
return e
|
||||
}
|
||||
|
||||
// =========================== AuthenticationError =============================
|
||||
|
||||
// AuthenticationError is the typed error for CategoryAuthentication.
|
||||
@@ -315,8 +323,17 @@ func (e *PermissionError) WithCause(cause error) *PermissionError {
|
||||
// intentionally not serialized.
|
||||
type ConfigError struct {
|
||||
Problem
|
||||
Field string `json:"field,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
Field string `json:"field,omitempty"`
|
||||
MissingKeys []string `json:"missing_keys,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
// CredentialSource is the machine-readable App/credential selection source
|
||||
// that produced this config error (e.g. "flag:--profile",
|
||||
// "env:LARKSUITE_CLI_PROFILE", "config"). It is required on
|
||||
// profile_not_found and no_active_profile (spec §5) so an agent can branch
|
||||
// on how the identity was (or was not) chosen. It is never a secret.
|
||||
CredentialSource string `json:"credential_source,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
@@ -370,6 +387,29 @@ func (e *ConfigError) WithField(field string) *ConfigError {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithMissingKeys(keys ...string) *ConfigError {
|
||||
e.MissingKeys = slices.Clone(keys)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithProfile(name string) *ConfigError {
|
||||
e.Profile = name
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithAppID(appID string) *ConfigError {
|
||||
e.AppID = appID
|
||||
return e
|
||||
}
|
||||
|
||||
// WithCredentialSource records the machine-readable credential-selection source
|
||||
// on the wire (snake_case credential_source). The value is an enum string
|
||||
// (e.g. "flag:--profile", "config"), never a secret.
|
||||
func (e *ConfigError) WithCredentialSource(source string) *ConfigError {
|
||||
e.CredentialSource = source
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithCause(cause error) *ConfigError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
|
||||
@@ -643,3 +643,29 @@ func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ======================= Profile selection error subtypes =======================
|
||||
|
||||
func TestConfigErrorProfileFields(t *testing.T) {
|
||||
e := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "incomplete").
|
||||
WithMissingKeys("LARKSUITE_CLI_APP_ID").
|
||||
WithCredentialSource("env:LARKSUITE_CLI_PROFILE")
|
||||
p, ok := errs.ProblemOf(e)
|
||||
if !ok || p.Subtype != errs.SubtypeAppCredentialIncomplete {
|
||||
t.Fatalf("subtype mismatch: %+v", p)
|
||||
}
|
||||
if len(e.MissingKeys) != 1 || e.MissingKeys[0] != "LARKSUITE_CLI_APP_ID" {
|
||||
t.Errorf("missing_keys not set: %v", e.MissingKeys)
|
||||
}
|
||||
if e.CredentialSource != "env:LARKSUITE_CLI_PROFILE" {
|
||||
t.Errorf("credential_source not set: %q", e.CredentialSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrorProfileConflict(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict, "conflict").
|
||||
WithProfileAppConflict("cli_profile", "cli_env")
|
||||
if e.ProfileAppID != "cli_profile" || e.EnvAppID != "cli_env" {
|
||||
t.Errorf("conflict fields not set: %q %q", e.ProfileAppID, e.EnvAppID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
// In tests, replace any field to stub out external dependencies.
|
||||
type InvocationContext struct {
|
||||
Profile string
|
||||
// ProfileFromFlag is true when Profile was set via the --profile flag,
|
||||
// and false when it came from the LARKSUITE_CLI_PROFILE env fallback
|
||||
// (or neither was set). Downstream credential resolution uses this to
|
||||
// report the correct profile source.
|
||||
ProfileFromFlag bool
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
|
||||
@@ -61,10 +61,11 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
f.Credential = buildCredentialProvider(credentialDeps{
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
ProfileFromFlag: inv.ProfileFromFlag,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
|
||||
// Phase 3: Config derived from Credential via an explicit conversion boundary.
|
||||
@@ -162,10 +163,11 @@ func buildSDKTransport() http.RoundTripper {
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
ProfileFromFlag bool
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
|
||||
@@ -178,5 +180,6 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
|
||||
// depend on. enrichUserInfo failures are already non-fatal (the
|
||||
// provider clears unverified identity fields), so silencing the
|
||||
// warning is safe.
|
||||
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
||||
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient).
|
||||
WithProfile(deps.Profile, deps.ProfileFromFlag)
|
||||
}
|
||||
|
||||
@@ -9,13 +9,21 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// directCredentialProviderName is the Name() of the env provider, the source
|
||||
// of direct app credentials (LARKSUITE_CLI_APP_ID / _APP_SECRET). Only its
|
||||
// incomplete blocks map to app_credential_incomplete (spec §3 step 1).
|
||||
const directCredentialProviderName = "env"
|
||||
|
||||
// DefaultAccountResolver is implemented by the default account provider.
|
||||
type DefaultAccountResolver interface {
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
@@ -136,10 +144,18 @@ type CredentialProvider struct {
|
||||
httpClient func() (*http.Client, error)
|
||||
warnOut io.Writer
|
||||
|
||||
// profile is the active profile (from --profile or LARKSUITE_CLI_PROFILE).
|
||||
// profileFromFlag discriminates the source for the reported selection.
|
||||
profile string
|
||||
profileFromFlag bool
|
||||
|
||||
accountOnce sync.Once
|
||||
account *Account
|
||||
accountErr error
|
||||
selectedSource credentialSource
|
||||
// selection is the explainable credential-selection result, populated by
|
||||
// doResolveAccount under accountOnce. It never carries a secret (§5.1).
|
||||
selection IdentitySelection
|
||||
|
||||
hintOnce sync.Once
|
||||
hint *IdentityHint
|
||||
@@ -161,6 +177,15 @@ func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
|
||||
return p
|
||||
}
|
||||
|
||||
// WithProfile records the active profile and whether it came from the
|
||||
// --profile flag (as opposed to the LARKSUITE_CLI_PROFILE env fallback).
|
||||
// It governs credential arbitration and the reported selection source.
|
||||
func (p *CredentialProvider) WithProfile(profile string, fromFlag bool) *CredentialProvider {
|
||||
p.profile = profile
|
||||
p.profileFromFlag = fromFlag
|
||||
return p
|
||||
}
|
||||
|
||||
// ResolveAccount resolves app credentials. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
||||
// Subsequent calls return the cached result regardless of their context.
|
||||
@@ -172,40 +197,273 @@ func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, erro
|
||||
return p.account, p.accountErr
|
||||
}
|
||||
|
||||
// doResolveAccount arbitrates the credential/App selection per the spec
|
||||
// resolution order (§3): env-partial → profile → env-complete → config default.
|
||||
// It populates p.selection (no secret; §5.1) and p.selectedSource on every
|
||||
// success path.
|
||||
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
||||
// Step 1 (spec §3): consult the extension providers. The env provider is
|
||||
// the "direct app credential" source. An incomplete direct credential
|
||||
// (only APP_ID or only APP_SECRET set) short-circuits to
|
||||
// app_credential_incomplete regardless of the active profile.
|
||||
var envAcct *Account
|
||||
var envSource extensionTokenSource
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
var blockErr *extcred.BlockError
|
||||
// Only the env (direct-credential) provider maps an incomplete
|
||||
// block to app_credential_incomplete. Other providers' blocks
|
||||
// propagate unchanged so they still stop the chain (§3 step 1
|
||||
// is specifically about direct app credential env vars).
|
||||
if errors.As(err, &blockErr) && prov.Name() == directCredentialProviderName {
|
||||
if missing := missingDirectCredentialKeys(); len(missing) > 0 {
|
||||
return nil, errs.NewConfigError(errs.SubtypeAppCredentialIncomplete,
|
||||
"direct app credential is incomplete").
|
||||
WithMissingKeys(missing...).
|
||||
WithHint("set both %s and %s, or unset both and use --profile / a config default.",
|
||||
envvars.CliAppID, envvars.CliAppSecret)
|
||||
}
|
||||
// Block for a reason other than incompleteness (e.g. an
|
||||
// invalid identity/strict-mode value); preserve prior behavior.
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if acct != nil {
|
||||
internal := convertAccount(acct)
|
||||
source := extensionTokenSource{provider: prov}
|
||||
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
// Only the env (direct-credential) provider feeds profile
|
||||
// arbitration / conflict detection / DirectCredentialEnv reporting.
|
||||
// This mirrors the block-path guard above. A non-env extension
|
||||
// provider (e.g. sidecar) is NOT a direct-credential env account:
|
||||
// it wins outright here, returning its account + token source
|
||||
// unchanged (pre-diff behavior), without being misreported as a
|
||||
// direct env credential (§4.2: Present = direct env vars actually
|
||||
// set) or triggering a spurious profile_app_credential_conflict.
|
||||
if prov.Name() != directCredentialProviderName {
|
||||
internal := convertAccount(acct)
|
||||
source := extensionTokenSource{provider: prov}
|
||||
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
}
|
||||
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
||||
// (used for strict mode) is already set by the provider.
|
||||
// Clear unverified user identity for safety.
|
||||
internal.UserOpenId = ""
|
||||
internal.UserName = ""
|
||||
}
|
||||
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
||||
// (used for strict mode) is already set by the provider.
|
||||
// Clear unverified user identity for safety.
|
||||
internal.UserOpenId = ""
|
||||
internal.UserName = ""
|
||||
p.selectedSource = source
|
||||
return internal, nil
|
||||
}
|
||||
p.selectedSource = source
|
||||
return internal, nil
|
||||
envAcct = convertAccount(acct)
|
||||
envSource = extensionTokenSource{provider: prov}
|
||||
break
|
||||
}
|
||||
}
|
||||
if p.defaultAcct != nil {
|
||||
|
||||
// Step 2 (spec §3): an explicit profile was requested.
|
||||
if p.profile != "" {
|
||||
multi, loadErr := core.LoadMultiAppConfig()
|
||||
if errors.Is(loadErr, core.ErrMalformedConfig) {
|
||||
// A malformed config must not be masked as profile_not_found (which
|
||||
// would tell the user to run `profile list` and hide a real config
|
||||
// problem). Pass the underlying error through unchanged so
|
||||
// errors.Is / errors.Unwrap keep working. An absent config is not
|
||||
// malformed and still falls through to the friendly
|
||||
// profile_not_found below, since the requested profile cannot exist.
|
||||
return nil, loadErr
|
||||
}
|
||||
var app *core.AppConfig
|
||||
if loadErr == nil && multi != nil {
|
||||
app = multi.FindApp(p.profile)
|
||||
}
|
||||
if app == nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeProfileNotFound,
|
||||
"profile %q not found", p.profile).
|
||||
WithProfile(p.profile).
|
||||
WithCredentialSource(string(p.profileSource())).
|
||||
WithHint("run `lark-cli profile list` to see available profiles.")
|
||||
}
|
||||
if envAcct != nil {
|
||||
// E == complete: the direct env app_id must match the profile.
|
||||
if app.AppId != envAcct.AppID {
|
||||
return nil, errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict,
|
||||
"profile %q app_id does not match %s", p.profile, envvars.CliAppID).
|
||||
WithProfileAppConflict(app.AppId, envAcct.AppID).
|
||||
WithHint("unset %s/%s, or select a profile whose app_id matches the environment.",
|
||||
envvars.CliAppID, envvars.CliAppSecret)
|
||||
}
|
||||
p.selection = IdentitySelection{
|
||||
Source: p.profileSource(),
|
||||
DirectCredentialEnv: DirectCredentialEnv{
|
||||
Present: true,
|
||||
Keys: presentDirectCredentialKeys(),
|
||||
AppID: envAcct.AppID,
|
||||
Matched: true,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
p.selection = IdentitySelection{
|
||||
Source: p.profileSource(),
|
||||
DirectCredentialEnv: DirectCredentialEnv{Present: false},
|
||||
}
|
||||
}
|
||||
// Resolve the profile's own (keychain-backed) credential locally.
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// SECURITY (§5.1): generic message — never embed the underlying
|
||||
// error or any secret material.
|
||||
p.selection = IdentitySelection{}
|
||||
return nil, errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
|
||||
"profile %q credential could not be resolved locally", p.profile).
|
||||
WithProfile(p.profile).
|
||||
WithAppID(app.AppId).
|
||||
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
|
||||
}
|
||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// Step 3 (spec §3): no explicit profile — direct env credential wins.
|
||||
if envAcct != nil {
|
||||
if err := p.enrichUserInfo(ctx, envAcct, envSource); err != nil {
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", envSource.Name(), err)
|
||||
}
|
||||
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
||||
// (used for strict mode) is already set by the provider.
|
||||
// Clear unverified user identity for safety.
|
||||
envAcct.UserOpenId = ""
|
||||
envAcct.UserName = ""
|
||||
}
|
||||
p.selectedSource = envSource
|
||||
p.selection = IdentitySelection{
|
||||
Source: SourceEnvAppID,
|
||||
DirectCredentialEnv: DirectCredentialEnv{
|
||||
Present: true,
|
||||
Keys: presentDirectCredentialKeys(),
|
||||
AppID: envAcct.AppID,
|
||||
},
|
||||
}
|
||||
return envAcct, nil
|
||||
}
|
||||
|
||||
// No direct env credential and no profile → the config default.
|
||||
if p.defaultAcct != nil {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
// The config default failed to resolve. Distinguish (spec §3 step
|
||||
// 3.2): a default profile that EXISTS (has an app_id) but whose
|
||||
// secret cannot be resolved locally is a profile_secret_invalid —
|
||||
// "identity is configured, its secret is broken" is more actionable
|
||||
// than "no active profile". Only when there is genuinely no usable
|
||||
// default profile do we report no_active_profile. Other typed
|
||||
// failures (e.g. a specific config error) pass through unchanged.
|
||||
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype == errs.SubtypeNotConfigured {
|
||||
if name, appID, ok := defaultProfileIdentity(); ok {
|
||||
// SECURITY (§5.1): generic message — never embed the
|
||||
// underlying error or any secret material. app_id is
|
||||
// plaintext and safe to echo.
|
||||
return nil, errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
|
||||
"profile %q credential could not be resolved locally", name).
|
||||
WithProfile(name).
|
||||
WithAppID(appID).
|
||||
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
|
||||
}
|
||||
return nil, errs.NewConfigError(errs.SubtypeNoActiveProfile, "no active profile").
|
||||
WithCredentialSource(noActiveProfileCredentialSource).
|
||||
WithHint("run `lark-cli config init` / `lark-cli profile add`, or set %s.", envvars.CliProfile)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||
p.selection = IdentitySelection{Source: selectionSourceForDefault(multi)}
|
||||
return acct, nil
|
||||
}
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
// profileSource reports the credential source kind for a profile-backed
|
||||
// selection, discriminating the --profile flag from the env fallback.
|
||||
func (p *CredentialProvider) profileSource() CredentialSourceKind {
|
||||
if p.profileFromFlag {
|
||||
return SourceFlagProfile
|
||||
}
|
||||
return SourceEnvProfile
|
||||
}
|
||||
|
||||
// noActiveProfileCredentialSource is the credential_source reported on the
|
||||
// no_active_profile error. Spec §5 fixes this to the literal "config": there is
|
||||
// no resolved default profile at all, so the more specific config:currentApp /
|
||||
// config:firstApp source values (used on successful config-default selections)
|
||||
// would be misleading. It is an enum string, never a secret.
|
||||
const noActiveProfileCredentialSource = "config"
|
||||
|
||||
// defaultProfileIdentity reports the config default profile's display name and
|
||||
// app_id when a usable default profile actually EXISTS (currentApp > firstApp
|
||||
// resolves to an app with a non-empty app_id). It never touches the keychain or
|
||||
// any secret, so it can distinguish "default profile exists but its secret is
|
||||
// broken" (→ profile_secret_invalid) from "no usable default profile at all"
|
||||
// (→ no_active_profile), without risking a secret leak (§5.1).
|
||||
func defaultProfileIdentity() (name, appID string, ok bool) {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil || multi == nil {
|
||||
return "", "", false
|
||||
}
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app == nil || app.AppId == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return app.ProfileName(), app.AppId, true
|
||||
}
|
||||
|
||||
// selectionSourceForDefault reports whether the config default resolved to the
|
||||
// explicit currentApp or fell back to the first app (spec §3 step 3.2).
|
||||
func selectionSourceForDefault(multi *core.MultiAppConfig) CredentialSourceKind {
|
||||
if multi != nil && multi.CurrentApp != "" {
|
||||
return SourceConfigCurrentApp
|
||||
}
|
||||
return SourceConfigFirstApp
|
||||
}
|
||||
|
||||
// missingDirectCredentialKeys returns the NAMES (never values) of the direct
|
||||
// app credential env vars that are absent. Used only when the env provider
|
||||
// blocks, to map an incomplete direct credential to app_credential_incomplete.
|
||||
func missingDirectCredentialKeys() []string {
|
||||
var missing []string
|
||||
if os.Getenv(envvars.CliAppID) == "" {
|
||||
missing = append(missing, envvars.CliAppID)
|
||||
}
|
||||
if os.Getenv(envvars.CliAppSecret) == "" {
|
||||
missing = append(missing, envvars.CliAppSecret)
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// presentDirectCredentialKeys returns the NAMES (never values) of the direct
|
||||
// app credential env vars that are set. Used to annotate DirectCredentialEnv.
|
||||
func presentDirectCredentialKeys() []string {
|
||||
var keys []string
|
||||
if os.Getenv(envvars.CliAppID) != "" {
|
||||
keys = append(keys, envvars.CliAppID)
|
||||
}
|
||||
if os.Getenv(envvars.CliAppSecret) != "" {
|
||||
keys = append(keys, envvars.CliAppSecret)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Selection resolves the account (once) and returns the cached, secret-free
|
||||
// explanation of how the credential/App was selected. It mirrors
|
||||
// selectedCredentialSource: resolve-then-return.
|
||||
func (p *CredentialProvider) Selection(ctx context.Context) (IdentitySelection, error) {
|
||||
if _, err := p.ResolveAccount(ctx); err != nil {
|
||||
return IdentitySelection{}, err
|
||||
}
|
||||
return p.selection, nil
|
||||
}
|
||||
|
||||
// enrichUserInfo resolves user identity when extension provides a UAT.
|
||||
// If UAT is available, user_info API call is mandatory (security: verify token validity).
|
||||
// If no UAT from extension, falls back to provider-supplied OpenID.
|
||||
|
||||
554
internal/credential/credential_provider_selection_test.go
Normal file
554
internal/credential/credential_provider_selection_test.go
Normal file
@@ -0,0 +1,554 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
func asConfigError(t *testing.T, err error) *errs.ConfigError {
|
||||
t.Helper()
|
||||
var ce *errs.ConfigError
|
||||
if !errors.As(err, &ce) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
return ce
|
||||
}
|
||||
|
||||
func asValidationError(t *testing.T, err error) *errs.ValidationError {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
|
||||
// secretValue is the profile secret written to config. It must NEVER appear in
|
||||
// any error message or IdentitySelection (security §5.1).
|
||||
const secretValue = "your-secret"
|
||||
|
||||
// envSecretValue is the direct env app secret. Same no-leak guarantee.
|
||||
const envSecretValue = "your-password"
|
||||
|
||||
// writeConfigTenantA writes a config with a single profile "tenant_a" (app_id
|
||||
// "cli_a"). The secret is a plaintext secret stored in config, which resolves
|
||||
// locally without a keychain lookup.
|
||||
func writeConfigTenantA(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.PlainSecret(secretValue),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeConfigTenantABroken writes tenant_a with a keychain-backed secret ref
|
||||
// that cannot be resolved (noop keychain returns empty), so profile secret
|
||||
// resolution fails locally.
|
||||
func writeConfigTenantABroken(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
// A keychain SecretRef whose key does NOT match app_id cli_a. Local secret
|
||||
// resolution fails (ValidateSecretKeyMatch), exercising profile_secret_invalid.
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:wrong_key"}},
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newProvider(t *testing.T, profile string, fromFlag bool) *credential.CredentialProvider {
|
||||
t.Helper()
|
||||
ep := &envprovider.Provider{}
|
||||
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, profile)
|
||||
cp := credential.NewCredentialProvider([]extcred.Provider{ep}, defaultAcct, nil, nil)
|
||||
cp.WithProfile(profile, fromFlag)
|
||||
return cp
|
||||
}
|
||||
|
||||
// assertNoSecretLeak fails if any secret value appears in the given strings.
|
||||
func assertNoSecretLeak(t *testing.T, where string, vals ...string) {
|
||||
t.Helper()
|
||||
for _, v := range vals {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(v, secretValue) {
|
||||
t.Errorf("%s leaked profile secret: %q", where, v)
|
||||
}
|
||||
if strings.Contains(v, envSecretValue) {
|
||||
t.Errorf("%s leaked env secret: %q", where, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func subtypeOf(t *testing.T, err error) errs.Subtype {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not a typed problem: %v", err)
|
||||
}
|
||||
return p.Subtype
|
||||
}
|
||||
|
||||
// State #2: P none, E none, C none -> no_active_profile.
|
||||
func TestSelection_State2_NoActiveProfile(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) // empty dir -> no config
|
||||
cp := newProvider(t, "", false)
|
||||
|
||||
sel, err := cp.Selection(context.Background())
|
||||
if got := subtypeOf(t, err); got != errs.SubtypeNoActiveProfile {
|
||||
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeNoActiveProfile)
|
||||
}
|
||||
// Defect 1 (spec §5): no_active_profile must carry credential_source=config.
|
||||
ce := asConfigError(t, err)
|
||||
if ce.CredentialSource != "config" {
|
||||
t.Errorf("credential_source = %q, want %q", ce.CredentialSource, "config")
|
||||
}
|
||||
assertNoSecretLeak(t, "state2", err.Error(), string(sel.Source))
|
||||
}
|
||||
|
||||
// Config-default profile with a broken secret: P none, E none, C present but the
|
||||
// default profile's keychain secret ref is corrupted. Per spec §3 step 3.2 this
|
||||
// must be profile_secret_invalid (the identity IS configured, only its secret is
|
||||
// broken) — NOT no_active_profile (which is reserved for "no usable default").
|
||||
func TestSelection_ConfigDefaultBrokenSecret_ProfileSecretInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
writeConfigTenantABroken(t) // CurrentApp = tenant_a (app_id cli_a), broken keychain ref
|
||||
cp := newProvider(t, "", false)
|
||||
|
||||
_, err := cp.Selection(context.Background())
|
||||
if got := subtypeOf(t, err); got != errs.SubtypeProfileSecretInvalid {
|
||||
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileSecretInvalid)
|
||||
}
|
||||
ce := asConfigError(t, err)
|
||||
if ce.Profile != "tenant_a" {
|
||||
t.Errorf("profile = %q, want tenant_a", ce.Profile)
|
||||
}
|
||||
if ce.AppID != "cli_a" {
|
||||
t.Errorf("app_id = %q, want cli_a", ce.AppID)
|
||||
}
|
||||
// §5.1: generic message, no cause, no secret anywhere.
|
||||
if errors.Unwrap(ce) != nil {
|
||||
t.Errorf("profile_secret_invalid must not attach a cause, got %v", errors.Unwrap(ce))
|
||||
}
|
||||
assertNoSecretLeak(t, "config-default-broken", ce.Message, ce.Hint, ce.AppID)
|
||||
}
|
||||
|
||||
// Explicit profile requested but the config file is malformed. The load error
|
||||
// must be propagated (errors.Is ErrMalformedConfig) rather than masked as
|
||||
// profile_not_found, which would hide a real config problem and misdirect the
|
||||
// user to `profile list`. An absent config is separately still profile_not_found.
|
||||
func TestSelection_ExplicitProfile_MalformedConfig_PropagatesError(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := os.MkdirAll(core.GetConfigDir(), 0o700); err != nil {
|
||||
t.Fatalf("mkdir config dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(core.GetConfigPath(), []byte("{ this is not valid json"), 0o600); err != nil {
|
||||
t.Fatalf("write malformed config: %v", err)
|
||||
}
|
||||
cp := newProvider(t, "tenant_a", true)
|
||||
|
||||
_, err := cp.Selection(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for malformed config, got nil")
|
||||
}
|
||||
if !errors.Is(err, core.ErrMalformedConfig) {
|
||||
t.Fatalf("malformed config error not propagated: %v", err)
|
||||
}
|
||||
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype == errs.SubtypeProfileNotFound {
|
||||
t.Fatalf("malformed config masked as profile_not_found")
|
||||
}
|
||||
}
|
||||
|
||||
// State #3: P none, E partial (only APP_ID) -> app_credential_incomplete.
|
||||
func TestSelection_State3_EnvPartial(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_env")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
writeConfigTenantA(t)
|
||||
cp := newProvider(t, "", false)
|
||||
|
||||
_, err := cp.Selection(context.Background())
|
||||
if got := subtypeOf(t, err); got != errs.SubtypeAppCredentialIncomplete {
|
||||
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeAppCredentialIncomplete)
|
||||
}
|
||||
prob, _ := errs.ProblemOf(err)
|
||||
ce := asConfigError(t, err)
|
||||
if !slices.Contains(ce.MissingKeys, envvars.CliAppSecret) {
|
||||
t.Errorf("missing_keys = %v, want to contain %q", ce.MissingKeys, envvars.CliAppSecret)
|
||||
}
|
||||
// missing_keys must be NAMES only, never values.
|
||||
for _, k := range ce.MissingKeys {
|
||||
if strings.Contains(k, envSecretValue) || strings.Contains(k, secretValue) {
|
||||
t.Errorf("missing_keys contains a value, not a name: %q", k)
|
||||
}
|
||||
}
|
||||
assertNoSecretLeak(t, "state3", prob.Message, prob.Hint)
|
||||
}
|
||||
|
||||
// State #4: P none, E complete -> env:LARKSUITE_CLI_APP_ID.
|
||||
func TestSelection_State4_EnvComplete(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_env")
|
||||
t.Setenv(envvars.CliAppSecret, envSecretValue)
|
||||
writeConfigTenantA(t)
|
||||
cp := newProvider(t, "", false)
|
||||
|
||||
sel, err := cp.Selection(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if sel.Source != credential.SourceEnvAppID {
|
||||
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceEnvAppID)
|
||||
}
|
||||
if !sel.DirectCredentialEnv.Present {
|
||||
t.Errorf("DirectCredentialEnv.Present = false, want true")
|
||||
}
|
||||
assertNoSecretLeak(t, "state4", string(sel.Source), sel.DirectCredentialEnv.AppID)
|
||||
assertNoSecretLeak(t, "state4-keys", sel.DirectCredentialEnv.Keys...)
|
||||
}
|
||||
|
||||
// State #5: P valid, E none -> flag:--profile (fromFlag) source.
|
||||
func TestSelection_State5_ProfileOnly(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
writeConfigTenantA(t)
|
||||
cp := newProvider(t, "tenant_a", true)
|
||||
|
||||
sel, err := cp.Selection(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if sel.Source != credential.SourceFlagProfile {
|
||||
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceFlagProfile)
|
||||
}
|
||||
if sel.DirectCredentialEnv.Present {
|
||||
t.Errorf("DirectCredentialEnv.Present = true, want false")
|
||||
}
|
||||
assertNoSecretLeak(t, "state5", string(sel.Source))
|
||||
}
|
||||
|
||||
// State #5b: P valid from env (not flag) -> env:LARKSUITE_CLI_PROFILE source.
|
||||
func TestSelection_State5_ProfileFromEnv(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
writeConfigTenantA(t)
|
||||
cp := newProvider(t, "tenant_a", false)
|
||||
|
||||
sel, err := cp.Selection(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if sel.Source != credential.SourceEnvProfile {
|
||||
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceEnvProfile)
|
||||
}
|
||||
}
|
||||
|
||||
// State #6: P missing (nonexistent), E complete -> profile_not_found.
|
||||
func TestSelection_State6_ProfileNotFound(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_env")
|
||||
t.Setenv(envvars.CliAppSecret, envSecretValue)
|
||||
writeConfigTenantA(t)
|
||||
cp := newProvider(t, "does_not_exist", true)
|
||||
|
||||
sel, err := cp.Selection(context.Background())
|
||||
if got := subtypeOf(t, err); got != errs.SubtypeProfileNotFound {
|
||||
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileNotFound)
|
||||
}
|
||||
prob, _ := errs.ProblemOf(err)
|
||||
// Defect 1 (spec §5): profile_not_found must carry the credential_source that
|
||||
// named the profile — here the --profile flag.
|
||||
ce := asConfigError(t, err)
|
||||
if ce.CredentialSource != string(credential.SourceFlagProfile) {
|
||||
t.Errorf("credential_source = %q, want %q", ce.CredentialSource, credential.SourceFlagProfile)
|
||||
}
|
||||
assertNoSecretLeak(t, "state6", err.Error(), prob.Hint, string(sel.Source))
|
||||
}
|
||||
|
||||
// State #7: P valid but secret broken, E none -> profile_secret_invalid.
|
||||
func TestSelection_State7_ProfileSecretInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
writeConfigTenantABroken(t)
|
||||
cp := newProvider(t, "tenant_a", true)
|
||||
|
||||
_, err := cp.Selection(context.Background())
|
||||
if got := subtypeOf(t, err); got != errs.SubtypeProfileSecretInvalid {
|
||||
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileSecretInvalid)
|
||||
}
|
||||
ce := asConfigError(t, err)
|
||||
if ce.Profile != "tenant_a" {
|
||||
t.Errorf("profile = %q, want tenant_a", ce.Profile)
|
||||
}
|
||||
if ce.AppID != "cli_a" {
|
||||
t.Errorf("app_id = %q, want cli_a", ce.AppID)
|
||||
}
|
||||
assertNoSecretLeak(t, "state7", ce.Message, ce.Hint)
|
||||
}
|
||||
|
||||
// secretMarkerValue is a distinctive string used to prove that the
|
||||
// profile_secret_invalid path drops the underlying error entirely, even when
|
||||
// that underlying error's own message CONTAINS a secret. Unlike
|
||||
// writeConfigTenantABroken (whose noop-keychain failure is a harmless empty
|
||||
// error), this uses a custom DefaultAccountResolver whose error text embeds
|
||||
// the marker, closing the gap where a leak could hide in a cause chain that
|
||||
// happens to be empty in the noop-keychain case.
|
||||
const secretMarkerValue = "your-access-token"
|
||||
|
||||
// leakingSecretResolver is a DefaultAccountResolver stub whose ResolveAccount
|
||||
// fails with an error whose message contains secretMarkerValue, simulating a
|
||||
// real keychain/secret-resolution failure that echoes back sensitive material
|
||||
// (e.g. a keychain library including the attempted secret in its error text).
|
||||
type leakingSecretResolver struct{}
|
||||
|
||||
func (leakingSecretResolver) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
return nil, fmt.Errorf("keychain decode failed for secret %s", secretMarkerValue)
|
||||
}
|
||||
|
||||
// State #7 (secret-bearing underlying error): P valid, but the underlying
|
||||
// account/secret resolution fails with an error that itself contains a
|
||||
// secret. This locks the §5.1 design: doResolveAccount emits a generic
|
||||
// profile_secret_invalid ConfigError WITHOUT attaching the underlying cause,
|
||||
// so a secret embedded in that underlying error can never surface through
|
||||
// err.Error(), Message, Hint, the unwrapped cause chain, or Selection().
|
||||
func TestSelection_State7_UnderlyingErrorContainingSecret_NotLeaked(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
writeConfigTenantA(t) // profile "tenant_a" exists with app_id "cli_a"
|
||||
|
||||
ep := &envprovider.Provider{}
|
||||
cp := credential.NewCredentialProvider([]extcred.Provider{ep}, leakingSecretResolver{}, nil, nil)
|
||||
cp.WithProfile("tenant_a", true)
|
||||
|
||||
sel, err := cp.Selection(context.Background())
|
||||
if got := subtypeOf(t, err); got != errs.SubtypeProfileSecretInvalid {
|
||||
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileSecretInvalid)
|
||||
}
|
||||
ce := asConfigError(t, err)
|
||||
if ce.Profile != "tenant_a" {
|
||||
t.Errorf("profile = %q, want tenant_a", ce.Profile)
|
||||
}
|
||||
if ce.AppID != "cli_a" {
|
||||
t.Errorf("app_id = %q, want cli_a", ce.AppID)
|
||||
}
|
||||
|
||||
// Walk the full unwrap chain. This is the assertion that would catch a
|
||||
// regression where the profile_secret_invalid branch starts attaching the
|
||||
// underlying error via WithCause: if it did, this loop would find the
|
||||
// marker in a wrapped link even though err.Error()/Message/Hint (which
|
||||
// only reflect the top-level ConfigError, not the chain) might look clean.
|
||||
for cur := error(ce); cur != nil; cur = errors.Unwrap(cur) {
|
||||
if strings.Contains(cur.Error(), secretMarkerValue) {
|
||||
t.Errorf("cause chain leaked secret marker: %v", cur)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), secretMarkerValue) {
|
||||
t.Errorf("err.Error() leaked secret marker: %q", err.Error())
|
||||
}
|
||||
if strings.Contains(ce.Message, secretMarkerValue) {
|
||||
t.Errorf("Message leaked secret marker: %q", ce.Message)
|
||||
}
|
||||
if strings.Contains(ce.Hint, secretMarkerValue) {
|
||||
t.Errorf("Hint leaked secret marker: %q", ce.Hint)
|
||||
}
|
||||
if strings.Contains(string(sel.Source), secretMarkerValue) {
|
||||
t.Errorf("Selection.Source leaked secret marker: %q", sel.Source)
|
||||
}
|
||||
if strings.Contains(sel.DirectCredentialEnv.AppID, secretMarkerValue) {
|
||||
t.Errorf("Selection.DirectCredentialEnv.AppID leaked secret marker: %q", sel.DirectCredentialEnv.AppID)
|
||||
}
|
||||
for _, k := range sel.DirectCredentialEnv.Keys {
|
||||
if strings.Contains(k, secretMarkerValue) {
|
||||
t.Errorf("Selection.DirectCredentialEnv.Keys leaked secret marker: %q", k)
|
||||
}
|
||||
}
|
||||
// State #7 always clears p.selection on the secret-invalid path (see
|
||||
// doResolveAccount); assert it is zero-valued, which trivially implies no
|
||||
// marker anywhere in it and guards against a future field being populated
|
||||
// from the failed resolution.
|
||||
if sel.Source != "" || sel.DirectCredentialEnv.Present ||
|
||||
sel.DirectCredentialEnv.AppID != "" || len(sel.DirectCredentialEnv.Keys) != 0 {
|
||||
t.Errorf("Selection() = %+v, want zero value on profile_secret_invalid", sel)
|
||||
}
|
||||
}
|
||||
|
||||
// State #8: P valid, E complete, app_id matches -> profile source, env present+matched.
|
||||
func TestSelection_State8_ProfileMatchesEnv(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_a") // matches profile app_id
|
||||
t.Setenv(envvars.CliAppSecret, envSecretValue)
|
||||
writeConfigTenantA(t)
|
||||
cp := newProvider(t, "tenant_a", true)
|
||||
|
||||
sel, err := cp.Selection(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if sel.Source != credential.SourceFlagProfile {
|
||||
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceFlagProfile)
|
||||
}
|
||||
if !sel.DirectCredentialEnv.Present || !sel.DirectCredentialEnv.Matched {
|
||||
t.Fatalf("DirectCredentialEnv = %+v, want Present && Matched", sel.DirectCredentialEnv)
|
||||
}
|
||||
if sel.DirectCredentialEnv.AppID != "cli_a" {
|
||||
t.Errorf("DirectCredentialEnv.AppID = %q, want cli_a", sel.DirectCredentialEnv.AppID)
|
||||
}
|
||||
assertNoSecretLeak(t, "state8", string(sel.Source), sel.DirectCredentialEnv.AppID)
|
||||
assertNoSecretLeak(t, "state8-keys", sel.DirectCredentialEnv.Keys...)
|
||||
}
|
||||
|
||||
// State #9: P valid, E complete, app_id mismatches -> profile_app_credential_conflict.
|
||||
func TestSelection_State9_Conflict(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_x") // mismatches profile app_id cli_a
|
||||
t.Setenv(envvars.CliAppSecret, envSecretValue)
|
||||
writeConfigTenantA(t)
|
||||
cp := newProvider(t, "tenant_a", true)
|
||||
|
||||
_, err := cp.Selection(context.Background())
|
||||
if got := subtypeOf(t, err); got != errs.SubtypeProfileAppCredentialConflict {
|
||||
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileAppCredentialConflict)
|
||||
}
|
||||
ve := asValidationError(t, err)
|
||||
if ve.ProfileAppID != "cli_a" {
|
||||
t.Errorf("profile_app_id = %q, want cli_a", ve.ProfileAppID)
|
||||
}
|
||||
if ve.EnvAppID != "cli_x" {
|
||||
t.Errorf("env_app_id = %q, want cli_x", ve.EnvAppID)
|
||||
}
|
||||
assertNoSecretLeak(t, "state9", ve.Message, ve.Hint)
|
||||
}
|
||||
|
||||
// State #10: P valid, E partial -> app_credential_incomplete (env-partial wins).
|
||||
func TestSelection_State10_ProfileWithEnvPartial(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, envSecretValue) // only secret set
|
||||
writeConfigTenantA(t)
|
||||
cp := newProvider(t, "tenant_a", true)
|
||||
|
||||
_, err := cp.Selection(context.Background())
|
||||
if got := subtypeOf(t, err); got != errs.SubtypeAppCredentialIncomplete {
|
||||
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeAppCredentialIncomplete)
|
||||
}
|
||||
ce := asConfigError(t, err)
|
||||
if !slices.Contains(ce.MissingKeys, envvars.CliAppID) {
|
||||
t.Errorf("missing_keys = %v, want to contain %q", ce.MissingKeys, envvars.CliAppID)
|
||||
}
|
||||
assertNoSecretLeak(t, "state10", ce.Message, ce.Hint)
|
||||
assertNoSecretLeak(t, "state10-keys", ce.MissingKeys...)
|
||||
}
|
||||
|
||||
// fakeSidecarProvider is a NON-env extension provider (Priority 0, Name !=
|
||||
// directCredentialProviderName) that always returns a non-nil account. It
|
||||
// stands in for the sidecar extension provider without needing a build tag.
|
||||
type fakeSidecarProvider struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (f *fakeSidecarProvider) Name() string { return "sidecar" }
|
||||
func (f *fakeSidecarProvider) Priority() int { return 0 }
|
||||
func (f *fakeSidecarProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: f.appID, Brand: extcred.Brand("feishu")}, nil
|
||||
}
|
||||
func (f *fakeSidecarProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return &extcred.Token{Value: "sidecar-tok", Source: "sidecar"}, nil
|
||||
}
|
||||
|
||||
// Regression: a NON-env extension provider (sidecar) that returns an account
|
||||
// must win outright even when a profile is set. It must NOT be treated as a
|
||||
// direct-credential env account: no profile arbitration, no
|
||||
// profile_app_credential_conflict (even though its app_id differs from the
|
||||
// profile's cli_a), and DirectCredentialEnv.Present must stay false (§4.2 —
|
||||
// no direct env vars are set). This proves the success-account provider gating
|
||||
// mirrors the block-path guard.
|
||||
func TestSelection_NonEnvExtensionProviderWinsOverProfile(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "") // no direct env credential
|
||||
t.Setenv(envvars.CliAppSecret, "") // no direct env credential
|
||||
writeConfigTenantA(t) // profile tenant_a exists, app_id cli_a
|
||||
|
||||
sidecar := &fakeSidecarProvider{appID: "sidecar_app"} // differs from cli_a
|
||||
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "tenant_a")
|
||||
cp := credential.NewCredentialProvider([]extcred.Provider{sidecar}, defaultAcct, nil, nil)
|
||||
cp.WithProfile("tenant_a", true)
|
||||
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// The sidecar account is used as-is, NOT overridden by profile arbitration.
|
||||
if acct == nil || acct.AppID != "sidecar_app" {
|
||||
t.Fatalf("account = %+v, want AppID sidecar_app (sidecar wins outright)", acct)
|
||||
}
|
||||
|
||||
sel, err := cp.Selection(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected Selection error: %v", err)
|
||||
}
|
||||
// No misreported direct env credential (§4.2).
|
||||
if sel.DirectCredentialEnv.Present {
|
||||
t.Errorf("DirectCredentialEnv.Present = true, want false (no direct env vars set)")
|
||||
}
|
||||
// The mismatched app_id (sidecar_app vs profile cli_a) must NOT trigger a
|
||||
// profile_app_credential_conflict: both ResolveAccount and Selection above
|
||||
// returned nil errors, so no conflict (or any other) error was produced.
|
||||
// Guard against a future regression that surfaces a conflict via Selection.
|
||||
if _, selErr := cp.Selection(context.Background()); selErr != nil {
|
||||
if subtypeOf(t, selErr) == errs.SubtypeProfileAppCredentialConflict {
|
||||
t.Errorf("got profile_app_credential_conflict, want none for non-env provider")
|
||||
}
|
||||
}
|
||||
assertNoSecretLeak(t, "nonenv-sidecar", string(sel.Source), sel.DirectCredentialEnv.AppID)
|
||||
}
|
||||
|
||||
// State #1: P none, E none, C present -> config default (currentApp).
|
||||
func TestSelection_State1_ConfigDefault(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
writeConfigTenantA(t) // CurrentApp = tenant_a
|
||||
cp := newProvider(t, "", false)
|
||||
|
||||
sel, err := cp.Selection(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if sel.Source != credential.SourceConfigCurrentApp {
|
||||
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceConfigCurrentApp)
|
||||
}
|
||||
}
|
||||
43
internal/credential/identity_selection.go
Normal file
43
internal/credential/identity_selection.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
// CredentialSourceKind is the wire-stable App/credential selection source.
|
||||
type CredentialSourceKind string
|
||||
|
||||
const (
|
||||
SourceFlagProfile CredentialSourceKind = "flag:--profile"
|
||||
SourceEnvProfile CredentialSourceKind = "env:LARKSUITE_CLI_PROFILE"
|
||||
SourceEnvAppID CredentialSourceKind = "env:LARKSUITE_CLI_APP_ID"
|
||||
SourceConfigCurrentApp CredentialSourceKind = "config:currentApp"
|
||||
SourceConfigFirstApp CredentialSourceKind = "config:firstApp"
|
||||
)
|
||||
|
||||
// DirectCredentialEnv describes the state of direct app credential env vars.
|
||||
// It never carries a secret value — only names and the non-sensitive app_id.
|
||||
type DirectCredentialEnv struct {
|
||||
Present bool `json:"present"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
AppID string `json:"appId,omitempty"`
|
||||
Matched bool `json:"matched,omitempty"`
|
||||
ConflictsWithProfile bool `json:"conflictsWithProfile,omitempty"`
|
||||
}
|
||||
|
||||
// IdentitySelection is the explainable result of credential selection.
|
||||
// It carries NO secret value (security: §5.1).
|
||||
type IdentitySelection struct {
|
||||
Source CredentialSourceKind
|
||||
DirectCredentialEnv DirectCredentialEnv
|
||||
}
|
||||
|
||||
// Explicit reports whether the identity was actively specified by the
|
||||
// user/agent (flag or env), which governs no-fallback behavior.
|
||||
func (s IdentitySelection) Explicit() bool {
|
||||
switch s.Source {
|
||||
case SourceFlagProfile, SourceEnvProfile, SourceEnvAppID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
25
internal/credential/identity_selection_test.go
Normal file
25
internal/credential/identity_selection_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIdentitySelectionExplicit(t *testing.T) {
|
||||
cases := []struct {
|
||||
src CredentialSourceKind
|
||||
explicit bool
|
||||
}{
|
||||
{SourceFlagProfile, true},
|
||||
{SourceEnvProfile, true},
|
||||
{SourceEnvAppID, true},
|
||||
{SourceConfigCurrentApp, false},
|
||||
{SourceConfigFirstApp, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
sel := IdentitySelection{Source: c.src}
|
||||
if sel.Explicit() != c.explicit {
|
||||
t.Errorf("source %q: Explicit()=%v want %v", c.src, sel.Explicit(), c.explicit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const (
|
||||
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
|
||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
CliProfile = "LARKSUITE_CLI_PROFILE"
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
|
||||
// Sidecar proxy (auth proxy mode)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-shared
|
||||
version: 1.0.0
|
||||
description: "Use for lark-cli setup/auth tasks: auth login/status/logout, user vs bot identity, business-domain permissions (--domain, including all/docs/drive), missing scopes, revoking authorization, or handling _notice JSON."
|
||||
description: "Use for lark-cli setup/auth/profile-selection tasks: auth login/status/logout, user vs bot identity, business-domain permissions (--domain, including all/docs/drive), missing scopes, revoking authorization, handling _notice JSON, profile/tenant/app-identity selection, or any request to make this task/session (or all following lark-cli commands) run under a specific profile/tenant — via LARKSUITE_CLI_PROFILE, --profile, unset LARKSUITE_CLI_PROFILE, or whoami identity diagnostics."
|
||||
---
|
||||
|
||||
# lark-cli 共享规则
|
||||
@@ -32,7 +32,7 @@ lark-cli config init --new
|
||||
| 获取全部权限 | `lark-cli auth login --domain all --no-wait --json` |
|
||||
| 按业务域授权 | `lark-cli auth login --domain docs --domain drive --no-wait --json`;`--domain` 可重复,也可用逗号分隔 |
|
||||
| 指定单个 scope 授权 | `lark-cli auth login --scope "<scope>" --no-wait --json` |
|
||||
| 检查当前登录态、是谁登录、token 是否有效 | `lark-cli auth status --json --verify`;回答时引用 `identity`、`verified`、`identities.user.status`、`identities.user.userName`、`identities.user.openId`(用户 open id)、`identities.user.tokenStatus`、`identities.user.scope` |
|
||||
| 检查当前登录态、是谁登录、token 是否有效 | 必须运行 `lark-cli auth status --json --verify`;回答时引用 `identity`、`verified`、`identities.user.status`、`identities.user.userName`、`identities.user.openId`(用户 open id)、`identities.user.tokenStatus`、`identities.user.scope` |
|
||||
| 快速查看当前身份状态 | `lark-cli whoami`;实际生效的那一个身份 |
|
||||
| 退出当前机器的用户登录态 | `lark-cli auth logout --json`;`loggedOut:true` 表示注销成功 |
|
||||
| bot 缺少权限 | 不要执行 `auth login`;引导用户在开发者后台开通 bot scope,优先复用错误里的 `console_url` |
|
||||
@@ -126,6 +126,10 @@ lark-cli auth login --device-code <device_code>
|
||||
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL
|
||||
- **禁止缓存 `verification_url` 或 `device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用
|
||||
|
||||
## Profile 选择
|
||||
|
||||
Profile selection: use `--profile <profile-or-appId>` for one command; for a task/session, prefix later `lark-cli` commands with `LARKSUITE_CLI_PROFILE=<profile-or-appId>` unless shell env persists, where you may `export` once and later `unset`. Ask if the selector is unknown; do not merely promise. Use `whoami` for the effective app/profile identity and `auth status --json --verify` for OAuth token state. Do not run `lark-cli profile use` unless changing the long-term default, and do not set `LARKSUITE_CLI_APP_ID`/`LARKSUITE_CLI_APP_SECRET` unless direct credentials are provided.
|
||||
|
||||
## 更新检查
|
||||
|
||||
lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。
|
||||
|
||||
Reference in New Issue
Block a user