WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
// Package auth holds the cobra commands for authentication
// (login / logout / list / refresh / status / token).
package auth
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// Credential-mode tokens used in the JSON output of auth list / login /
// status / token. The string names describe the HTTP credential type rather
// than the login flow (e.g. JWT → bearer regardless of whether it was
// obtained via password or refresh) so an agent can branch directly on the
// header to construct: `Authorization: Bearer <token>` (ModeBearer) or
// `X-API-Key: <token>` (ModeAPIKey).
const (
ModeBearer = "bearer"
ModeAPIKey = "api-key"
ModeUnknown = "unknown"
)
// modeFromRefs maps the per-profile TokenRef / APIKeyRef presence to a
// canonical credential-mode token. Bearer wins when both are present -
// matches the precedence in cmdutil.buildClient.
func modeFromRefs(apiKeyRef, tokenRef string) string {
switch {
case tokenRef != "":
return ModeBearer
case apiKeyRef != "":
return ModeAPIKey
default:
return ModeUnknown
}
}
// NewCmdAuth builds the `weknora auth` command tree and registers its
// subcommands. Called from cli/cmd/root.go.
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Manage authentication credentials and profiles",
}
cmd.AddCommand(NewCmdLogin(f, nil))
cmd.AddCommand(NewCmdLogout(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdRefresh(f))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdToken(f))
return cmd
}
+132
View File
@@ -0,0 +1,132 @@
package auth
import (
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
"github.com/Tencent/WeKnora/cli/internal/testutil"
sdk "github.com/Tencent/WeKnora/client"
)
func TestNewCmdAuth_TreeShape(t *testing.T) {
cmd := NewCmdAuth(&cmdutil.Factory{})
assert.Equal(t, "auth", cmd.Use)
subs := map[string]*cobra.Command{}
for _, c := range cmd.Commands() {
subs[c.Use] = c
}
assert.Contains(t, subs, "login")
assert.Contains(t, subs, "status")
}
func TestNewCmdLogin_FlagsRegistered(t *testing.T) {
cmd := NewCmdLogin(&cmdutil.Factory{}, nil)
// --format is a persistent root flag (v0.7); only per-command flags here.
assert.NotNil(t, cmd.Flags().Lookup("with-token"), "flag with-token missing")
// login authenticates the active profile; host/name come from config
// (created via `profile add`), so these flags are gone.
assert.Nil(t, cmd.Flags().Lookup("host"), "auth login must not declare --host (v0.9)")
assert.Nil(t, cmd.Flags().Lookup("name"), "auth login must not declare --name (v0.9)")
// --profile is the global persistent override; local registration would
// silently shadow it.
assert.Nil(t, cmd.Flags().Lookup("profile"), "auth login must not declare a local --profile flag (use the global --profile)")
}
func TestNewCmdLogin_InvokesRunF(t *testing.T) {
iostreams.SetForTest(t)
called := false
store := secrets.NewMemStore()
f := &cmdutil.Factory{
Secrets: func() (secrets.Store, error) { return store, nil },
}
cmd := NewCmdLogin(f, func(_ context.Context, opts *LoginOptions, _ *cmdutil.FormatOptions, _ *cmdutil.Factory, _ LoginService) error {
called = true
// host/profile resolve inside runLogin from the active profile;
// the runF seam just receives the parsed flags.
assert.True(t, opts.WithToken)
return nil
})
cmd.SetArgs([]string{"--with-token"})
require.NoError(t, cmd.Execute())
assert.True(t, called)
}
func TestLoginServiceFor(t *testing.T) {
assert.Nil(t, loginServiceFor(""))
assert.NotNil(t, loginServiceFor("https://x"))
}
func TestPersistAPIKey_WritesContext(t *testing.T) {
iostreams.SetForTest(t)
testutil.XDGTempDir(t)
store := secrets.NewMemStore()
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return config.Load() },
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
Secrets: func() (secrets.Store, error) { return store, nil },
}
// persistAPIKey MERGES into the existing active profile record (created
// via `profile add`) rather than creating it; seed it first.
require.NoError(t, config.Save(&config.Config{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{"ci": {Host: "https://kb.example.com"}},
}))
opts := &LoginOptions{
Host: "https://kb.example.com",
Profile: "ci",
APIKey: "sk-zzz",
}
require.NoError(t, persistAPIKey(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
v, _ := store.Get("ci", "api_key")
assert.Equal(t, "sk-zzz", v)
cfg, _ := f.Config()
assert.Equal(t, "ci", cfg.CurrentProfile, "active profile unchanged by login")
assert.Equal(t, "https://kb.example.com", cfg.Profiles["ci"].Host, "existing host preserved by merge")
// APIKeyRef should be the mem:// URI from the store's Ref method.
assert.Equal(t, "mem://ci/api_key", cfg.Profiles["ci"].APIKeyRef)
}
func TestPersistJWT_StoresBothTokens(t *testing.T) {
iostreams.SetForTest(t)
testutil.XDGTempDir(t)
store := secrets.NewMemStore()
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return config.Load() },
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
Secrets: func() (secrets.Store, error) { return store, nil },
}
opts := &LoginOptions{
Host: "https://x",
Profile: "p",
}
resp := &sdk.LoginResponse{
Token: "jwt-acc",
RefreshToken: "jwt-ref",
User: &sdk.AuthUser{Email: "a@b.c", TenantID: 7},
}
require.NoError(t, persistJWT(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, resp))
a, _ := store.Get("p", "access")
r, _ := store.Get("p", "refresh")
assert.Equal(t, "jwt-acc", a)
assert.Equal(t, "jwt-ref", r)
}
func TestReadStdinTrimmed(t *testing.T) {
out, err := readStdinTrimmed(strings.NewReader(" hello \n"))
require.NoError(t, err)
assert.Equal(t, "hello", out)
out, err = readStdinTrimmed(nil)
require.NoError(t, err)
assert.Equal(t, "", out)
}
+124
View File
@@ -0,0 +1,124 @@
// Package auth — dryrun_validation_test.go asserts that --dry-run on
// auth subcommands rejects identically to the live path. Before the
// surrounding fix, validation lived in runX() and was reached only after
// HandleDryRun short-circuited, so --dry-run accepted invocations the live
// path would reject.
package auth
import (
"context"
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// authDryRunFactory builds a Factory whose Client closure panics if invoked —
// dry-run must early-exit before any SDK call. Config is supplied via the cfg
// argument so each test gets isolated state.
func authDryRunFactory(t *testing.T, cfg *config.Config) *cmdutil.Factory {
t.Helper()
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
Client: func() (*sdk.Client, error) {
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
return nil, nil
},
Prompter: func() prompt.Prompter {
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
return nil
},
Secrets: func() (secrets.Store, error) { return secrets.NewMemStore(), nil },
}
}
// withRootHarnessAuth wraps an auth subcommand under a synthetic root cmd
// that registers the global persistent flags.
func withRootHarnessAuth(sub *cobra.Command, args ...string) *cobra.Command {
root := &cobra.Command{Use: "weknora"}
pf := root.PersistentFlags()
pf.BoolP("yes", "y", false, "")
pf.String("format", "", "")
pf.StringP("jq", "q", "", "")
root.AddCommand(sub)
root.SetArgs(append([]string{sub.Name()}, args...))
root.SetContext(context.Background())
root.SilenceErrors = true
root.SilenceUsage = true
return root
}
// TestAuthLogout_DryRun_RejectsNoProfiles: empty config → live path returns
// auth.unauthenticated; --dry-run must do the same.
func TestAuthLogout_DryRun_RejectsNoProfiles(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{}
root := withRootHarnessAuth(NewCmdLogout(authDryRunFactory(t, cfg)),
"--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "dry-run must reject empty config")
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
}
// TestAuthLogout_DryRun_RejectsMissingActiveProfile: the active profile name
// (e.g. set via the global --profile) has no config entry → live path returns
// local.profile_not_found; --dry-run must do the same.
func TestAuthLogout_DryRun_RejectsMissingActiveProfile(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "ghost", // active profile with no matching entry
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
}
root := withRootHarnessAuth(NewCmdLogout(authDryRunFactory(t, cfg)),
"--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "dry-run must reject a missing active profile")
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
assert.Equal(t, cmdutil.CodeLocalProfileNotFound, typed.Code)
}
// TestAuthRefresh_DryRun_RejectsNoCurrentProfile: no current profile → live
// path returns auth.unauthenticated; --dry-run must do the same.
func TestAuthRefresh_DryRun_RejectsNoCurrentProfile(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{}
root := withRootHarnessAuth(NewCmdRefresh(authDryRunFactory(t, cfg)),
"--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err)
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed))
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
}
// TestAuthRefresh_DryRun_RejectsMissingRefreshToken: API-key-only profile →
// live path returns input.invalid_argument ("no refresh token"); --dry-run
// must do the same.
func TestAuthRefresh_DryRun_RejectsMissingRefreshToken(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", APIKeyRef: "k"},
},
}
root := withRootHarnessAuth(NewCmdRefresh(authDryRunFactory(t, cfg)),
"--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err)
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed))
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
+94
View File
@@ -0,0 +1,94 @@
package auth
import (
"fmt"
"sort"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
)
type ListOptions struct{}
// authListFields enumerates the fields surfaced for `--format json` discovery on
// `auth list`. Each entry is a per-profile summary row.
var authListFields = []string{
"name", "host", "user", "mode", "current",
}
type listEntry struct {
Name string `json:"name"`
Host string `json:"host"`
User string `json:"user,omitempty"`
Mode string `json:"mode"` // ModeBearer / ModeAPIKey / ModeUnknown
Current bool `json:"current"`
}
// NewCmdList builds `weknora auth list`. Per-host enumeration: render one
// row per registered profile, marking the active one. Reads only
// ~/.config/weknora/config.yaml - no network, no keyring touch.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List configured authentication profiles",
Long: `Show every configured profile (name, host, user, mode, current). Read-only; no network or keyring access.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
return runList(fopts, f)
},
}
cmdutil.AddFormatFlag(cmd, authListFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "list configured profiles and their credential mode (bearer / api-key)",
Examples: []string{"weknora auth list", "weknora auth list --jq '.data[].name'"},
Output: "envelope.data is an array of profiles with name, host, mode, and which is active",
})
return cmd
}
func runList(fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error {
cfg, err := f.Config()
if err != nil {
return err
}
entries := make([]listEntry, 0, len(cfg.Profiles))
for name, c := range cfg.Profiles {
entries = append(entries, listEntry{
Name: name,
Host: c.Host,
User: c.User,
Mode: modeFromRefs(c.APIKeyRef, c.TokenRef),
Current: name == cfg.CurrentProfile,
})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
if fopts.WantsJSON() {
meta := &output.Meta{Count: output.IntPtr(len(entries))}
return fopts.Emit(iostreams.IO.Out, entries, meta)
}
if len(entries) == 0 {
fmt.Fprintln(iostreams.IO.Out, "No profiles configured. Run `weknora auth login` to create one.")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, " NAME\tHOST\tUSER\tMODE")
for _, e := range entries {
marker := " "
if e.Current {
marker = "* "
}
fmt.Fprintf(tw, "%s%s\t%s\t%s\t%s\n", marker, e.Name, e.Host, format.DashIfEmpty(e.User), e.Mode)
}
return tw.Flush()
}
+86
View File
@@ -0,0 +1,86 @@
package auth
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
func newListFactory(cfg *config.Config) *cmdutil.Factory {
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
}
}
func TestList_TextRender(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", User: "alice@example.com", TokenRef: "keychain://prod/access"},
"staging": {Host: "https://staging", APIKeyRef: "keychain://staging/api_key"},
},
}
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(cfg)))
got := out.String()
// One row per profile, current marked with `*`.
assert.Contains(t, got, "* prod")
assert.Contains(t, got, " staging")
// Mode column.
assert.Contains(t, got, ModeBearer)
assert.Contains(t, got, ModeAPIKey)
// Sorted alphabetically - prod after staging? No: "prod" < "staging".
assert.Less(t, strings.Index(got, "prod"), strings.Index(got, "staging"),
"profiles should render sorted by name")
}
func TestList_Empty(t *testing.T) {
out, _ := iostreams.SetForTest(t)
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(&config.Config{})))
assert.Contains(t, out.String(), "No profiles configured")
}
func TestList_JSON_BareArray(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", User: "alice", TokenRef: "tok"},
"staging": {Host: "https://staging", APIKeyRef: "key"},
},
}
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, newListFactory(cfg)))
var env struct {
OK bool `json:"ok"`
Data []listEntry `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.True(t, env.OK)
got := env.Data
require.Len(t, got, 2)
// Sorted: prod < staging.
assert.Equal(t, "prod", got[0].Name)
assert.True(t, got[0].Current)
assert.Equal(t, ModeBearer, got[0].Mode)
assert.Equal(t, "staging", got[1].Name)
assert.False(t, got[1].Current)
assert.Equal(t, ModeAPIKey, got[1].Mode)
}
func TestModeFromRefs(t *testing.T) {
// Hand-edited config with neither ref set - surface "unknown" rather
// than pretending the profile is a valid login.
assert.Equal(t, ModeUnknown, modeFromRefs("", ""))
assert.Equal(t, ModeBearer, modeFromRefs("", "tok"))
assert.Equal(t, ModeAPIKey, modeFromRefs("key", ""))
assert.Equal(t, ModeBearer, modeFromRefs("key", "tok"), "JWT wins when both set")
}
+400
View File
@@ -0,0 +1,400 @@
package auth
import (
"context"
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// authLoginFields enumerates the fields surfaced for `--format json` discovery on
// `auth login`. The post-login summary has no token values - they stay in the
// keyring; agents who need to verify the credential should re-run
// `auth status`.
var authLoginFields = []string{
"profile", "host", "mode", "email", "tenant_id",
}
// LoginOptions is the configuration captured from flags + prompts. Host and
// Profile are resolved from the active profile in config (not from flags) -
// `auth login` authenticates the already-existing active profile, created
// beforehand with `weknora profile add <name> --host <h> --use`.
type LoginOptions struct {
Host string // resolved from the active profile's Host in config
Profile string // resolved active profile name (honors global --profile)
WithToken bool // --with-token: read api key from stdin instead of prompting password
APIKey string // populated by --with-token from stdin
Email string
Password string
StdinReader io.Reader // override for tests
}
// LoginService is the narrow SDK surface auth login depends on.
// *sdk.Client satisfies it implicitly via the new Login(ctx, LoginRequest)
// signature added in client/auth.go.
type LoginService interface {
Login(ctx context.Context, req sdk.LoginRequest) (*sdk.LoginResponse, error)
}
// apiKeyValidator probes /auth/me with the supplied API key so a bad key
// fails fast at `auth login --with-token` time rather than on the next
// authenticated call.
//
// Returns the resolved user (used to populate Profile.User / TenantID at
// rest, so later `auth list` reflects who owns the key).
type apiKeyValidator func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error)
// defaultAPIKeyValidator builds a one-shot SDK client with the supplied key
// and calls /auth/me. Side-effect-free; no persistence.
var defaultAPIKeyValidator apiKeyValidator = func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error) {
resp, err := sdk.NewClient(host, sdk.WithAPIKey(apiKey)).GetCurrentUser(ctx)
if err != nil {
return nil, err
}
if !resp.Success || resp.Data.User == nil {
return nil, fmt.Errorf("server rejected the API key (no user returned)")
}
return resp.Data.User, nil
}
// NewCmdLogin builds the `weknora auth login` command. runF is the testable
// entrypoint (left nil for production; see cli/cmd/auth/login_test.go).
func NewCmdLogin(f *cmdutil.Factory, runF func(context.Context, *LoginOptions, *cmdutil.FormatOptions, *cmdutil.Factory, LoginService) error) *cobra.Command {
opts := &LoginOptions{}
cmd := &cobra.Command{
Use: "login",
Short: "Authenticate the active profile against its WeKnora server",
Long: `Authenticate the active profile by email + password (interactive prompt) or
pipe an API key with --with-token.
` + "`auth login`" + ` operates on the active profile (override with the global
--profile flag). Create the profile first with
` + "`weknora profile add <name> --host <h> --use`" + `, then run ` + "`weknora auth login`" + `.
Credentials are persisted to the OS keyring when available; otherwise to a
0600 file under $XDG_CONFIG_HOME/weknora/secrets.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
if opts.StdinReader == nil {
opts.StdinReader = iostreams.IO.In
}
if runF != nil {
// Test seam: the injected runF supplies its own service and
// (where needed) seeds opts.Host / opts.Profile itself.
return runF(c.Context(), opts, fopts, f, nil)
}
return runLogin(c.Context(), opts, fopts, f, nil)
},
}
cmd.Flags().BoolVar(&opts.WithToken, "with-token", false, "Read an API key from stdin instead of prompting for password")
cmdutil.AddFormatFlag(cmd, authLoginFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "authenticate the active profile; --with-token reads an API key from stdin (non-interactive)",
Examples: []string{`echo "$WEKNORA_API_KEY" | weknora auth login --with-token`},
Output: "envelope.data is {profile, host, mode, email, tenant_id} on success (email matches `auth status`)",
Warnings: []string{
"a profile must exist and be active first (`weknora profile add <n> --host <url> --use`)",
"password login is interactive-only (no flags) — agents must use --with-token with the key piped to stdin",
},
})
return cmd
}
// resolveActiveProfile loads config and resolves the active profile (honoring
// the global --profile override) plus its persisted Host. `auth login`
// authenticates an already-existing profile; profile creation is `profile
// add`'s job. Returns typed errors when no active profile is configured or
// the profile lacks a host.
func resolveActiveProfile(f *cmdutil.Factory) (name, host string, err error) {
// f.Config() already folds the global --profile / WEKNORA_PROFILE override
// into cfg.CurrentProfile (same source f.ActiveProfile reads), so one load
// resolves the active profile — matches logout/refresh.
cfg, err := f.Config()
if err != nil {
return "", "", err
}
active := cfg.CurrentProfile
if active == "" {
msg := "no active profile; run `weknora profile add <name> --host <h> --use` first"
if envActive, kind := cmdutil.EnvCredential(); envActive {
msg = "no active profile to log in — you are already authenticated this session via " + kind +
"; `auth login` only persists a named profile, so run `weknora profile add <name> --host <h> --use` first if you want one"
}
return "", "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, msg)
}
prof, ok := cfg.Profiles[active]
if !ok {
return "", "", cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("active profile %q not found in config", active))
}
if prof.Host == "" {
return "", "", cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
fmt.Sprintf("profile %q has no host", active))
}
return active, prof.Host, nil
}
// loginServiceFor returns a fresh SDK client targeting host. login.go cannot
// reuse Factory.Client because that closure requires an existing profile.
func loginServiceFor(host string) LoginService {
if host == "" {
return nil
}
return sdk.NewClient(host)
}
func runLogin(ctx context.Context, opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, svc LoginService) error {
// Resolve the target profile + host from the active profile in config.
// `auth login` no longer takes --host / --name; it authenticates the
// already-existing active profile (override via the global --profile).
name, host, err := resolveActiveProfile(f)
if err != nil {
return err
}
opts.Profile = name
opts.Host = host
if svc == nil {
svc = loginServiceFor(host)
}
if err := validateHost(opts.Host); err != nil {
return err
}
// Reject shell-metacharacter / path-like names up-front so opts.Profile
// stays safe to interpolate into the keyring namespace, config.yaml
// keys, and envelope.error.retry_argv. Matches `profile add`.
if err := cmdutil.ValidateProfileName(opts.Profile); err != nil {
return err
}
if opts.WithToken {
key, err := readStdinTrimmed(opts.StdinReader)
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read stdin")
}
if key == "" {
return cmdutil.NewError(cmdutil.CodeInputMissingFlag, "--with-token requires an API key piped to stdin")
}
opts.APIKey = key
// Validate against the server before persisting so a typo'd /
// expired / wrong-host key fails fast at login time. The probe
// is /auth/me - read-only, side-effect-free.
user, err := defaultAPIKeyValidator(ctx, opts.Host, key)
if err != nil {
// Transport errors (connection refused, DNS failure) must not be
// surfaced as auth.bad_credential — the key may be fine but the
// host is unreachable. Classify via WrapHTTP so network errors
// get CodeNetworkError and the hint points at `weknora doctor`.
if cmdutil.ClassifyHTTPError(err) == cmdutil.CodeNetworkError {
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "validate API key: check host reachability")
}
return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "validate API key")
}
return persistAPIKey(opts, fopts, f, user)
}
// Interactive: prompt for email + password. svc is always set by now
// (loginServiceFor returns non-nil because resolveActiveProfile guarantees
// a non-empty host).
if opts.Email == "" || opts.Password == "" {
p := f.Prompter()
if opts.Email == "" {
email, err := p.Input("Email", "")
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeInputMissingFlag, err, "email prompt")
}
opts.Email = email
}
if opts.Password == "" {
pw, err := p.Password("Password")
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeInputMissingFlag, err, "password prompt")
}
opts.Password = pw
}
}
resp, err := svc.Login(ctx, sdk.LoginRequest{Email: opts.Email, Password: opts.Password})
if err != nil {
// Transport errors (connection refused, DNS failure) must not be
// surfaced as auth.bad_credential — credentials may be fine but the
// host is unreachable. Classify so network errors get CodeNetworkError.
if cmdutil.ClassifyHTTPError(err) == cmdutil.CodeNetworkError {
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "login: check host reachability")
}
return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "login")
}
if !resp.Success || resp.Token == "" {
return cmdutil.NewError(cmdutil.CodeAuthBadCredential, fmt.Sprintf("login refused: %s", resp.Message))
}
return persistJWT(opts, fopts, f, resp)
}
// persistAPIKey saves the --with-token API key and writes the profile.
// user is the principal returned by /auth/me during pre-persist validation,
// used to populate Profile.User / TenantID so `auth list` reflects who
// owns the key.
func persistAPIKey(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, user *sdk.AuthUser) error {
store, err := f.Secrets()
if err != nil {
return err
}
warnOnFileFallback(store)
if err := store.Set(opts.Profile, "api_key", opts.APIKey); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save api key")
}
// Switching credential mode: this profile is now an API-key profile, so
// any leftover JWT refs (from a prior password login) are stale. Set the
// api-key ref and clear the token/refresh refs; keep Host + existing
// User/TenantID (merged in saveProfileRef via applyUser).
mutate := func(prof *config.Profile) {
prof.APIKeyRef = store.Ref(opts.Profile, "api_key")
prof.TokenRef = ""
prof.RefreshRef = ""
applyUser(prof, user)
}
return saveProfileRef(opts, fopts, f, mutate, ModeAPIKey, user)
}
// persistJWT saves access + refresh tokens and writes the profile.
func persistJWT(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, resp *sdk.LoginResponse) error {
store, err := f.Secrets()
if err != nil {
return err
}
warnOnFileFallback(store)
if err := store.Set(opts.Profile, "access", resp.Token); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save access token")
}
if resp.RefreshToken != "" {
if err := store.Set(opts.Profile, "refresh", resp.RefreshToken); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save refresh token")
}
}
// Switching to a JWT profile: set token refs and drop any stale api-key
// ref; keep Host + existing User/TenantID (merged in saveProfileRef via applyUser).
mutate := func(prof *config.Profile) {
prof.TokenRef = store.Ref(opts.Profile, "access")
prof.RefreshRef = store.Ref(opts.Profile, "refresh")
prof.APIKeyRef = ""
applyUser(prof, resp.User)
}
return saveProfileRef(opts, fopts, f, mutate, ModeBearer, resp.User)
}
// applyUser overwrites prof.User / prof.TenantID only when the server actually
// returned them. A nil user (or empty email) must NOT wipe an existing User
// that was set during `profile add` or a prior login.
func applyUser(prof *config.Profile, user *sdk.AuthUser) {
if user == nil {
return
}
if user.Email != "" {
prof.User = user.Email
}
if user.TenantID != 0 {
prof.TenantID = user.TenantID
}
}
// loginResult is the typed payload emitted by `--format json`. mode is derived from
// whether the server returned a user (password flow) vs API-key flow.
type loginResult struct {
Profile string `json:"profile"`
Host string `json:"host"`
Mode string `json:"mode"` // ModeBearer or ModeAPIKey
// Email is the authenticated principal's email. Named "email" (not
// "user") so the identity field matches `auth status`, which also
// exposes it as `email` — one key for one concept across both commands.
Email string `json:"email,omitempty"`
TenantID uint64 `json:"tenant_id,omitempty"`
}
// saveProfileRef MERGES the new credential refs into the EXISTING profile
// record and prints success. The active profile already exists (created via
// `profile add`, carrying Host and possibly User/TenantID); mutate sets only
// the credential refs + any server-returned user, so re-login never clobbers
// the host or wipes an existing user. cfg.CurrentProfile is left untouched -
// `auth login` authenticates the already-active profile, it doesn't switch.
func saveProfileRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, mutate func(*config.Profile), mode string, user *sdk.AuthUser) error {
cfg, err := f.Config()
if err != nil {
return err
}
if cfg.Profiles == nil {
cfg.Profiles = map[string]config.Profile{}
}
prof := cfg.Profiles[opts.Profile] // existing record (Host, User, TenantID)
mutate(&prof)
cfg.Profiles[opts.Profile] = prof
if err := config.Save(cfg); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
}
if fopts.WantsJSON() {
// mode reflects the credential actually stored (passed by the caller),
// not whether the server happened to return a user — an API-key login
// validates via /auth/me and DOES get a user, so deriving mode from
// user != nil would mislabel it as bearer.
result := loginResult{Profile: opts.Profile, Host: opts.Host, Mode: mode}
if user != nil {
result.Email = user.Email
result.TenantID = user.TenantID
}
return fopts.Emit(iostreams.IO.Out, result, nil)
}
who := opts.Profile
if user != nil {
who = user.Email
}
fmt.Fprintf(iostreams.IO.Out, "✓ Logged in to %s as %s (profile=%s)\n", opts.Host, who, opts.Profile)
return nil
}
// validateHost rejects empty / non-http URLs early so we surface a clean
// flag error instead of failing inside the SDK transport.
func validateHost(host string) error {
_, err := cmdutil.NormalizeHost(host)
return err
}
// warnOnFileFallback prints a one-shot stderr advisory when the secrets
// store fell back to the plaintext 0600 file backend (keychain unavailable
// - typical on headless CI, WSL without DBus, agent containers). Helps
// users notice that credentials are NOT in the OS keychain before they're
// surprised by it later. doctor's credential_storage check carries the
// same info but agents that bypass doctor would otherwise miss it.
func warnOnFileFallback(store secrets.Store) {
if _, isFile := store.(*secrets.FileStore); !isFile {
return
}
fmt.Fprintln(iostreams.IO.Err, "warning: OS keychain unavailable - credentials will be saved to a 0600 file under $XDG_CONFIG_HOME/weknora/secrets/.")
fmt.Fprintln(iostreams.IO.Err, " install / unlock the keyring (or use `weknora doctor` to inspect) for OS-backed storage.")
}
// readStdinTrimmed reads all of r and returns the result with surrounding
// whitespace stripped. Empty result is returned as-is for the caller to
// validate.
func readStdinTrimmed(r io.Reader) (string, error) {
if r == nil {
return "", nil
}
b, err := io.ReadAll(r)
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
+264
View File
@@ -0,0 +1,264 @@
package auth
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
"github.com/Tencent/WeKnora/cli/internal/testutil"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeLoginService captures the email/password it received.
type fakeLoginService struct {
resp *sdk.LoginResponse
err error
got struct{ email, password string }
}
func (f *fakeLoginService) Login(_ context.Context, req sdk.LoginRequest) (*sdk.LoginResponse, error) {
f.got.email = req.Email
f.got.password = req.Password
return f.resp, f.err
}
// scriptedPrompter satisfies prompt.Prompter with predetermined values.
type scriptedPrompter struct{ email, password string }
func (s scriptedPrompter) Input(string, string) (string, error) { return s.email, nil }
func (s scriptedPrompter) Password(string) (string, error) { return s.password, nil }
func (s scriptedPrompter) Confirm(string, bool) (bool, error) { return true, nil }
func newTestFactoryWithConfig(t *testing.T, p prompt.Prompter) (*cmdutil.Factory, *secrets.MemStore) {
t.Helper()
testutil.XDGTempDir(t)
store := secrets.NewMemStore()
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return config.Load() },
Client: func() (*sdk.Client, error) { panic("client") },
Prompter: func() prompt.Prompter { return p },
Secrets: func() (secrets.Store, error) { return store, nil },
}, store
}
// seedActiveProfile writes an active profile to the (XDG-isolated) config so
// runLogin can resolve host/name from it - `auth login` authenticates the
// already-existing active profile rather than creating one. Returns nothing;
// callers read back via f.Config() to assert merge behavior.
func seedActiveProfile(t *testing.T, name string, prof config.Profile) {
t.Helper()
cfg := &config.Config{
CurrentProfile: name,
Profiles: map[string]config.Profile{name: prof},
}
require.NoError(t, config.Save(cfg))
}
func TestRunLogin_PasswordMode(t *testing.T) {
iostreams.SetForTest(t)
f, store := newTestFactoryWithConfig(t, scriptedPrompter{email: "a@b.c", password: "secret"})
seedActiveProfile(t, "prod", config.Profile{Host: "https://kb.example.com"})
svc := &fakeLoginService{resp: &sdk.LoginResponse{
Success: true,
Token: "jwt-access",
User: &sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7},
}}
require.NoError(t, runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc))
assert.Equal(t, "a@b.c", svc.got.email)
assert.Equal(t, "secret", svc.got.password)
got, _ := store.Get("prod", "access")
assert.Equal(t, "jwt-access", got)
cfg, _ := f.Config()
assert.Equal(t, "https://kb.example.com", cfg.Profiles["prod"].Host, "host must be preserved from the seeded profile")
}
func TestRunLogin_WithToken(t *testing.T) {
iostreams.SetForTest(t)
f, store := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
return &sdk.AuthUser{ID: "u1", Email: "ci@example.com", TenantID: 7}, nil
})
defer restore()
opts := &LoginOptions{
WithToken: true,
StdinReader: strings.NewReader(" sk-1234 \n"),
}
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
got, _ := store.Get("ci", "api_key")
assert.Equal(t, "sk-1234", got)
cfg, _ := f.Config()
assert.Equal(t, "ci@example.com", cfg.Profiles["ci"].User, "validator-returned user should be persisted")
assert.Equal(t, uint64(7), cfg.Profiles["ci"].TenantID)
assert.Equal(t, "https://kb.example.com", cfg.Profiles["ci"].Host, "host preserved from seeded profile")
}
func TestRunLogin_WithToken_JSONReportsAPIKeyMode(t *testing.T) {
out, _ := iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
// API-key validation hits /auth/me, which DOES return a user. mode
// must still be reported as api-key (derived from credential type,
// not from "did the server return a user").
return &sdk.AuthUser{ID: "u1", Email: "ci@example.com", TenantID: 7}, nil
})
defer restore()
opts := &LoginOptions{WithToken: true, StdinReader: strings.NewReader("sk-1234")}
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, nil))
var env struct {
Data struct {
Mode string `json:"mode"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.Equal(t, "api-key", env.Data.Mode, "api-key login must report mode=api-key, not bearer")
}
func TestRunLogin_WithToken_ServerRejects(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
// Use the SDK-format HTTP error message so ClassifyHTTPError detects
// this as an HTTP 401, not a transport/network failure.
return nil, errors.New("HTTP error 401: invalid api key")
})
defer restore()
opts := &LoginOptions{
WithToken: true,
StdinReader: strings.NewReader("sk-bad"),
}
err := runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthBadCredential, typed.Code,
"server-side rejection must surface as auth.bad_credential, not persist the key")
}
// stubAPIKeyValidator swaps defaultAPIKeyValidator for the test and returns
// a restore func to defer.
func stubAPIKeyValidator(fn apiKeyValidator) func() {
saved := defaultAPIKeyValidator
defaultAPIKeyValidator = fn
return func() { defaultAPIKeyValidator = saved }
}
func TestRunLogin_WithToken_Empty(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
// Validator must NOT be called when stdin is empty - verify by setting
// a panic-on-call sentinel.
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
t.Fatal("validator should not be called on empty stdin")
return nil, nil
})
defer restore()
opts := &LoginOptions{
WithToken: true,
StdinReader: strings.NewReader(""),
}
err := runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "input.missing_flag")
}
func TestRunLogin_BadHost(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
// A non-http host stored on the active profile must be rejected by the
// post-resolution validateHost check (config corruption guard).
seedActiveProfile(t, "bad", config.Profile{Host: "ftp://nope"})
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
func TestRunLogin_LoginRefused(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, scriptedPrompter{email: "a@b.c", password: "x"})
seedActiveProfile(t, "p", config.Profile{Host: "https://x"})
svc := &fakeLoginService{resp: &sdk.LoginResponse{Success: false, Message: "bad password"}}
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "auth.bad_credential")
}
func TestValidateHost(t *testing.T) {
require.NoError(t, validateHost("https://kb.example.com"))
require.NoError(t, validateHost("http://localhost:8080"))
require.Error(t, validateHost(""))
require.Error(t, validateHost("ftp://x"))
require.Error(t, validateHost("not a url"))
}
// TestLogin_NoHostNoName asserts the --host / --name flags are gone: `auth
// login` now authenticates the active profile (create it via `profile add`).
func TestLogin_NoHostNoName(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
cmd := NewCmdLogin(f, nil)
assert.Nil(t, cmd.Flags().Lookup("host"), "--host flag must be removed")
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
assert.NotNil(t, cmd.Flags().Lookup("with-token"), "--with-token must remain")
}
// TestLogin_NoActiveProfile_Errors asserts that with no active profile
// configured, login returns a clear auth.unauthenticated error pointing the
// user at `profile add`.
func TestLogin_NoActiveProfile_Errors(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
// No seedActiveProfile: empty config.
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
assert.Contains(t, err.Error(), "profile add")
}
// TestLogin_UsesActiveProfileHost asserts login persists credentials under the
// active profile using that profile's stored host, and that an existing User
// is NOT clobbered when the server (API-key validator) returns it unchanged.
func TestLogin_UsesActiveProfileHost(t *testing.T) {
iostreams.SetForTest(t)
f, store := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
// Pre-seed "prod" with a host AND a user that must survive re-login.
seedActiveProfile(t, "prod", config.Profile{Host: "https://prod.example.com", User: "existing@owner.com"})
restore := stubAPIKeyValidator(func(_ context.Context, host, _ string) (*sdk.AuthUser, error) {
assert.Equal(t, "https://prod.example.com", host, "validator must be called with the active profile's host")
// Server returns nil user (e.g. /auth/me gave no email) - must NOT
// wipe the existing User.
return &sdk.AuthUser{}, nil
})
defer restore()
opts := &LoginOptions{WithToken: true, StdinReader: strings.NewReader("sk-prod")}
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
got, _ := store.Get("prod", "api_key")
assert.Equal(t, "sk-prod", got, "api key must be stored under the active profile")
cfg, _ := f.Config()
prof := cfg.Profiles["prod"]
assert.Equal(t, "https://prod.example.com", prof.Host, "host must be preserved, not clobbered")
assert.Equal(t, "existing@owner.com", prof.User, "existing User must NOT be wiped when server returns none")
assert.NotEmpty(t, prof.APIKeyRef, "api-key ref must be set")
assert.Equal(t, "prod", cfg.CurrentProfile, "active profile unchanged")
}
+197
View File
@@ -0,0 +1,197 @@
package auth
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
)
type LogoutOptions struct {
All bool // --all: clear every profile
Yes bool // sourced from the global -y/--yes persistent flag
DryRun bool
}
// authLogoutFields enumerates the fields surfaced for `--format json` discovery
// on `auth logout`. The result is the list of profile names that were
// logged out.
var authLogoutFields = []string{"removed"}
// logoutResult is the typed payload emitted under data.
type logoutResult struct {
Removed []string `json:"removed"`
}
// NewCmdLogout builds `weknora auth logout`. Clears stored credentials
// (keyring + file fallback) and removes the profile entry from config.yaml.
// No server-side revocation - local-only credential clear.
func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
opts := &LogoutOptions{}
cmd := &cobra.Command{
Use: "logout",
Short: "Remove stored credentials for a profile",
Long: `Clear keyring + file-fallback secrets for one profile (or all of
them with --all) and drop the profile entry from ~/.config/weknora/config.yaml.
Note: this does NOT revoke the credential server-side - for API keys, you
must rotate them in the server UI; for JWT, the token will continue to be
accepted until it expires.`,
Example: ` weknora auth logout # active profile
weknora --profile staging auth logout # specific profile
weknora auth logout --all`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.Yes, _ = c.Flags().GetBool("yes")
// Pure-local validation runs before the dry-run gate so --dry-run
// rejects identically to the live path. Same typed errors as
// runLogout (kept there for direct-call callers).
cfg, cfgErr := f.Config()
if cfgErr != nil {
return cfgErr
}
if len(cfg.Profiles) == 0 {
if active, kind := cmdutil.EnvCredential(); active {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"authenticated via "+kind+" (stateless env credential) — nothing is stored to log out; unset "+kind+" to drop it")
}
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
}
if _, err := pickLogoutTargets(opts, cfg); err != nil {
return err
}
if opts.DryRun {
planArgs := map[string]any{"profile": cfg.CurrentProfile}
if opts.All {
planArgs = map[string]any{"all": true}
}
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
Action: "auth.logout",
Args: planArgs,
}); handled {
return err
}
}
return runLogout(opts, fopts, f)
},
}
cmd.Flags().BoolVar(&opts.All, "all", false, "Log out of every configured profile")
cmdutil.AddFormatFlag(cmd, authLogoutFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetRisk(cmd, "auth.logout")
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "clear stored credentials for the active profile (or --all); the profile itself stays registered (host preserved) so `auth login` can re-auth it — use `profile remove` to delete the profile entirely",
Examples: []string{
"weknora auth logout",
"weknora --profile staging auth logout",
"weknora auth logout --all",
},
Output: "envelope.data is {removed: [profile names cleared]}",
Warnings: []string{
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
"auth logout clears local credentials for this profile; server-side session continues until expiry.",
},
})
return cmd
}
func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error {
cfg, err := f.Config()
if err != nil {
return err
}
if len(cfg.Profiles) == 0 {
if active, kind := cmdutil.EnvCredential(); active {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"authenticated via "+kind+" (stateless env credential) — nothing is stored to log out; unset "+kind+" to drop it")
}
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
}
targets, err := pickLogoutTargets(opts, cfg)
if err != nil {
return err
}
// Destructive-write protocol: confirm before clearing credentials.
scope := fmt.Sprintf("%d profile(s) [%s]", len(targets), strings.Join(targets, ", "))
retryCmd := []string{"weknora", "auth", "logout", "-y"}
if opts.All {
retryCmd = []string{"weknora", "auth", "logout", "--all", "-y"}
}
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "auth credentials", scope, "auth.logout", retryCmd); err != nil {
return err
}
store, err := f.Secrets()
if err != nil {
return err
}
for _, name := range targets {
clearProfileSecrets(store, cfg.Profiles[name], name)
// Keep the profile registered (its host stays) — only clear the
// credential refs so it reads as logged-out and can be re-authed with
// `auth login`. Deleting the profile entirely is `profile remove`'s job;
// that clean logout(who)/remove(what) split matches gh / lark.
p := cfg.Profiles[name]
p.APIKeyRef = ""
p.TokenRef = ""
p.RefreshRef = ""
cfg.Profiles[name] = p
}
if err := config.Save(cfg); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, logoutResult{Removed: targets}, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Logged out of %d profile(s): %s\n", len(targets), strings.Join(targets, ", "))
return nil
}
// pickLogoutTargets resolves the set of profiles to clear from flags + config.
func pickLogoutTargets(opts *LogoutOptions, cfg *config.Config) ([]string, error) {
if opts.All {
names := make([]string, 0, len(cfg.Profiles))
for n := range cfg.Profiles {
names = append(names, n)
}
return names, nil
}
name := cfg.CurrentProfile
if name == "" {
return nil, cmdutil.NewError(cmdutil.CodeInputMissingFlag,
"no active profile set; use the global --profile <profile> or --all")
}
if _, ok := cfg.Profiles[name]; !ok {
return nil, cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("profile %q not found in config", name))
}
return []string{name}, nil
}
// clearProfileSecrets best-effort deletes every secret slot the profile
// references. Errors are swallowed because a missing secret is a no-op
// (tested in keyring_test.go) - we don't want a stale ref to block logout.
func clearProfileSecrets(store secrets.Store, c config.Profile, name string) {
if c.TokenRef != "" {
_ = store.Delete(name, "access")
}
if c.RefreshRef != "" {
_ = store.Delete(name, "refresh")
}
if c.APIKeyRef != "" {
_ = store.Delete(name, "api_key")
}
}
+165
View File
@@ -0,0 +1,165 @@
package auth
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
)
// newLogoutFactory builds a Factory whose Config closure mutates the supplied
// cfg in place - runLogout writes back via config.Save which touches disk, so
// tests use t.Setenv("XDG_CONFIG_HOME", t.TempDir()) at the call site to
// isolate the on-disk file.
func newLogoutFactory(t *testing.T, cfg *config.Config, store secrets.Store) *cmdutil.Factory {
t.Helper()
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
Secrets: func() (secrets.Store, error) { return store, nil },
Prompter: func() prompt.Prompter { return &prompt.AgentPrompter{} },
}
}
func isolateConfig(t *testing.T) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
}
func TestLogout_CurrentProfile(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "access", "jwt-prod"))
require.NoError(t, store.Set("prod", "refresh", "rfr-prod"))
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", TokenRef: store.Ref("prod", "access"), RefreshRef: store.Ref("prod", "refresh")},
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
},
}
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
assert.Equal(t, "prod", cfg.CurrentProfile, "active profile stays selected — logout clears creds, not the profile")
require.Contains(t, cfg.Profiles, "prod", "profile stays registered after logout")
assert.Empty(t, cfg.Profiles["prod"].APIKeyRef, "credential ref cleared")
assert.Empty(t, cfg.Profiles["prod"].TokenRef, "credential ref cleared")
assert.Empty(t, cfg.Profiles["prod"].RefreshRef, "credential ref cleared")
assert.Equal(t, "https://prod", cfg.Profiles["prod"].Host, "host preserved for re-login")
assert.Contains(t, cfg.Profiles, "staging", "non-target profile untouched")
// Secrets gone for the removed profile, kept for the survivor.
if _, err := store.Get("prod", "access"); err == nil {
t.Error("prod access secret should be deleted")
}
if v, _ := store.Get("staging", "api_key"); v != "sk-staging" {
t.Errorf("staging secret unexpectedly cleared: %q", v)
}
}
// TestLogout_ActiveProfileViaOverride exercises targeting a non-default
// profile. Production resolves this via the global --profile flag (which
// rewrites cfg.CurrentProfile in Factory.Config); here we simulate that by
// setting CurrentProfile=staging directly, since runLogout's single target is
// the active profile.
func TestLogout_ActiveProfileViaOverride(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
cfg := &config.Config{
CurrentProfile: "staging", // global --profile staging resolves to this
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", TokenRef: "tok"},
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
},
}
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
require.Contains(t, cfg.Profiles, "staging", "target profile stays registered (creds cleared, not removed)")
assert.Empty(t, cfg.Profiles["staging"].APIKeyRef, "target's credential ref cleared")
assert.Contains(t, cfg.Profiles, "prod", "non-target profile untouched")
}
func TestLogout_All(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
store := secrets.NewMemStore()
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod"},
"staging": {Host: "https://staging"},
},
}
require.NoError(t, runLogout(&LogoutOptions{All: true, Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
// --all clears every profile's credentials but keeps the profiles registered.
require.NotEmpty(t, cfg.Profiles, "profiles stay registered after logout --all")
for name, p := range cfg.Profiles {
assert.Empty(t, p.APIKeyRef, "%s api-key ref cleared", name)
assert.Empty(t, p.TokenRef, "%s token ref cleared", name)
assert.Empty(t, p.RefreshRef, "%s refresh ref cleared", name)
}
assert.Equal(t, "prod", cfg.CurrentProfile, "active selection is preserved; logout clears creds, not the profile")
}
func TestLogout_NoProfiles(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{}
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
}
// TestLogout_ActiveProfileMissing covers the corrupt-config case where the
// active profile name (e.g. set via --profile ghost) has no matching entry.
func TestLogout_ActiveProfileMissing(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "ghost", // points at a non-existent entry
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
}
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeLocalProfileNotFound, typed.Code)
}
func TestLogout_NoCurrentNoFlag(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
}
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
}
// TestLogout_NoNameFlag asserts the --name flag is gone (logout now targets
// the active profile / global --profile) while --all stands alone.
func TestLogout_NoNameFlag(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{Profiles: map[string]config.Profile{"a": {}}}
cmd := NewCmdLogout(newLogoutFactory(t, cfg, secrets.NewMemStore()))
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
assert.NotNil(t, cmd.Flags().Lookup("all"), "--all flag must remain")
}
+169
View File
@@ -0,0 +1,169 @@
package auth
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type RefreshOptions struct {
DryRun bool
}
// authRefreshFields enumerates the fields surfaced for `--format json` discovery
// on `auth refresh`. Token values are intentionally omitted - see refreshResult.
var authRefreshFields = []string{"profile"}
// refreshResult is the typed payload emitted under data on success. Token
// values are intentionally NOT included - emitting them would leak secrets
// into stdout / agent transcripts. Agents needing to verify the new token
// can re-run `weknora auth status` (live API check).
type refreshResult struct {
Profile string `json:"profile"`
}
// NewCmdRefresh builds `weknora auth refresh`. Renews the JWT access
// token by spending the stored refresh_token via POST /auth/refresh -
// the standard OAuth refresh-token grant.
//
// API-key profiles are rejected - they have no refresh semantic;
// rotate the key via the server UI instead.
func NewCmdRefresh(f *cmdutil.Factory) *cobra.Command {
opts := &RefreshOptions{}
cmd := &cobra.Command{
Use: "refresh",
Short: "Renew the JWT access token using the stored refresh token",
Long: `Reads the refresh token previously stored by ` + "`weknora auth login`" + ` and
exchanges it for a new access + refresh token pair via POST /api/v1/auth/refresh.
Both new tokens replace the existing entries in the OS keyring.
API-key profiles are rejected with input.invalid_argument - they have no
refresh semantic. Rotate the key in the server UI instead.`,
Example: ` weknora auth refresh # refresh the active profile
weknora --profile staging auth refresh # refresh a specific profile`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Pure-local validation runs before the dry-run gate so --dry-run
// rejects identically to the live path. Same typed errors as
// runRefresh (kept there for direct-call callers).
cfg, cfgErr := f.Config()
if cfgErr != nil {
return cfgErr
}
name := cfg.CurrentProfile
if name == "" {
if active, kind := cmdutil.EnvCredential(); active {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"authenticated via "+kind+" (stateless env credential): there is no stored JWT to refresh — env credentials are supplied fresh each call, so no refresh is needed")
}
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"no active profile configured; run `weknora auth login` to set one up")
}
prof, ok := cfg.Profiles[name]
if !ok {
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("profile not found: %s", name))
}
if prof.Host == "" {
return cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
fmt.Sprintf("profile %q has no host", name))
}
if prof.RefreshRef == "" {
hint := "api-key profiles can't be refreshed - rotate the key in the server UI and run `weknora auth login --with-token`"
if prof.APIKeyRef == "" {
hint = "no refresh token stored - run `weknora auth login` to authenticate"
}
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("profile %q has no refresh token", name),
Hint: hint,
}
}
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
Action: "auth.refresh",
Args: map[string]any{},
}); handled {
return err
}
return runRefresh(c.Context(), opts, fopts, f, defaultRefresher)
},
}
cmdutil.AddFormatFlag(cmd, authRefreshFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Renew the JWT access token for the active profile (override with the global --profile) using the stored refresh token. API-key profiles are rejected.",
Output: "envelope.data has profile name that was refreshed",
Examples: []string{
"weknora auth refresh",
"weknora --profile staging auth refresh",
},
})
return cmd
}
// defaultRefresher constructs a fresh, unauthenticated SDK client targeting
// host - the /auth/refresh endpoint reads the refresh token from the body,
// so no bearer / api-key header is needed.
func defaultRefresher(host string) cmdutil.Refresher {
return sdk.NewClient(host)
}
func runRefresh(ctx context.Context, opts *RefreshOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, refresherFor func(host string) cmdutil.Refresher) error {
cfg, err := f.Config()
if err != nil {
return err
}
name := cfg.CurrentProfile
if name == "" {
if active, kind := cmdutil.EnvCredential(); active {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"authenticated via "+kind+" (stateless env credential): there is no stored JWT to refresh — env credentials are supplied fresh each call, so no refresh is needed")
}
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"no active profile configured; run `weknora auth login` to set one up")
}
c, ok := cfg.Profiles[name]
if !ok {
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("profile not found: %s", name))
}
if c.Host == "" {
return cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
fmt.Sprintf("profile %q has no host", name))
}
if c.RefreshRef == "" {
hint := "api-key profiles can't be refreshed - rotate the key in the server UI and run `weknora auth login --with-token`"
if c.APIKeyRef == "" {
hint = "no refresh token stored - run `weknora auth login` to authenticate"
}
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("profile %q has no refresh token", name),
Hint: hint,
}
}
store, err := f.Secrets()
if err != nil {
return err
}
if _, err := cmdutil.RefreshAndPersist(ctx, store, refresherFor(c.Host), name); err != nil {
return err
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, refreshResult{Profile: name}, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Refreshed access token for profile %s\n", name)
return nil
}
+237
View File
@@ -0,0 +1,237 @@
package auth
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
"github.com/Tencent/WeKnora/cli/internal/testutil"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeRefreshService scripts a RefreshToken response.
type fakeRefreshService struct {
resp *sdk.RefreshTokenResponse
err error
gotTok string
}
func (f *fakeRefreshService) RefreshToken(_ context.Context, refreshToken string) (*sdk.RefreshTokenResponse, error) {
f.gotTok = refreshToken
return f.resp, f.err
}
// stubSvc returns a closure conforming to the refresherFor signature; it
// ignores host since the fake doesn't talk to the network.
func stubSvc(s cmdutil.Refresher) func(string) cmdutil.Refresher {
return func(string) cmdutil.Refresher { return s }
}
func newRefreshFactory(t *testing.T, cfg *config.Config, store *secrets.MemStore) *cmdutil.Factory {
t.Helper()
testutil.XDGTempDir(t)
require.NoError(t, config.Save(cfg))
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return config.Load() },
Client: func() (*sdk.Client, error) { panic("client") },
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
Secrets: func() (secrets.Store, error) { return store, nil },
}
}
func TestRefresh_Happy(t *testing.T) {
iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "access", "old-access"))
require.NoError(t, store.Set("prod", "refresh", "old-refresh"))
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {
Host: "https://kb.example.com",
TokenRef: "mem://prod/access",
RefreshRef: "mem://prod/refresh",
User: "alice@example.com",
},
},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{
Success: true,
AccessToken: "new-access",
RefreshToken: "new-refresh",
}}
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)))
assert.Equal(t, "old-refresh", svc.gotTok, "must pass stored refresh token to SDK")
gotAccess, _ := store.Get("prod", "access")
gotRefresh, _ := store.Get("prod", "refresh")
assert.Equal(t, "new-access", gotAccess)
assert.Equal(t, "new-refresh", gotRefresh)
}
// TestRefresh_ActiveProfileViaOverride exercises refreshing a non-default
// profile. Production resolves this via the global --profile flag (which
// rewrites cfg.CurrentProfile in Factory.Config); here we set
// CurrentProfile=staging directly, since runRefresh's target is the active
// profile.
func TestRefresh_ActiveProfileViaOverride(t *testing.T) {
iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("staging", "refresh", "stg-refresh"))
cfg := &config.Config{
CurrentProfile: "staging", // global --profile staging resolves to this
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
"staging": {Host: "https://stg", TokenRef: "mem://staging/access", RefreshRef: "mem://staging/refresh"},
},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{
Success: true, AccessToken: "new-stg-access", RefreshToken: "new-stg-refresh",
}}
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)))
assert.Equal(t, "stg-refresh", svc.gotTok, "active profile staging must be refreshed")
// prod (non-active) is untouched
if v, _ := store.Get("prod", "access"); v != "" {
t.Errorf("prod must not have been touched, got %q", v)
}
}
// TestRefresh_NoNameFlag asserts the --name flag is gone; refresh targets the
// active profile (override via the global --profile).
func TestRefresh_NoNameFlag(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{Profiles: map[string]config.Profile{"a": {Host: "https://a"}}}
f := newRefreshFactory(t, cfg, secrets.NewMemStore())
cmd := NewCmdRefresh(f)
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
}
func TestRefresh_NoCurrentProfile(t *testing.T) {
iostreams.SetForTest(t)
f := newRefreshFactory(t, &config.Config{}, secrets.NewMemStore())
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
}
func TestRefresh_APIKeyContext(t *testing.T) {
iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("ci", "api_key", "sk-123"))
cfg := &config.Config{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{"ci": {Host: "https://kb", APIKeyRef: "mem://ci/api_key"}},
}
f := newRefreshFactory(t, cfg, store)
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
assert.Contains(t, typed.Hint, "api-key", "hint should explain api-key profiles cannot be refreshed")
}
func TestRefresh_NoRefreshTokenStored(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
},
}
// MemStore is empty - RefreshRef points to a slot that doesn't exist.
f := newRefreshFactory(t, cfg, secrets.NewMemStore())
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthTokenExpired, typed.Code)
assert.Contains(t, typed.Hint, "auth login")
}
func TestRefresh_ServerRefused(t *testing.T) {
iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "refresh", "stale-refresh"))
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: false, Message: "refresh token expired"}}
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthTokenExpired, typed.Code)
assert.Contains(t, typed.Hint, "auth login")
// stored access must NOT have been overwritten with empty
if v, _ := store.Get("prod", "access"); v == "" {
// Was never set in this test, that's fine - main thing is no panic.
_ = v
}
}
func TestRefresh_TransportError(t *testing.T) {
iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{err: errors.New("connection reset")}
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
// network/transport classified as network.error (mirrors auth login's
// CodeAuthBadCredential mapping pattern; here we keep network errors
// distinct since auth/refresh treats them as retryable).
assert.Equal(t, cmdutil.CodeNetworkError, typed.Code)
}
func TestRefresh_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: true, AccessToken: "a", RefreshToken: "r"}}
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, stubSvc(svc)))
body := out.String()
// payload must not leak the actual token values.
assert.NotContains(t, body, "ok-refresh", "output must not leak refresh token")
assert.NotContains(t, body, "\"a\"", "output must not leak the new access token")
assert.NotContains(t, body, "\"r\"", "output must not leak the new refresh token")
// must mention the profile name so agents can confirm what was refreshed
assert.True(t, strings.Contains(body, "prod"), "output should reference the refreshed profile")
// v0.7 envelope: ok:true is expected
var env struct {
OK bool `json:"ok"`
}
require.NoError(t, json.Unmarshal([]byte(body), &env))
assert.True(t, env.OK, "envelope.ok must be true")
}
+140
View File
@@ -0,0 +1,140 @@
package auth
import (
"context"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// authStatusFields enumerates the fields surfaced for `--format json` discovery
// on `auth status`. Single-resource shape: filter applies to data itself.
var authStatusFields = []string{
"profile", "host", "auth_source", "user_id", "username", "email", "is_active",
"can_access_all_tenants", "tenant_id", "tenant_name",
}
// StatusService is the narrow SDK surface auth status depends on.
type StatusService interface {
GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error)
}
// statusResult is the typed payload emitted by `--format json`. Mirrors the
// SDK AuthUser + AuthTenant projection so agents can branch on
// can_access_all_tenants (cross-tenant admin) and is_active (disabled
// account) without a second round-trip.
type statusResult struct {
Profile string `json:"profile"`
Host string `json:"host,omitempty"`
AuthSource string `json:"auth_source,omitempty"`
UserID string `json:"user_id,omitempty"`
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
IsActive bool `json:"is_active,omitempty"`
CanAccessAllTenants bool `json:"can_access_all_tenants,omitempty"`
TenantID uint64 `json:"tenant_id,omitempty"`
TenantName string `json:"tenant_name,omitempty"`
}
// NewCmdStatus builds the `weknora auth status` command.
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "status",
Short: "Show the active profile, principal, and token state",
Long: `Live-check the active credential by calling /auth/me. Reports the user
and tenant the server resolves the credential to.
Exits with auth.unauthenticated when the token is invalid or missing - run
` + "`weknora auth login`" + ` (or ` + "`auth refresh`" + ` for JWT profiles) to recover.
For JWT profiles the SDK transparently refreshes on 401, so this command
usually only surfaces a hard auth failure.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runStatus(c.Context(), fopts, f, cli)
},
}
cmdutil.AddFormatFlag(cmd, authStatusFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "show the active profile, the authenticated principal, and token state",
Examples: []string{"weknora auth status", "weknora auth status --jq .data.tenant_id"},
Output: "envelope.data is {profile, host, auth_source, user_id, username, email, tenant_id, tenant_name, ...}; auth_source is 'profile + keyring' or a stateless env credential; exit 3 if unauthenticated",
})
return cmd
}
func runStatus(ctx context.Context, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, svc StatusService) error {
if svc == nil {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no SDK client available; run `weknora auth login`")
}
resp, err := svc.GetCurrentUser(ctx)
if err != nil {
return cmdutil.WrapHTTP(err, "fetch current user")
}
user := resp.Data.User
tenant := resp.Data.Tenant
cfg, err := f.Config()
if err != nil {
return err
}
// Effective host + auth source. Stateless env credentials
// (WEKNORA_TOKEN/WEKNORA_API_KEY) override the profile + keyring for the
// client, so report the host they authenticate against (WEKNORA_HOST) and
// that they are in effect — not the bypassed config profile's host.
host := ""
if c, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
host = c.Host
}
authSource := "profile + keyring"
if active, kind := cmdutil.EnvCredential(); active {
authSource = kind + " env (stateless)"
if h := strings.TrimSpace(os.Getenv("WEKNORA_HOST")); h != "" {
host = h
}
}
if fopts.WantsJSON() {
result := statusResult{Profile: cfg.CurrentProfile, Host: host, AuthSource: authSource}
if user != nil {
result.UserID = user.ID
result.Username = user.Username
result.Email = user.Email
result.IsActive = user.IsActive
result.CanAccessAllTenants = user.CanAccessAllTenants
result.TenantID = user.TenantID
}
if tenant != nil {
result.TenantName = tenant.Name
}
return fopts.Emit(iostreams.IO.Out, result, nil)
}
fmt.Fprintf(iostreams.IO.Out, "profile: %s\n", cfg.CurrentProfile)
fmt.Fprintf(iostreams.IO.Out, "auth_source: %s\n", authSource)
fmt.Fprintf(iostreams.IO.Out, "host: %s\n", host)
if user != nil {
fmt.Fprintf(iostreams.IO.Out, "user: %s (%s)\n", user.Email, user.ID)
fmt.Fprintf(iostreams.IO.Out, "tenant: %d", user.TenantID)
if tenant != nil {
fmt.Fprintf(iostreams.IO.Out, " (%s)", tenant.Name)
}
fmt.Fprintln(iostreams.IO.Out)
}
return nil
}
+112
View File
@@ -0,0 +1,112 @@
package auth
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/testutil"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeStatusService struct {
resp *sdk.CurrentUserResponse
err error
}
func (f *fakeStatusService) GetCurrentUser(_ context.Context) (*sdk.CurrentUserResponse, error) {
return f.resp, f.err
}
func newCurrentUserResponse(user *sdk.AuthUser, tenant *sdk.AuthTenant) *sdk.CurrentUserResponse {
r := &sdk.CurrentUserResponse{Success: true}
r.Data.User = user
r.Data.Tenant = tenant
return r
}
func TestRunStatus_TextOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TenantID: 7},
},
}))
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return config.Load() },
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
}
svc := &fakeStatusService{
resp: newCurrentUserResponse(
&sdk.AuthUser{ID: "u1", Email: "alice@example.com", TenantID: 7},
&sdk.AuthTenant{ID: 7, Name: "Acme"},
),
}
require.NoError(t, runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc))
got := out.String()
assert.Contains(t, got, "profile: prod")
assert.Contains(t, got, "auth_source: profile + keyring")
assert.Contains(t, got, "host: https://kb.example.com")
assert.Contains(t, got, "alice@example.com")
assert.Contains(t, got, "Acme")
}
func TestRunStatus_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://x"}},
}))
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
svc := &fakeStatusService{resp: newCurrentUserResponse(&sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7}, nil)}
require.NoError(t, runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, svc))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got: %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Equal(t, "prod", env.Data["profile"], "profile field should be prod")
assert.Contains(t, got, `"email":"a@b.c"`)
}
func TestRunStatus_NoSDKClient(t *testing.T) {
iostreams.SetForTest(t)
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &cmdutil.Factory{}, nil)
require.Error(t, err)
assert.True(t, cmdutil.IsAuthError(err))
}
func TestRunStatus_SDKError_Transport(t *testing.T) {
iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: assert.AnError})
require.Error(t, err)
// Non-HTTP errors (DNS / TCP) are transport problems, not auth problems -
// classify network.error so retry logic / exit code 7 / IsTransient apply.
assert.True(t, cmdutil.IsTransient(err), "expected transient/network classification, got %v", err)
}
func TestRunStatus_SDKError_HTTP401(t *testing.T) {
iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: errors.New("HTTP error 401: invalid token")})
require.Error(t, err)
assert.True(t, cmdutil.IsAuthError(err))
}
+183
View File
@@ -0,0 +1,183 @@
package auth
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// authTokenFields lists fields surfaced in `--help` as a hint for `--jq`
// projection. Single-resource shape: emits the bare token object directly.
var authTokenFields = []string{"token", "mode", "profile"}
type tokenResult struct {
Token string `json:"token"`
Mode string `json:"mode"` // ModeBearer (JWT) or ModeAPIKey
Profile string `json:"profile"`
}
// NewCmdToken builds `weknora auth token`. Prints the active profile's
// credential to stdout for use in shell pipelines, e.g.
//
// WEKNORA_TOKEN=$(weknora auth token)
// curl -H "Authorization: Bearer $WEKNORA_TOKEN" ... # JWT mode
// curl -H "X-API-Key: $WEKNORA_TOKEN" ... # api-key mode
//
// The user is responsible for constructing the appropriate header -
// `auth list` shows which mode each profile uses.
//
// Default output: raw token on stdout, no trailing newline (clean $(...)).
// `--format json[=fields]` emits a bare {token, mode, profile} object.
func NewCmdToken(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "token",
Short: "Print the active profile's credential to stdout",
Long: `Print the active profile's credential to stdout, with no trailing
newline, suitable for shell command substitution.
The credential is the long-lived API key (mode: api-key) or the access JWT
(mode: bearer), depending on how the profile was created. Run ` + "`weknora auth list`" + `
to see which mode each profile uses, and construct the matching HTTP header:
Authorization: Bearer <token> # bearer mode
X-API-Key: <token> # api-key mode
` + "`--profile <name>`" + ` (global flag) selects a non-active profile to read from.`,
Example: ` WEKNORA_TOKEN=$(weknora auth token)
weknora auth token --profile staging
weknora auth token --format json`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
// `auth token` is a scalar scripting helper (WEKNORA_TOKEN=$(...)),
// so it defaults to the raw token — overriding the global json
// default, the same way `doc download` streams raw bytes. Explicit
// --format json / WEKNORA_FORMAT=json still emit the
// {token,mode,profile} envelope.
fopts.FromEnv()
if fopts.Mode == "" {
// --jq implies JSON (it filters the envelope); without it,
// default to the raw token for clean $(...) capture.
if fopts.JQ != "" {
fopts.Mode = cmdutil.FormatJSON
} else {
fopts.Mode = cmdutil.FormatText
}
}
return runToken(f, fopts)
},
}
cmdutil.AddFormatFlag(cmd, authTokenFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "print the active profile's raw credential to stdout for shell capture",
Examples: []string{
"WEKNORA_TOKEN=$(weknora auth token)",
"weknora auth token --profile staging",
"weknora auth token --format json",
},
Output: "raw token on stdout (no envelope, no trailing newline) by default; --format json emits {token, mode, profile}",
Warnings: []string{
"default output is the bare token, NOT the JSON envelope — it is a scripting helper for shell capture",
},
})
return cmd
}
func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
// Env credentials are the active credential on the headless path — `auth
// token` must surface them (they ARE the token / api key) instead of
// erroring on the absence of a stored profile.
if active, kind := cmdutil.EnvCredential(); active {
mode := ModeBearer
if kind == "WEKNORA_API_KEY" {
mode = ModeAPIKey
}
return emitToken(fopts, os.Getenv(kind), mode, "(env)")
}
cfg, err := f.Config()
if err != nil {
return err
}
profileName := cfg.CurrentProfile
if f.ProfileOverride != "" {
profileName = f.ProfileOverride
}
if profileName == "" {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"no active profile configured")
}
ctx, ok := cfg.Profiles[profileName]
if !ok {
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("profile %q not found", profileName))
}
store, err := f.Secrets()
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "init secrets store")
}
// Resolve the stored credential. Prefer bearer (JWT) when both refs
// are present - JWT is the more capable mode and what `buildClient`
// uses for the Authorization header (see factory.go buildClient).
var token, mode string
switch {
case ctx.TokenRef != "":
v, ferr := cmdutil.LoadSecret(store, profileName, "access")
if ferr != nil {
return ferr
}
token, mode = v, ModeBearer
case ctx.APIKeyRef != "":
v, ferr := cmdutil.LoadSecret(store, profileName, "api_key")
if ferr != nil {
return ferr
}
token, mode = v, ModeAPIKey
default:
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
fmt.Sprintf("profile %q has no stored credential; run `weknora auth login`", profileName))
}
if token == "" {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
fmt.Sprintf("profile %q credential is empty in keyring; run `weknora auth login`", profileName))
}
return emitToken(fopts, token, mode, profileName)
}
// emitToken renders a resolved credential: the {token, mode, profile} envelope
// under --format json, else the raw token on stdout (no trailing newline, for
// clean $(weknora auth token) capture) with a TTY-only leak hint on stderr.
func emitToken(fopts *cmdutil.FormatOptions, token, mode, profile string) error {
if token == "" {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "active credential is empty")
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Profile: profile}, nil)
}
// No trailing newline - clean $(weknora auth token) substitution.
fmt.Fprint(iostreams.IO.Out, token)
// Defensive hint to stderr when stdout is an interactive terminal:
// the user likely didn't mean to display the secret on screen.
// stderr-only so scripts (always non-TTY) are unaffected. Mode-specific
// because api-key tokens are long-lived and rotation is the only
// recourse on leak - bearer tokens self-expire via refresh.
if iostreams.IO.IsStdoutTTY() {
fmt.Fprintln(iostreams.IO.Err)
fmt.Fprintln(iostreams.IO.Err, "hint: pipe to $(weknora auth token) to capture; this terminal scrollback now contains the secret")
if mode == ModeAPIKey {
fmt.Fprintln(iostreams.IO.Err, "note: api-key credentials are long-lived - rotate via your auth provider if exposed (no `auth refresh` path)")
}
}
return nil
}
+354
View File
@@ -0,0 +1,354 @@
package auth
import (
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
)
// tokenTestFactory wires a config + in-memory secrets store the same way
// the production Factory does, so runToken exercises the real LoadSecret
// path.
func tokenTestFactory(t *testing.T, cfg *config.Config, store *secrets.MemStore) *cmdutil.Factory {
t.Helper()
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
Secrets: func() (secrets.Store, error) { return store, nil },
}
return f
}
// TestAuthToken_DefaultIsRawToken locks the scripting contract: with no
// explicit --format, `weknora auth token` emits the raw token (so
// WEKNORA_TOKEN=$(weknora auth token) works), NOT the JSON envelope — even
// though the global --format default is json. Explicit --format json still
// emits the {token,mode,profile} envelope (covered by the JSON test below).
func TestAuthToken_DefaultIsRawToken(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "jwt-default-xyz")
out, _ := iostreams.SetForTest(t)
cmd := NewCmdToken(tokenTestFactory(t, cfg, store))
cmd.PersistentFlags().String("format", "", "") // mirror the root persistent flag, unset
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if got := out.String(); got != "jwt-default-xyz" {
t.Errorf("default output must be the raw token (not an envelope); got %q", got)
}
}
// TestAuthToken_JQImpliesJSON: --jq filters the envelope, so passing it without
// an explicit --format must resolve to JSON (not the raw-token default), else
// the filter would be silently dropped.
func TestAuthToken_JQImpliesJSON(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "jwt-jq-xyz")
out, _ := iostreams.SetForTest(t)
cmd := NewCmdToken(tokenTestFactory(t, cfg, store))
cmd.PersistentFlags().String("format", "", "")
cmd.PersistentFlags().StringP("jq", "q", "", "")
// Structural projection: proves jq ran on the JSON envelope (object out),
// not the raw-token text path (which would print the bare string).
cmd.SetArgs([]string{"--jq", ".data | {token}"})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("--jq without --format must filter the JSON envelope, got non-JSON %q: %v", out.String(), err)
}
if got["token"] != "jwt-jq-xyz" {
t.Errorf("jq projection wrong: %+v", got)
}
}
func TestAuthToken_BearerMode_PlainOutput(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access", RefreshRef: "prod:refresh"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "jwt-token-xyz")
out, _ := iostreams.SetForTest(t)
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
if err != nil {
t.Fatalf("runToken: %v", err)
}
got := out.String()
if got != "jwt-token-xyz" {
t.Errorf("expected raw token, got %q", got)
}
if strings.HasSuffix(got, "\n") {
t.Errorf("output must NOT end with newline (clean $(...) substitution); got %q", got)
}
}
func TestAuthToken_APIKeyMode_PlainOutput(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
},
}
store := secrets.NewMemStore()
_ = store.Set("ci", "api_key", "sk_test_apikey_42")
out, _ := iostreams.SetForTest(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
t.Fatalf("runToken: %v", err)
}
if got := out.String(); got != "sk_test_apikey_42" {
t.Errorf("expected api-key value, got %q", got)
}
}
func TestAuthToken_JSON(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "jwt-xyz")
out, _ := iostreams.SetForTest(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil {
t.Fatalf("runToken: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Token string `json:"token"`
Mode string `json:"mode"`
Profile string `json:"profile"`
} `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("parse: %v\n%s", err, out.String())
}
got := env.Data
if got.Token != "jwt-xyz" || got.Mode != "bearer" || got.Profile != "prod" {
t.Errorf("payload wrong: %+v", got)
}
}
func TestAuthToken_JSON_JQProjection(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
},
}
store := secrets.NewMemStore()
_ = store.Set("ci", "api_key", "sk_42")
out, _ := iostreams.SetForTest(t)
// jq projects from the envelope; .data | {token} extracts from the data object inside envelope.
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".data | {token}"}
if err := runToken(tokenTestFactory(t, cfg, store), fopts); err != nil {
t.Fatalf("runToken: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("parse: %v", err)
}
if _, has := got["mode"]; has {
t.Errorf("mode should be filtered out: %+v", got)
}
if got["token"] != "sk_42" {
t.Errorf("token wrong: %+v", got)
}
}
func TestAuthToken_NoCurrentProfile(t *testing.T) {
cfg := &config.Config{}
store := secrets.NewMemStore()
iostreams.SetForTest(t)
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
if err == nil {
t.Fatal("expected error, got nil")
}
if !cmdutil.IsAuthError(err) {
t.Errorf("want auth.* code, got %v", err)
}
}
func TestAuthToken_ProfileOverride(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod.example.com", TokenRef: "prod:access"},
"staging": {Host: "https://staging.example.com", APIKeyRef: "staging:api_key"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "prod-jwt")
_ = store.Set("staging", "api_key", "staging-key")
f := tokenTestFactory(t, cfg, store)
f.ProfileOverride = "staging"
out, _ := iostreams.SetForTest(t)
if err := runToken(f, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
t.Fatalf("runToken: %v", err)
}
if got := out.String(); got != "staging-key" {
t.Errorf("expected staging-key (override), got %q", got)
}
}
func TestAuthToken_NoStoredCredential(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
store := secrets.NewMemStore()
// no Set - keyring is empty
iostreams.SetForTest(t)
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
if err == nil {
t.Fatal("expected auth.unauthenticated, got nil")
}
if !cmdutil.IsAuthError(err) {
t.Errorf("want auth.*, got %v", err)
}
}
func TestAuthToken_ContextWithNoCredentialRefs(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "empty",
Profiles: map[string]config.Profile{
"empty": {Host: "https://kb.example.com"}, // no TokenRef or APIKeyRef
},
}
store := secrets.NewMemStore()
iostreams.SetForTest(t)
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
if err == nil {
t.Fatal("expected auth.unauthenticated, got nil")
}
if !cmdutil.IsAuthError(err) {
t.Errorf("want auth.*, got %v", err)
}
}
// --- stderr advisory tests --------------------------------------------------
//
// auth token prints the token to stdout unconditionally. When stdout is an
// interactive terminal, it ALSO writes a stderr advisory ("you just put the
// secret in your scrollback") + a mode-specific rotation note for api-key
// credentials. The stdout half must stay clean under all modes so $(...)
// substitution is unaffected - tests assert both axes.
func makeBearerCfg() (*config.Config, *secrets.MemStore) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "jwt-xyz")
return cfg, store
}
func makeAPIKeyCfg() (*config.Config, *secrets.MemStore) {
cfg := &config.Config{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
},
}
store := secrets.NewMemStore()
_ = store.Set("ci", "api_key", "sk_42")
return cfg, store
}
func TestAuthToken_NonTTY_NoStderrHint(t *testing.T) {
cfg, store := makeBearerCfg()
out, errBuf := iostreams.SetForTest(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
t.Fatalf("runToken: %v", err)
}
if out.String() != "jwt-xyz" {
t.Errorf("stdout = %q, want %q", out.String(), "jwt-xyz")
}
if errBuf.Len() != 0 {
t.Errorf("non-TTY stderr should be empty (scripts depend on this), got %q", errBuf.String())
}
}
func TestAuthToken_TTY_BearerMode_StderrHintNoRotationNote(t *testing.T) {
cfg, store := makeBearerCfg()
out, errBuf := iostreams.SetForTestWithTTY(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
t.Fatalf("runToken: %v", err)
}
if out.String() != "jwt-xyz" {
t.Errorf("stdout = %q, want raw token only", out.String())
}
if !strings.Contains(errBuf.String(), "scrollback") {
t.Errorf("expected stderr scrollback hint on TTY, got %q", errBuf.String())
}
if strings.Contains(errBuf.String(), "api-key") {
t.Errorf("bearer mode should not surface the api-key rotation note: %q", errBuf.String())
}
}
func TestAuthToken_TTY_APIKeyMode_IncludesRotationNote(t *testing.T) {
cfg, store := makeAPIKeyCfg()
out, errBuf := iostreams.SetForTestWithTTY(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
t.Fatalf("runToken: %v", err)
}
if out.String() != "sk_42" {
t.Errorf("stdout = %q, want raw token only", out.String())
}
stderr := errBuf.String()
if !strings.Contains(stderr, "scrollback") {
t.Errorf("api-key TTY stderr should still include the scrollback hint, got %q", stderr)
}
if !strings.Contains(stderr, "long-lived") || !strings.Contains(stderr, "rotate") {
t.Errorf("api-key mode should include rotation note, got %q", stderr)
}
}
func TestAuthToken_TTY_JSONMode_NoStderrHint(t *testing.T) {
// --format json output mode targets script/agent consumers even when stdout
// happens to be a TTY (e.g. an IDE running the CLI on the user's
// behalf). Hint would pollute their parsing - suppress.
cfg, store := makeBearerCfg()
_, errBuf := iostreams.SetForTestWithTTY(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil {
t.Fatalf("runToken: %v", err)
}
if errBuf.Len() != 0 {
t.Errorf("JSON mode should not emit stderr hint, got %q", errBuf.String())
}
}