Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"time"
Expand Down Expand Up @@ -90,7 +91,7 @@ func completeDomain(toComplete string) []string {
return completions
}

// authLoginRun executes the login command logic.
// authLoginRun executes the interactive or direct auth login flow.
func authLoginRun(opts *LoginOptions) error {
f := opts.Factory

Expand Down Expand Up @@ -299,9 +300,11 @@ func authLoginRun(opts *LoginOptions) error {
Scope: result.Token.Scope,
GrantedAt: now,
}
if err := larkauth.SetStoredToken(storedToken); err != nil {
usedFallback, err := larkauth.SetStoredToken(storedToken)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
}
warnIfEncryptedTokenFallback(f.IOStreams.ErrOut, usedFallback)

// Step 8: Update config — overwrite Users to single user, clean old tokens
multi, _ := core.LoadMultiAppConfig()
Expand Down Expand Up @@ -379,9 +382,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
Scope: result.Token.Scope,
GrantedAt: now,
}
if err := larkauth.SetStoredToken(storedToken); err != nil {
usedFallback, err := larkauth.SetStoredToken(storedToken)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
}
warnIfEncryptedTokenFallback(f.IOStreams.ErrOut, usedFallback)

// Update config — overwrite Users to single user, clean old tokens
multi, _ := core.LoadMultiAppConfig()
Expand All @@ -402,6 +407,14 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return nil
}

// warnIfEncryptedTokenFallback explains when auth token persistence downgraded to the encrypted file fallback.
func warnIfEncryptedTokenFallback(w io.Writer, usedFallback bool) {
if !usedFallback {
return
}
fmt.Fprintln(w, "warning: keychain unavailable, auth token stored in a local file protected by filesystem permissions (0600) managed by lark-cli")
}

// collectScopesForDomains collects API scopes (from from_meta projects) and
// shortcut scopes for the given domain names.
func collectScopesForDomains(domains []string, identity string) []string {
Expand Down
32 changes: 32 additions & 0 deletions cmd/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package auth

import (
"bytes"
"context"
"sort"
"strings"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)

// TestSuggestDomain_PrefixMatch verifies prefix matching returns the expected shortcut domain.
func TestSuggestDomain_PrefixMatch(t *testing.T) {
known := map[string]bool{
"calendar": true,
Expand All @@ -34,6 +36,7 @@ func TestSuggestDomain_PrefixMatch(t *testing.T) {
}
}

// TestSuggestDomain_NoMatch verifies unknown prefixes do not produce suggestions.
func TestSuggestDomain_NoMatch(t *testing.T) {
known := map[string]bool{
"calendar": true,
Expand All @@ -45,6 +48,7 @@ func TestSuggestDomain_NoMatch(t *testing.T) {
}
}

// TestSuggestDomain_ExactMatch verifies exact matches resolve without modification.
func TestSuggestDomain_ExactMatch(t *testing.T) {
known := map[string]bool{
"calendar": true,
Expand All @@ -56,6 +60,7 @@ func TestSuggestDomain_ExactMatch(t *testing.T) {
}
}

// TestShortcutSupportsIdentity_DefaultUser verifies shortcuts default to user identity when unspecified.
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
// Empty AuthTypes defaults to ["user"]
sc := common.Shortcut{AuthTypes: nil}
Expand All @@ -67,6 +72,7 @@ func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
}
}

// TestShortcutSupportsIdentity_ExplicitTypes verifies shortcuts honor explicit identity declarations.
func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
sc := common.Shortcut{AuthTypes: []string{"user", "bot"}}
if !shortcutSupportsIdentity(sc, "user") {
Expand All @@ -80,6 +86,7 @@ func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
}
}

// TestShortcutSupportsIdentity_BotOnly verifies user identity is rejected for bot-only shortcuts.
func TestShortcutSupportsIdentity_BotOnly(t *testing.T) {
sc := common.Shortcut{AuthTypes: []string{"bot"}}
if shortcutSupportsIdentity(sc, "user") {
Expand All @@ -90,6 +97,7 @@ func TestShortcutSupportsIdentity_BotOnly(t *testing.T) {
}
}

// TestCompleteDomain verifies shell completion returns matching domain candidates.
func TestCompleteDomain(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
Expand All @@ -115,6 +123,7 @@ func TestCompleteDomain(t *testing.T) {
}
}

// TestCompleteDomain_CommaSeparated verifies completion uses the last comma-separated domain fragment.
func TestCompleteDomain_CommaSeparated(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
Expand All @@ -130,6 +139,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) {
}
}

// TestAllKnownDomains verifies the known-domain list includes registry and shortcut-only domains.
func TestAllKnownDomains(t *testing.T) {
domains := allKnownDomains()
if len(domains) == 0 {
Expand All @@ -144,6 +154,7 @@ func TestAllKnownDomains(t *testing.T) {
}
}

// TestSortedKnownDomains verifies known domains are returned in sorted order.
func TestSortedKnownDomains(t *testing.T) {
sorted := sortedKnownDomains()
if len(sorted) == 0 {
Expand All @@ -161,6 +172,7 @@ func TestSortedKnownDomains(t *testing.T) {
}
}

// TestGetShortcutOnlyDomainNames_HaveDescriptions verifies shortcut-only domains retain descriptions.
func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
for _, name := range getShortcutOnlyDomainNames() {
zhDesc := registry.GetServiceDescription(name, "zh")
Expand All @@ -174,6 +186,7 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
}
}

// TestCollectScopesForDomains verifies domain selection expands to the expected scopes.
func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
Expand Down Expand Up @@ -206,13 +219,15 @@ func TestCollectScopesForDomains(t *testing.T) {
}
}

// TestCollectScopesForDomains_NonexistentDomain verifies unknown domains are ignored safely.
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
if len(scopes) != 0 {
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
}
}

