Files
xinmi/cli/cmd/auth/login.go
T
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

401 lines
16 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{
"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
}