Files
WeKnora/cli/cmd/auth/login.go
nullkey 0e081aec5c feat(cli): --log-level + kb/agent status & check + cross-cutting refactor
Operability surface and the bulk of the jopts→fopts migration:

* --log-level error|warn|info|debug + WEKNORA_LOG_LEVEL env, wired to
  the SDK via client.SetDebugLevel. Invalid --log-level returns FlagError
  (exit 2).
* kb status <kb-id> / kb check <kb-id> verb split (1 HTTP vs 1+N for
  failed_count aggregation).
* agent status <agent-id> / agent check <agent-id> verb split
  (probes kb_scope_all_reachable via 1+N HTTP).
* kb create <name> positional (matches agent create).
* Positional id help strings namespaced (<kb-id> / <agent-id>).
* All auth / context / link / doctor / kb / agent CRUD commands migrated
  to the FormatOptions API.
* root.go Execute(ctx) takes a context so signal-cancellation propagates
  via cmd.Context() into long-running commands.
* Pagination termination uses len(accum) >= total (not page*pageSize)
  so server-capped page sizes do not truncate aggregations.
2026-05-18 11:10:19 +08:00

295 lines
10 KiB
Go

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{
"context", "host", "mode", "user", "tenant_id",
}
// LoginOptions is the configuration captured from flags + prompts.
type LoginOptions struct {
Host string // --host
Context string // --name: context name to write into config.yaml
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 context.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 against a WeKnora server and persist credentials",
Long: `Log in by email + password (interactive prompt) or pipe an API key with --with-token.
Credentials are persisted to the OS keyring when available; otherwise to a
0600 file under $XDG_CONFIG_HOME/weknora/secrets. The named context becomes
the current_context in ~/.config/weknora/config.yaml.`,
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())
run := runF
if run == nil {
run = runLogin
}
svc := loginServiceFor(opts.Host)
if opts.StdinReader == nil {
opts.StdinReader = iostreams.IO.In
}
return run(c.Context(), opts, fopts, f, svc)
},
}
cmd.Flags().StringVar(&opts.Host, "host", "", "WeKnora server URL, e.g. https://kb.example.com")
cmd.Flags().StringVar(&opts.Context, "name", "default", "Context name to register in config.yaml")
cmd.Flags().BoolVar(&opts.WithToken, "with-token", false, "Read an API key from stdin instead of prompting for password")
cmdutil.AddFormatFlag(cmd, authLoginFields...)
_ = cmd.MarkFlagRequired("host")
return cmd
}
// loginServiceFor returns a fresh SDK client targeting host. login.go cannot
// reuse Factory.Client because that closure requires an existing context.
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 {
if err := validateHost(opts.Host); 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 {
return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "validate API key")
}
return persistAPIKey(opts, fopts, f, user)
}
// Interactive: prompt for email + password.
if svc == nil {
return cmdutil.NewError(cmdutil.CodeServerError, "login: no SDK client (host missing?)")
}
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 {
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 context.
// user is the principal returned by /auth/me during pre-persist validation,
// used to populate context.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.Context, "api_key", opts.APIKey); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save api key")
}
ctx := &config.Context{
Host: opts.Host,
APIKeyRef: store.Ref(opts.Context, "api_key"),
}
if user != nil {
ctx.User = user.Email
ctx.TenantID = user.TenantID
}
return saveContextRef(opts, fopts, f, ctx, user)
}
// persistJWT saves access + refresh tokens and writes the context.
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.Context, "access", resp.Token); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save access token")
}
if resp.RefreshToken != "" {
if err := store.Set(opts.Context, "refresh", resp.RefreshToken); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save refresh token")
}
}
c := &config.Context{
Host: opts.Host,
TokenRef: store.Ref(opts.Context, "access"),
RefreshRef: store.Ref(opts.Context, "refresh"),
}
if resp.User != nil {
c.User = resp.User.Email
c.TenantID = resp.User.TenantID
}
return saveContextRef(opts, fopts, f, c, resp.User)
}
// 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 {
Context string `json:"context"`
Host string `json:"host"`
Mode string `json:"mode"` // ModeBearer or ModeAPIKey
User string `json:"user,omitempty"`
TenantID uint64 `json:"tenant_id,omitempty"`
}
// saveContextRef writes the context to config.yaml and prints success.
func saveContextRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, ctx *config.Context, user *sdk.AuthUser) error {
cfg, err := f.Config()
if err != nil {
return err
}
if cfg.Contexts == nil {
cfg.Contexts = map[string]config.Context{}
}
cfg.Contexts[opts.Context] = *ctx
cfg.CurrentContext = opts.Context
if err := config.Save(cfg); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
}
if fopts.WantsJSON() {
result := loginResult{Context: opts.Context, Host: opts.Host, Mode: ModeAPIKey}
if user != nil {
result.Mode = ModeBearer
result.User = user.Email
result.TenantID = user.TenantID
}
return fopts.Emit(iostreams.IO.Out, result)
}
who := opts.Context
if user != nil {
who = user.Email
}
fmt.Fprintf(iostreams.IO.Out, "✓ Logged in to %s as %s (context=%s)\n", opts.Host, who, opts.Context)
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
}