// TestGetDomainMetadata_IncludesFromMeta verifies registry-backed domains appear in metadata output.
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
domains := getDomainMetadata("zh")
nameSet := make(map[string]bool)
Expand All @@ -228,6 +243,7 @@ func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
}
}

// TestGetDomainMetadata_IncludesShortcutOnlyDomains verifies shortcut-only domains appear in metadata output.
func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) {
domains := getDomainMetadata("zh")
nameSet := make(map[string]bool)
Expand All @@ -242,6 +258,7 @@ func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) {
}
}

// TestGetDomainMetadata_Sorted verifies domain metadata is sorted predictably.
func TestGetDomainMetadata_Sorted(t *testing.T) {
domains := getDomainMetadata("zh")
for i := 1; i < len(domains); i++ {
Expand All @@ -251,6 +268,7 @@ func TestGetDomainMetadata_Sorted(t *testing.T) {
}
}

// TestGetDomainMetadata_HasTitleAndDescription verifies metadata entries are populated with user-facing fields.
func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
Expand All @@ -260,6 +278,19 @@ func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) {
}
}

// TestWarnIfEncryptedTokenFallback verifies the fallback warning explains local-file protection.
func TestWarnIfEncryptedTokenFallback(t *testing.T) {
var stderr bytes.Buffer

warnIfEncryptedTokenFallback(&stderr, true)

got := stderr.String()
if !strings.Contains(got, "filesystem permissions") {
t.Fatalf("expected warning to explain filesystem-permission protection, got %q", got)
}
}

// TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint verifies login rejects non-interactive runs without the required flags.
func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu,
Expand All @@ -282,6 +313,7 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
}
}

// TestGetDomainMetadata_ExcludesEvent verifies event is hidden from the interactive login domain list.
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
Expand Down
105 changes: 77 additions & 28 deletions cmd/config/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,69 @@ func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand,
return core.SaveMultiAppConfig(config)
}

// warnIfEncryptedSecretFallback explains when app-secret persistence downgraded to the encrypted file fallback.
func warnIfEncryptedSecretFallback(w io.Writer, secret core.SecretInput, original core.SecretInput) {
if !original.IsPlain() || !secret.IsSecretRef() || secret.Ref.Source != "encrypted_file" {
return
}
fmt.Fprintln(w, "warning: keychain unavailable, app secret stored in a local file protected by filesystem permissions (0600) managed by lark-cli")
}

// validateSecretReuse rejects managed secret references that no longer match the selected app ID.
func validateSecretReuse(appID string, secret core.SecretInput) error {
if !secret.IsSecretRef() {
return nil
}
if secret.Ref.Source == "file" {
return nil
}
expectedID := "appsecret:" + appID
if secret.Ref.ID == expectedID {
return nil
}
return output.ErrValidation("App Secret must be re-entered when App ID changes")
}

// cleanupReplacedCurrentAppSecret removes the previous current-app secret when the backend changes in place.
func cleanupReplacedCurrentAppSecret(existing *core.MultiAppConfig, f *cmdutil.Factory, appID string, newSecret core.SecretInput) {
if existing == nil || len(existing.Apps) == 0 {
return
}
current := existing.Apps[0]
if current.AppId != appID || !current.AppSecret.IsSecretRef() || !newSecret.IsSecretRef() {
return
}
if current.AppSecret.Ref.Source == newSecret.Ref.Source && current.AppSecret.Ref.ID == newSecret.Ref.ID {
return
}
core.RemoveSecretStore(current.AppSecret, f.Keychain)
}

// storeAndSaveOnlyApp persists a single-app config while keeping secret storage, rollback, and cleanup in sync.
func storeAndSaveOnlyApp(existing *core.MultiAppConfig, f *cmdutil.Factory, appID string, plainSecret core.SecretInput, brand core.LarkBrand, lang string) error {
// Keep the secret persistence pipeline centralized here.
// New config-init branches should call this helper instead of reimplementing
// store -> save -> cleanup -> fallback-warning sequencing inline.
if err := validateSecretReuse(appID, plainSecret); err != nil {
return err
}
secret, err := core.ForStorageWithEncryptedFallback(appID, plainSecret, f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
if err := saveAsOnlyApp(appID, secret, brand, lang); err != nil {
if plainSecret.IsPlain() {
core.RemoveSecretStore(secret, f.Keychain)
}
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
cleanupReplacedCurrentAppSecret(existing, f, appID, secret)
cleanupOldConfig(existing, f, appID)
warnIfEncryptedSecretFallback(f.IOStreams.ErrOut, secret, plainSecret)
return nil
}

// configInitRun initializes or updates the local CLI app configuration.
func configInitRun(opts *ConfigInitOptions) error {
f := opts.Factory

Expand All @@ -120,13 +183,9 @@ func configInitRun(opts *ConfigInitOptions) error {
// Mode 1: Non-interactive
if opts.AppID != "" && opts.appSecret != "" {
brand := parseBrand(opts.Brand)
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, opts.AppID)
if err := saveAsOnlyApp(opts.AppID, secret, brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
plainSecret := core.PlainSecret(opts.appSecret)
if err := storeAndSaveOnlyApp(existing, f, opts.AppID, plainSecret, brand, opts.Lang); err != nil {
return err
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
Expand Down Expand Up @@ -161,13 +220,9 @@ func configInitRun(opts *ConfigInitOptions) error {
return output.ErrValidation("app creation returned no result")
}
existing, _ := core.LoadMultiAppConfig()
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, result.AppID)
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
plainSecret := core.PlainSecret(result.AppSecret)
if err := storeAndSaveOnlyApp(existing, f, result.AppID, plainSecret, result.Brand, opts.Lang); err != nil {
return err
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
return nil
Expand All @@ -187,17 +242,16 @@ func configInitRun(opts *ConfigInitOptions) error {

if result.AppSecret != "" {
// New secret provided (either from "create" or "existing" with input)
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, result.AppID)
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
plainSecret := core.PlainSecret(result.AppSecret)
if err := storeAndSaveOnlyApp(existing, f, result.AppID, plainSecret, result.Brand, opts.Lang); err != nil {
return err
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
if existing != nil && len(existing.Apps) > 0 {
if err := validateSecretReuse(result.AppID, existing.Apps[0].AppSecret); err != nil {
return err
}
existing.Apps[0].AppId = result.AppID
existing.Apps[0].Brand = result.Brand
existing.Apps[0].Lang = opts.Lang
Expand Down Expand Up @@ -292,13 +346,8 @@ func configInitRun(opts *ConfigInitOptions) error {
return output.ErrValidation("App ID and App Secret cannot be empty")
}

storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, resolvedAppId)
if err := saveAsOnlyApp(resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
if err := storeAndSaveOnlyApp(existing, f, resolvedAppId, resolvedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return err
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
return nil
Expand Down
Loading