diff --git a/README.md b/README.md index 3d34166..579f787 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,29 @@ nan auth login --api-token < token.txt # or pipe it in Never put a token on the command line itself: it would end up in your shell history and in `ps`. +**Two-factor authentication.** With it turned on for your account (Settings > +Account > Two-factor authentication at ), +`nan auth login` and the dashboard's sign-in ask for the code from your +authenticator app after the link, without echo. One of your recovery codes +works in its place. When nan cannot ask, pipe the code in. Never type the code +into the command itself, where it would stay in your shell history: read it +into a variable without echo, or redirect it from a file or a password +manager's command line. + +```bash +read -rs code # type or paste it; it is not shown +printf '%s\n' "$code" | nan auth login --link "" --totp - +nan auth login --link "" --totp - < code.txt +``` + +`--totp` never takes the code as an argument: its only value is `-`, which +reads one line of stdin. With `--totp -` the code gets one try, where the +prompt lets you try again; if stdin is a terminal it is still asked for there, +without echo. + +Versions before 0.1.35 cannot ask for the code: the platform refuses the link +with `mfa enabled update nan cli`. Update, then ask for a new link. + ## Build from source Requires Go 1.26.10+ ([mise](https://mise.jdx.dev/) recommended) — the version diff --git a/cmd/auth.go b/cmd/auth.go index a9f0e10..ef6ccee 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "os/signal" "strings" "github.com/charmbracelet/x/term" @@ -23,6 +24,7 @@ var ( linkFlag string keepToolsFlag bool apiTokenFlag bool + totpFlag string ) var authCmd = &cobra.Command{ @@ -35,6 +37,12 @@ var loginCmd = &cobra.Command{ Short: "Log in by email, or save a platform token with --api-token", Long: `Log in with a sign-in link sent to your email. +With two-factor authentication on, the link is followed by the code from +your authenticator app, or one of your recovery codes. It is asked for +without echo, again after a wrong one; with no terminal to ask on, pipe it +in with --totp -. With --totp - the code gets one try, and if stdin is a +terminal it is still asked for there without echo. + With --api-token, save a platform token instead, for a machine with nobody at the keyboard (a server, a CI runner). Create the token in Settings > Tokens at https://cloud.nan.builders. It is read from stdin only, so it @@ -45,7 +53,8 @@ need the email sign-in. An API key (sk-...) can be saved the same way; it replaces the one the Setup tab saved.`, Example: ` nan auth login --email you@example.com nan auth login --api-token # paste the token; it is not shown - nan auth login --api-token < token.txt`, + nan auth login --api-token < token.txt + read -rs code; printf '%s\n' "$code" | nan auth login --link "" --totp - # two-factor, no prompt from nan`, RunE: runLogin, } @@ -63,6 +72,7 @@ func init() { loginCmd.Flags().StringVar(&linkFlag, "link", "", `Finish the login with the link from the email ("-" reads it from stdin, keeping the token out of your shell history)`) loginCmd.Flags().StringVar(&tokenFlag, "token", "", `Save a nan_session token directly, skipping the email ("-" reads it from stdin, keeping it out of your shell history)`) loginCmd.Flags().BoolVar(&apiTokenFlag, "api-token", false, "Save a platform token (nan_pat_...) or API key (sk-...) for nan run and nan runs, read from stdin") + loginCmd.Flags().StringVar(&totpFlag, "totp", "", `With two-factor authentication on, read the code (or a recovery code) from stdin instead of asking: "-" is the only value`) logoutCmd.Flags().BoolVar(&keepToolsFlag, "keep-tools", false, "Leave the API key in the tools this CLI configured") } @@ -72,11 +82,14 @@ func init() { // and the command then asked for a cookie that no longer existed. func runLogin(cmd *cobra.Command, args []string) error { if apiTokenFlag { - if tokenFlag != "" || linkFlag != "" || emailFlag != "" { - return fmt.Errorf("--api-token cannot be combined with --email, --link or --token") + if tokenFlag != "" || linkFlag != "" || emailFlag != "" || totpFlag != "" { + return fmt.Errorf("--api-token cannot be combined with --email, --link, --token or --totp") } return loginWithAPIToken(cmd.Context(), newTokenInput(os.Stdin), os.Stdout, os.Stderr) } + if err := checkTOTPFlag(); err != nil { + return err + } if tokenFlag != "" { token, err := flagValue(tokenFlag) if err != nil { @@ -97,11 +110,11 @@ func runLogin(cmd *cobra.Command, args []string) error { if err != nil { return err } - sessionToken, err := auth.ExchangeToken(token) - if err != nil { - return err - } - return saveToken(sessionToken) + // A code is a line of a few characters; nothing past this is one. + stdin := bufio.NewReader(io.LimitReader(os.Stdin, 1024)) + return finishSignIn(token, newCodeInput(os.Stdin, func() (string, error) { + return stdin.ReadString('\n') + }), os.Stdout, os.Stderr) } in := bufio.NewScanner(os.Stdin) @@ -169,11 +182,206 @@ func runLogin(cmd *cobra.Command, args []string) error { return err } + // The code comes from the same scanner as the two answers before it: a + // pipe that carries all three has already been read into its buffer. + return finishSignIn(token, newCodeInput(os.Stdin, func() (string, error) { + if in.Scan() { + return in.Text(), nil + } + if err := in.Err(); err != nil { + return "", err + } + return "", io.EOF + }), os.Stdout, os.Stderr) +} + +// checkTOTPFlag refuses the --totp spellings that cannot work. +// +// Only "-": --totp never takes the code as an argument. A recovery code is +// good until it is used, and one written on the command line is in the +// shell's history and in `ps` for as long as that lasts. The examples keep it +// off the command line too - read into a variable, or from a file - because +// `echo |` would put it in the history just the same. And not next to another flag that reads stdin, because the first one +// would take everything and leave the code nothing. +func checkTOTPFlag() error { + switch { + case totpFlag == "": + return nil + case totpFlag != "-": + return fmt.Errorf(`--totp only takes "-": it never takes the code as an argument, where it would stay in your shell history and in ps. Read it from stdin:%s`, totpHint) + case tokenFlag != "": + return fmt.Errorf("--totp cannot be combined with --token: a saved session token skips the sign-in, and its code") + case linkFlag == "-": + return fmt.Errorf("--link - and --totp - cannot both read stdin: pass the link as an argument (it works once, and is spent by this command):%s", totpHint) + } + return nil +} + +// totpHint is how to pipe the code in without typing it into the command: +// the variable is filled without echo and never appears in the history. +const totpHint = "\n\n read -rs code; printf '%s\\n' \"$code\" | nan auth login --link \"\" --totp -\n\n" + + "or redirect it from a file or a password manager: --totp - < code.txt" + +// finishSignIn spends the link, answers the two-factor challenge when the +// account has one, and saves the session. +func finishSignIn(token string, codes codeInput, out, prompt io.Writer) error { sessionToken, err := auth.ExchangeToken(token) + var challenge *auth.MFARequiredError + if !errors.As(err, &challenge) { + if err != nil { + return err + } + return saveToken(sessionToken) + } + + signIn, err := answerChallenge(challenge, codes, prompt) if err != nil { return err } - return saveToken(sessionToken) + if err := saveToken(signIn.Session); err != nil { + return err + } + if signIn.UsedRecoveryCode { + fmt.Fprintln(out, recoveryCodesLeft(signIn.RecoveryCodesLeft)) + } + return nil +} + +// answerChallenge asks for the code until the platform takes one or the +// sign-in ends. Asking again is for a member at a terminal: a code piped in +// with --totp - gets one try, and anything else ends with the refusal. +func answerChallenge(challenge *auth.MFARequiredError, codes codeInput, prompt io.Writer) (*auth.SignIn, error) { + if !codes.tty && !codes.fromStdin { + // The link is spent by now - the platform consumed it to open the + // challenge - so this cannot just say "run it again with the flag". + return nil, fmt.Errorf("this account uses two-factor authentication, and there is no terminal here to ask for the code. "+ + "The link is spent: ask for a new one, and pipe the code in with --totp -:%s", totpHint) + } + interactive := codes.tty && !codes.fromStdin + if interactive { + fmt.Fprintln(prompt, "This account uses two-factor authentication. Enter the 6-digit code from your") + fmt.Fprintln(prompt, "authenticator app, or one of your recovery codes.") + } + for { + input, err := codes.read(prompt) + if err != nil { + return nil, err + } + signIn, err := challenge.Complete(input) + if err == nil { + return signIn, nil + } + if !interactive || !auth.Retryable(err) { + return nil, err + } + fmt.Fprintln(prompt, "error: "+err.Error()) + } +} + +// recoveryCodesLeft is what the login says after a recovery code got the +// member in: each works once, and running out is the way to be locked out. +func recoveryCodesLeft(n int) string { + const where = "cloud.nan.builders, Settings > Account > Two-factor authentication" + switch n { + case 0: + return "That was your last recovery code. Make new ones before you need one: " + where + "." + case 1: + return "You signed in with a recovery code: 1 left. Make new ones in the browser: " + where + "." + } + return fmt.Sprintf("You signed in with a recovery code: %d left. You can make new ones in the browser: %s.", n, where) +} + +// readHiddenLine reads a line from the terminal f without echo. +// +// term.ReadPassword turns echo off and only puts it back when it returns. A +// ctrl-c in between ends the process first, and the shell it hands back to is +// left without echo: whatever is typed next is invisible until `reset`. So for +// as long as the prompt is up, an interrupt puts the terminal back before the +// command exits. +func readHiddenLine(f *os.File) ([]byte, error) { + fd := f.Fd() + if state, err := termGetState(fd); err == nil { + release := onInterrupt(func() { + _ = termRestore(fd, state) + fmt.Fprintln(os.Stderr) + }) + defer release() + } + return termReadPassword(fd) +} + +// The terminal calls readHiddenLine makes, as variables so a test can stand +// in for a terminal it does not have and see what the interrupt handler puts +// back. +var ( + termGetState = term.GetState + termRestore = term.Restore + termReadPassword = term.ReadPassword +) + +// interrupted ends the command after ctrl-c, with the status a shell gives an +// interrupted one. A variable for the tests. +var interrupted = func() { os.Exit(130) } + +// onInterrupt runs undo and then interrupted if ctrl-c arrives before release +// is called. Until then the interrupt is this function's to handle, not the +// runtime's, which would exit without undoing anything. +func onInterrupt(undo func()) (release func()) { + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + done := make(chan struct{}) + go func() { + select { + case <-sig: + undo() + interrupted() + case <-done: + } + }() + return func() { + signal.Stop(sig) + close(done) + } +} + +// codeInput is where the second factor comes from: a hidden prompt on a +// terminal, or one line of stdin with --totp -. +type codeInput struct { + tty bool + fromStdin bool + readHidden func() ([]byte, error) + readLine func() (string, error) +} + +// newCodeInput asks f, hidden when it is a terminal; readLine is the next +// line of the same stdin, for --totp -. +func newCodeInput(f *os.File, readLine func() (string, error)) codeInput { + return codeInput{ + tty: term.IsTerminal(f.Fd()), + fromStdin: totpFlag == "-", + readHidden: func() ([]byte, error) { return readHiddenLine(f) }, + readLine: readLine, + } +} + +func (in codeInput) read(prompt io.Writer) (string, error) { + if in.tty { + fmt.Fprint(prompt, "Authenticator code: ") + raw, err := in.readHidden() + fmt.Fprintln(prompt) + if err != nil { + return "", fmt.Errorf("could not read the code: %w", err) + } + return strings.TrimSpace(string(raw)), nil + } + line, err := in.readLine() + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("could not read the code from stdin: %w", err) + } + if line = strings.TrimSpace(line); line == "" { + return "", fmt.Errorf("no code arrived on stdin. The link is spent: ask for a new one, and pipe the code in:%s", totpHint) + } + return line, nil } // A flag whose value is a credential, with "-" meaning stdin. @@ -266,7 +474,7 @@ func newTokenInput(f *os.File) tokenInput { return tokenInput{ r: f, tty: term.IsTerminal(f.Fd()), - readHidden: func() ([]byte, error) { return term.ReadPassword(f.Fd()) }, + readHidden: func() ([]byte, error) { return readHiddenLine(f) }, } } diff --git a/cmd/auth_mfa_test.go b/cmd/auth_mfa_test.go new file mode 100644 index 0000000..953877b --- /dev/null +++ b/cmd/auth_mfa_test.go @@ -0,0 +1,439 @@ +package cmd + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nxssie/nan-cli/internal/auth" + "github.com/nxssie/nan-cli/internal/auth/authtest" +) + +// Two-factor sign-in from `nan auth login`, against a fake platform written +// from the MFA API contract (internal/auth/authtest). + +const testLink = "https://cloud-api.nan.builders/api/auth/login/verify?token=" + authtest.LinkToken + +// mfaPlatform points the sign-in at the fake and gives the login flags back +// their defaults when the test ends: cobra writes them into package vars. +func mfaPlatform(t *testing.T, factor bool) *authtest.Platform { + t.Helper() + p := authtest.New(t) + p.Factor = factor + swap(t, &auth.SignInBaseURL, p.URL) + for _, f := range []*string{&linkFlag, &totpFlag, &tokenFlag, &emailFlag} { + swap(t, f, "") + } + swap(t, &apiTokenFlag, false) + return p +} + +// typed is a terminal on which the member types these, one per prompt. +func typed(answers ...string) (codeInput, *int) { + asked := new(int) + return codeInput{tty: true, readHidden: func() ([]byte, error) { + if *asked >= len(answers) { + return nil, io.EOF + } + *asked++ + return []byte(answers[*asked-1] + "\n"), nil + }}, asked +} + +// piped is stdin that is not a terminal: what --totp - reads. +func piped(stdin string, totp bool) codeInput { + r := strings.NewReader(stdin) + line := func() (string, error) { + var b strings.Builder + buf := make([]byte, 1) + for { + n, err := r.Read(buf) + if n == 1 { + if buf[0] == '\n' { + return b.String(), nil + } + b.WriteByte(buf[0]) + } + if err != nil { + return b.String(), err + } + } + } + return codeInput{fromStdin: totp, readLine: line} +} + +func TestLoginWithoutAFactorIsUnchanged(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, false) + var prompt bytes.Buffer + codes, asked := typed() + if err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt); err != nil { + t.Fatal(err) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } + if *asked != 0 || prompt.Len() != 0 || len(p.Challenges()) != 0 { + t.Errorf("asked for a code an account without a factor does not have: %d prompts, %q", *asked, prompt.String()) + } +} + +func TestLoginAsksForTheCodeOnATerminal(t *testing.T) { + home := fakeHome(t, `{"token":"","apiKey":"sk-kept"}`) + mfaPlatform(t, true) + var prompt bytes.Buffer + codes, asked := typed(authtest.Code) + if err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt); err != nil { + t.Fatal(err) + } + sess, _ := readSession(t, home) + if sess["token"] != authtest.Session || sess["apiKey"] != "sk-kept" { + t.Errorf("session.json = %v", sess) + } + if *asked != 1 || !strings.Contains(prompt.String(), "Authenticator code: ") { + t.Errorf("%d prompts: %q", *asked, prompt.String()) + } + if !strings.Contains(prompt.String(), "recovery codes") { + t.Errorf("the prompt does not say a recovery code works too: %q", prompt.String()) + } +} + +// A wrong code, a replayed one and something that is not a code at all are +// all asked again, on the same sign-in, with what went wrong. +func TestLoginAsksAgainAfterAWrongCode(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + var prompt bytes.Buffer + codes, asked := typed("000000", "not a code", authtest.Code) + if err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt); err != nil { + t.Fatal(err) + } + if *asked != 3 { + t.Errorf("%d prompts, want 3", *asked) + } + for _, want := range []string{"not right (4 attempts left)", "neither a 6-digit code"} { + if !strings.Contains(prompt.String(), want) { + t.Errorf("%q does not say %q", prompt.String(), want) + } + } + // "not a code" never left the machine. + if n := len(p.Challenges()); n != 2 { + t.Errorf("%d challenge POSTs, want 2", n) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } +} + +func TestLoginWithARecoveryCodeSaysHowManyAreLeft(t *testing.T) { + for _, c := range []struct { + codes []string + want string + }{ + {[]string{"q7wa-z2xd-pl4r-6wze", "aaaa-bbbb-cccc-dddd", "eeee-ffff-gggg-hhhh"}, "2 left"}, + {[]string{"q7wa-z2xd-pl4r-6wze"}, "last recovery code"}, + } { + t.Run(c.want, func(t *testing.T) { + fakeHome(t, "") + p := mfaPlatform(t, true) + p.RecoveryCodes = c.codes + var out bytes.Buffer + codes, _ := typed("Q7WA Z2XD PL4R 6WZE") + if err := finishSignIn(authtest.LinkToken, codes, &out, io.Discard); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), c.want) || !strings.Contains(out.String(), "Two-factor authentication") { + t.Errorf("%q does not say %q and where to make more", out.String(), c.want) + } + }) + } +} + +// Each way the sign-in ends saves nothing and says what to do next. +func TestLoginThatCannotFinishSavesNothing(t *testing.T) { + for _, c := range []struct { + name string + setup func(*authtest.Platform) + codes []string + want string + }{ + {"five wrong codes", nil, []string{"000000", "000001", "000002", "000003", "000004"}, "too many wrong codes"}, + {"expired", func(p *authtest.Platform) { p.Expire() }, []string{authtest.Code}, "timed out"}, + {"refused", func(p *authtest.Platform) { + p.Next = &authtest.Refusal{Status: 403, Body: `{"error":"login_refused","message":"x"}`} + }, []string{authtest.Code}, "refused the sign-in"}, + {"nothing typed at all", nil, nil, "could not read the code"}, + } { + t.Run(c.name, func(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + codes, _ := typed(c.codes...) + // The challenge opens on the verify POST, which finishSignIn + // makes; the fake is changed as soon as it has. + if c.setup != nil { + inner := codes.readHidden + first := true + codes.readHidden = func() ([]byte, error) { + if first { + first = false + c.setup(p) + } + return inner() + } + } + err := finishSignIn(authtest.LinkToken, codes, io.Discard, io.Discard) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("err = %v, want %q", err, c.want) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } + }) + } +} + +// --totp - reads one line of stdin, with no prompt and no second try: a +// script cannot answer one. +func TestTOTPFromStdin(t *testing.T) { + t.Run("right", func(t *testing.T) { + home := fakeHome(t, "") + mfaPlatform(t, true) + var prompt bytes.Buffer + if err := finishSignIn(authtest.LinkToken, piped(authtest.Code+"\n", true), io.Discard, &prompt); err != nil { + t.Fatal(err) + } + if prompt.Len() != 0 { + t.Errorf("prompted a pipe: %q", prompt.String()) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } + }) + t.Run("wrong, then nothing", func(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + err := finishSignIn(authtest.LinkToken, piped("000000\n"+authtest.Code+"\n", true), io.Discard, io.Discard) + if err == nil || !strings.Contains(err.Error(), "not right") { + t.Fatalf("err = %v", err) + } + if n := len(p.Challenges()); n != 1 { + t.Errorf("%d tries from a pipe, want 1", n) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } + }) + t.Run("empty", func(t *testing.T) { + fakeHome(t, "") + mfaPlatform(t, true) + err := finishSignIn(authtest.LinkToken, piped("", true), io.Discard, io.Discard) + if err == nil || !strings.Contains(err.Error(), "no code arrived") || !strings.Contains(err.Error(), "--totp -") { + t.Fatalf("err = %v", err) + } + }) +} + +// --totp - with a terminal on stdin: the code is still read without echo, +// and it still gets one try, as the help says. +func TestTOTPFromATerminalStdin(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + codes, asked := typed("000000", authtest.Code) + codes.fromStdin = true + var prompt bytes.Buffer + err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt) + if err == nil || !strings.Contains(err.Error(), "not right") { + t.Fatalf("err = %v", err) + } + if *asked != 1 || len(p.Challenges()) != 1 { + t.Errorf("%d prompts, %d POSTs: --totp - gets one try", *asked, len(p.Challenges())) + } + if !strings.Contains(prompt.String(), "Authenticator code: ") { + t.Errorf("not asked on the terminal: %q", prompt.String()) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } +} + +// An account that has not finished signing up and has a factor finishes in +// the browser: nan auth login asks for no code and saves nothing, and says +// what to do next. +func TestLoginForAnUnfinishedSignUpSendsItToTheBrowser(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + p.Onboarding = true + var prompt bytes.Buffer + codes, asked := typed(authtest.Code) + err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt) + const want = "your NaN account is not set up yet — finish it in the browser: ask for a new link at " + + "https://cloud.nan.builders (this one is spent), complete the setup there, then sign in here again" + if err == nil || err.Error() != want { + t.Fatalf("err = %v", err) + } + if *asked != 0 || prompt.Len() != 0 || len(p.Challenges()) != 0 { + t.Errorf("asked for a code with no challenge: %d prompts, %q", *asked, prompt.String()) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } +} + +// No terminal and no --totp: the link is already spent, so the message says +// to ask for another and names the flag that would have worked. +func TestNoTerminalAndNoFlag(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + err := finishSignIn(authtest.LinkToken, piped(authtest.Code+"\n", false), io.Discard, io.Discard) + if err == nil { + t.Fatal("signed in with no way to ask for the code") + } + for _, want := range []string{"two-factor", "new one", "--totp -"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("read a code nobody asked to be read: %d POSTs", n) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } +} + +// The whole command, as a script runs it: link as an argument, code piped in. +func TestLoginCommandWithTOTPFromStdin(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + withStdin(t, authtest.Code+"\n") + rootCmd.SetArgs([]string{"auth", "login", "--link", testLink, "--totp", "-"}) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + if err := rootCmd.Execute(); err != nil { + t.Fatal(err) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } + if f := p.Verifies(); len(f) != 1 || f[0].Get("mfa") != "1" { + t.Errorf("verify forms = %v", f) + } + assertNoChallengeOnDisk(t, home) +} + +func TestTOTPFlagSpellings(t *testing.T) { + for _, c := range []struct { + name string + args []string + want string + }{ + {"a code on the command line", []string{"--link", testLink, "--totp", authtest.Code}, `only takes "-"`}, + {"two flags reading stdin", []string{"--link", "-", "--totp", "-"}, "cannot both read stdin"}, + {"with a saved token", []string{"--token", "x", "--totp", "-"}, "cannot be combined with --token"}, + {"with --api-token", []string{"--api-token", "--totp", "-"}, "cannot be combined"}, + } { + t.Run(c.name, func(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + rootCmd.SetArgs(append([]string{"auth", "login"}, c.args...)) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("err = %v, want %q", err, c.want) + } + if strings.Contains(err.Error(), authtest.Code) { + t.Errorf("the refusal repeats the code: %v", err) + } + if n := len(p.Verifies()); n != 0 { + t.Errorf("spent the link on flags that cannot work: %d verify POSTs", n) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } + }) + } +} + +// No example of --totp - shows the code typed into the command, which would +// put it in the shell history the flag exists to keep it out of: not the +// help, not the hint the errors print, not the README. +func TestTOTPExamplesKeepTheCodeOffTheCommandLine(t *testing.T) { + readme, err := os.ReadFile(filepath.Join("..", "README.md")) + if err != nil { + t.Fatal(err) + } + for name, text := range map[string]string{ + "nan auth login --help": loginCmd.Example + "\n" + loginCmd.Long, + "the errors' hint": totpHint, + "README.md": string(readme), + } { + var examples int + for _, line := range strings.Split(text, "\n") { + if !strings.Contains(line, "--totp -") { + continue + } + examples++ + if strings.Contains(line, "echo ") || strings.Contains(line, "") || strings.Contains(line, authtest.Code) { + t.Errorf("%s puts the code on the command line: %q", name, strings.TrimSpace(line)) + } + } + if examples == 0 { + t.Errorf("%s has no --totp - example left to check", name) + } + } +} + +// The challenge cookie is a credential for ten minutes and is kept in memory +// only. Whatever the sign-in wrote under HOME - on success and on failure - +// does not contain it, and neither does anything it printed. +func TestTheChallengeCookieNeverReachesDiskOrOutput(t *testing.T) { + for _, c := range []struct { + name string + codes []string + }{ + {"signed in", []string{"000000", authtest.Code}}, + {"gave up", []string{"000000"}}, + } { + t.Run(c.name, func(t *testing.T) { + home := fakeHome(t, `{"token":"","apiKey":"sk-kept"}`) + mfaPlatform(t, true) + var out, prompt bytes.Buffer + codes, _ := typed(c.codes...) + err := finishSignIn(authtest.LinkToken, codes, &out, &prompt) + printed := out.String() + prompt.String() + if err != nil { + printed += err.Error() + } + if strings.Contains(printed, authtest.Pending) { + t.Errorf("printed the challenge cookie: %q", printed) + } + assertNoChallengeOnDisk(t, home) + }) + } +} + +func assertNoChallengeOnDisk(t *testing.T, home string) { + t.Helper() + err := filepath.WalkDir(home, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if bytes.Contains(data, []byte(authtest.Pending)) { + t.Errorf("%s holds the challenge cookie", path) + } + return nil + }) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + t.Fatal(err) + } +} diff --git a/cmd/hidden_unix_test.go b/cmd/hidden_unix_test.go new file mode 100644 index 0000000..35ee927 --- /dev/null +++ b/cmd/hidden_unix_test.go @@ -0,0 +1,82 @@ +//go:build unix + +package cmd + +import ( + "os" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/charmbracelet/x/term" +) + +// Ctrl-c at a hidden prompt used to end the process while echo was off, and +// the shell stayed that way. While the prompt is up, the interrupt is caught: +// the terminal is put back first, and only then does the command exit. +func TestCtrlCAtAHiddenPromptPutsTheTerminalBackFirst(t *testing.T) { + var restored atomic.Bool + exited := make(chan bool, 1) + swap(t, &interrupted, func() { exited <- restored.Load() }) + + release := onInterrupt(func() { restored.Store(true) }) + defer release() + + if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil { + t.Fatal(err) + } + select { + case restoredFirst := <-exited: + if !restoredFirst { + t.Error("exited before putting the terminal back") + } + case <-time.After(5 * time.Second): + t.Fatal("ctrl-c at the prompt was not handled") + } +} + +// readHiddenLine hands the interrupt handler the terminal's own state: a +// ctrl-c while the password is being read restores exactly what GetState +// saved, on the same descriptor, before the command exits. Without a real +// terminal, the three calls are stood in for. +func TestCtrlCWhileReadingRestoresTheSavedState(t *testing.T) { + saved := &term.State{} + f, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + type restore struct { + fd uintptr + state *term.State + } + restored := make(chan restore, 1) + exited := make(chan struct{}) + swap(t, &termGetState, func(uintptr) (*term.State, error) { return saved, nil }) + swap(t, &termRestore, func(fd uintptr, s *term.State) error { restored <- restore{fd, s}; return nil }) + swap(t, &interrupted, func() { close(exited) }) + // The member presses ctrl-c while the prompt is waiting for them. + swap(t, &termReadPassword, func(uintptr) ([]byte, error) { + if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil { + t.Error(err) + } + select { + case <-exited: + case <-time.After(5 * time.Second): + t.Error("ctrl-c while reading was not handled") + } + return nil, os.ErrClosed + }) + + _, _ = readHiddenLine(f) + select { + case got := <-restored: + if got.fd != f.Fd() || got.state != saved { + t.Errorf("restored fd %d state %p, want fd %d state %p", got.fd, got.state, f.Fd(), saved) + } + default: + t.Error("ctrl-c exited without restoring the terminal") + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b4b61e7..57930cc 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -20,8 +20,7 @@ import ( ) const ( - loginRequestURL = "https://cloud-api.nan.builders/api/auth/login/request" - sessionCookie = "nan_session" + sessionCookie = "nan_session" // The domain the platform sends its sign-in links from. Subdomains count: // the link lands on the web app, the API lives next door. linkDomain = "nan.builders" @@ -34,9 +33,30 @@ const ( LinkValidity = "15 minutes" ) -// loginVerifyURL is a variable so a test can point the exchange at a server -// of its own. -var loginVerifyURL = "https://cloud-api.nan.builders/api/auth/login/verify" +// SignInBaseURL is where the three calls of the sign-in go: the link +// request, the link exchange and the two-factor challenge. Nothing else: the +// rest of the CLI (internal/api - nan me, logout, runs, the panel's tabs) +// keeps its own production URLs. +// +// A variable and not a constant for two readers only: the tests, which point +// the sign-in at a server of their own, and a build for QA, which points it at +// the dev platform without a flag or an environment variable that a released +// binary would carry: +// +// go build -ldflags "-X github.com/nxssie/nan-cli/internal/auth.SignInBaseURL=https://cloud-api-dev.nan.builders" -o nan-qa . +// HOME=$(mktemp -d) ./nan-qa auth login --email you@example.com +// +// Run a QA build with a HOME of its own. It saves the dev session in +// ~/.config/nan/session.json, over the member's production one, and every +// command after the sign-in sends that dev session to production: the panel +// takes the refusal for an expired session and clears it, and logout asks +// production, not dev, to end it. +// +// Nothing else sets it. +var SignInBaseURL = "https://cloud-api.nan.builders" + +func loginRequestURL() string { return SignInBaseURL + "/api/auth/login/request" } +func loginVerifyURL() string { return SignInBaseURL + "/api/auth/login/verify" } // clientKind is what this CLI declares on the verify POST, so the platform // records the session it hands back as a CLI session. A CLI session can @@ -61,7 +81,7 @@ func RequestSignInLink(email string) error { return err } client := &http.Client{Timeout: requestTimeout} - resp, err := client.Post(loginRequestURL, "application/json", bytes.NewReader(body)) + resp, err := client.Post(loginRequestURL(), "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("could not reach nan.builders: %w", err) } @@ -119,6 +139,10 @@ func isLinkHost(host string) bool { // cookie back. This does the same POST and keeps the cookie instead of // following the redirect, which is the whole reason the old flow had to send // people into DevTools. +// +// An account with two-factor authentication gets no session from the link +// alone: the answer is a challenge, returned here as *MFARequiredError, and +// the session comes from answering it (MFARequiredError.Complete). func ExchangeToken(token string) (string, error) { client := &http.Client{ Timeout: requestTimeout, @@ -126,23 +150,39 @@ func ExchangeToken(token string) (string, error) { return http.ErrUseLastResponse }, } - form := url.Values{"token": {token}, "client": {clientKind}} - resp, err := client.PostForm(loginVerifyURL, form) + // mfa=1 says this build can answer a two-factor challenge. Without it the + // platform refuses an account that has a factor outright, with a reason + // that tells the member to update - which is what every build before this + // one gets, since none of them could ask for the code. + form := url.Values{"token": {token}, "client": {clientKind}, "mfa": {"1"}} + resp, err := client.PostForm(loginVerifyURL(), form) if err != nil { return "", fmt.Errorf("could not reach nan.builders: %w", err) } defer resp.Body.Close() + var pending string for _, c := range resp.Cookies() { if c.Name == sessionCookie && c.Value != "" { return c.Value, nil } + if c.Name == mfaPendingCookie && c.Value != "" { + pending = c.Value + } } // A spent or expired link redirects to the platform's access-denied page // with the reason in the query, which is more useful than the status code. + // A link that worked for an account with a second factor redirects to the + // page that asks for the code, with the challenge in a cookie. if location, err := resp.Location(); err == nil { - if reason := location.Query().Get("reason"); reason != "" { + reason := location.Query().Get("reason") + switch { + case reason == mfaRequiredReason && pending != "": + return "", &MFARequiredError{pending: pending} + case reason == mfaRequiredReason: + return "", errors.New("nan.builders asked for a two-factor code but sent no sign-in to answer it — ask for a new link and sign in again") + case reason != "": return "", errors.New(linkReasonMessage(reason)) } } @@ -159,11 +199,28 @@ var linkReasons = map[string]string{ "invalid_link": "that link expired or was already used — a link works once and " + "expires " + LinkValidity + " after it is sent, so ask for another one", "missing_token": "that link is incomplete — copy the whole link out of the most recent email", + // What a build from before two-factor sign-in gets for an account that + // has it. This build says it can answer the challenge, so it should never + // be told this; if it is, the sentence is still the right one. + "mfa_enabled_update_nan_cli": "this account uses two-factor authentication, and this nan-cli cannot ask for the code — " + + "update it with the installer (https://github.com/helmcode/nan-cli#install) and ask for a new link", + // An account that has not finished signing up and has two-factor + // authentication on: the setup, behind the code, happens in the browser + // only. The link this was is spent. + "finish_onboarding_in_the_browser": "your NaN account is not set up yet — finish it in the browser: " + + "ask for a new link at https://cloud.nan.builders (this one is spent), complete the setup there, " + + "then sign in here again", } func linkReasonMessage(reason string) string { if message, ok := linkReasons[reason]; ok { return message } + // The reason comes out of a redirect's query, decoded, so it can carry + // anything - an escape sequence, a paragraph. Only something shaped like + // the platform's reasons is put on the terminal. + if !isErrorCode(reason) { + return "the link did not work, and nan.builders did not say why in a way this version can show — ask for a new link" + } return "the link did not work: " + strings.ReplaceAll(reason, "_", " ") } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 3edb67b..b057b2e 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -109,9 +109,9 @@ func TestExchangeTokenDeclaresTheCLI(t *testing.T) { http.Redirect(w, r, "/dashboard", http.StatusSeeOther) })) defer srv.Close() - old := loginVerifyURL - loginVerifyURL = srv.URL - defer func() { loginVerifyURL = old }() + old := SignInBaseURL + SignInBaseURL = srv.URL + defer func() { SignInBaseURL = old }() got, err := ExchangeToken("tok") if err != nil || got != "sess" { diff --git a/internal/auth/authtest/platform.go b/internal/auth/authtest/platform.go new file mode 100644 index 0000000..c747cb2 --- /dev/null +++ b/internal/auth/authtest/platform.go @@ -0,0 +1,330 @@ +// Package authtest is a fake nan.builders sign-in for tests: the link +// exchange and the two-factor challenge, answering the way the platform's API +// contract says they answer (nan-cloud-api openspec/changes/mfa/api-contract.md, +// "POST /api/auth/login/verify", "POST /api/auth/mfa/challenge" and "CLI +// contract"). +// +// It is imported by tests only, so it never reaches the binary. +package authtest + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +const ( + // FrontendURL is the UI the platform redirects to, and the only origin + // the challenge route accepts. + FrontendURL = "https://cloud.nan.builders" + + // PendingCookie is the challenge cookie's name. + PendingCookie = "__Host-nan_mfa_pending" + + // Values a test can look for: in what was sent, and in what must never + // be written anywhere. + LinkToken = "link-token-for-a-test" + Pending = "fake-pending-challenge-0123456789abcdefghij" // 43 base64url characters, like the real one + Session = "fake-session-from-the-platform" + Code = "246810" +) + +// Platform is the fake. Set the exported fields before the first request. +type Platform struct { + *httptest.Server + + // Factor: the account has a confirmed authenticator. Without it the link + // alone signs in, exactly as before two-factor sign-in existed. + Factor bool + // Onboarding: the member is invited or registered and has not finished + // signing up, so a link gets them the onboarding token, never a session. + Onboarding bool + // RecoveryCodes the account has not used yet, in canonical form + // (xxxx-xxxx-xxxx-xxxx). + RecoveryCodes []string + // Next, when set, answers the next challenge POST instead of checking + // it, and is then cleared: a refusal the test wants to see handled. + Next *Refusal + + mu sync.Mutex + // What came in, for the test to look at. + verifies []url.Values + challenges []Request + + spent bool + open bool // a challenge is waiting for its code + ended string // why the last challenge ended: used, exhausted, expired + attempts int // left on the open challenge + codeUsed bool // Code's step has been spent (anti-replay) + lockedUntil time.Time +} + +// Refusal is an answer the test forces on the challenge route. +type Refusal struct { + Status int + Body string + Header map[string]string +} + +// Request is a challenge POST as it arrived. +type Request struct { + Header http.Header + Body string + // Cookie is the challenge cookie it carried, "" without one. + Cookie string +} + +// New starts the fake. It stops when the test ends. +func New(t testing.TB) *Platform { + t.Helper() + p := &Platform{} + mux := http.NewServeMux() + mux.HandleFunc("POST /api/auth/login/request", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + }) + mux.HandleFunc("POST /api/auth/login/verify", p.verify) + mux.HandleFunc("POST /api/auth/mfa/challenge", p.challenge) + p.Server = httptest.NewServer(mux) + t.Cleanup(p.Close) + return p +} + +// Verifies are the forms the verify route received. +func (p *Platform) Verifies() []url.Values { + p.mu.Lock() + defer p.mu.Unlock() + return append([]url.Values(nil), p.verifies...) +} + +// Challenges are the challenge POSTs the platform received. +func (p *Platform) Challenges() []Request { + p.mu.Lock() + defer p.mu.Unlock() + return append([]Request(nil), p.challenges...) +} + +// NewLink lets one more link through, as a new email would. +func (p *Platform) NewLink() { + p.mu.Lock() + defer p.mu.Unlock() + p.spent = false +} + +// Expire ends the open challenge the way ten minutes would. +func (p *Platform) Expire() { + p.mu.Lock() + defer p.mu.Unlock() + if p.open { + p.open, p.ended = false, "expired" + } +} + +// Lock pauses the account's two-factor sign-in until the given time. +func (p *Platform) Lock(until time.Time) { + p.mu.Lock() + defer p.mu.Unlock() + p.lockedUntil = until +} + +func (p *Platform) verify(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + p.mu.Lock() + defer p.mu.Unlock() + p.verifies = append(p.verifies, r.PostForm) + + // The link is consumed in every row of the contract's table. + if r.PostForm.Get("token") != LinkToken || p.spent { + http.Redirect(w, r, FrontendURL+"/access-denied?reason=invalid_link", http.StatusFound) + return + } + p.spent = true + + cli := r.PostForm.Get("client") == "cli" + switch { + case p.Onboarding && !p.Factor: + // Unchanged by two-factor sign-in: the onboarding token, which is not + // a session, and the page that uses it. + http.SetCookie(w, &http.Cookie{Name: "nan_onboarding", Value: "fake-onboarding-token", Path: "/", Secure: true, HttpOnly: true}) + http.Redirect(w, r, FrontendURL+"/onboarding", http.StatusFound) + case p.Onboarding && cli: + // "CLI contract" step 8: with a factor, onboarding is finished in the + // browser, whatever mfa says. No cookie of any kind. + http.Redirect(w, r, FrontendURL+"/access-denied?reason=finish_onboarding_in_the_browser", http.StatusFound) + case !p.Factor: + http.SetCookie(w, &http.Cookie{Name: "nan_session", Value: Session, Path: "/", Domain: ".nan.builders", Secure: true, HttpOnly: true}) + http.Redirect(w, r, FrontendURL+"/", http.StatusFound) + case cli && r.PostForm.Get("mfa") != "1": + http.Redirect(w, r, FrontendURL+"/access-denied?reason=mfa_enabled_update_nan_cli", http.StatusFound) + default: + // A new challenge replaces any earlier one (D8). + p.open, p.ended, p.attempts = true, "", 5 + http.SetCookie(w, &http.Cookie{Name: PendingCookie, Value: Pending, Path: "/", MaxAge: 600, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode}) + http.Redirect(w, r, FrontendURL+"/mfa?reason=mfa_required", http.StatusFound) + } +} + +func (p *Platform) challenge(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16)) + cookie := "" + if c, err := r.Cookie(PendingCookie); err == nil { + cookie = c.Value + } + p.mu.Lock() + defer p.mu.Unlock() + p.challenges = append(p.challenges, Request{Header: r.Header.Clone(), Body: string(body), Cookie: cookie}) + w.Header().Set("Cache-Control", "no-store") + + // The FRONTEND_URL origin only; without an Origin, refused when the + // browser says it came from another site, through otherwise (the CLI). + if o := r.Header.Get("Origin"); o != "" && o != FrontendURL { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "forbidden_origin"}) + return + } + if s := r.Header.Get("Sec-Fetch-Site"); r.Header.Get("Origin") == "" && (s == "cross-site" || s == "same-site") { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "forbidden_origin"}) + return + } + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + + if p.Next != nil { + next := p.Next + p.Next = nil + for k, v := range next.Header { + w.Header().Set(k, v) + } + w.WriteHeader(next.Status) + _, _ = io.WriteString(w, next.Body) + return + } + + if cookie != Pending { + expire(w) + writeJSON(w, http.StatusForbidden, refusal("mfa_challenge_missing")) + return + } + if !p.open { + expire(w) + code := "mfa_challenge_" + p.ended + if p.ended == "" { + code = "mfa_challenge_missing" + } + writeJSON(w, http.StatusForbidden, refusal(code)) + return + } + if time.Now().Before(p.lockedUntil) { + p.locked(w) + return + } + + var answer map[string]string + dec := json.NewDecoder(strings.NewReader(string(body))) + dec.DisallowUnknownFields() + if err := dec.Decode(&answer); err != nil || len(answer) != 1 { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + + if code, ok := answer["code"]; ok { + code = strings.ReplaceAll(code, " ", "") + if len(code) != 6 { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + switch { + case code == Code && !p.codeUsed: + p.codeUsed = true + p.pass(w, nil) + case code == Code: + p.fail(w, "mfa_code_already_used") + default: + p.fail(w, "mfa_code_invalid") + } + return + } + if rc, ok := answer["recovery_code"]; ok { + canonical, ok := canonicalRecovery(rc) + if !ok { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + for i, have := range p.RecoveryCodes { + if have == canonical { + p.RecoveryCodes = append(p.RecoveryCodes[:i:i], p.RecoveryCodes[i+1:]...) + left := len(p.RecoveryCodes) + p.pass(w, &left) + return + } + } + p.fail(w, "mfa_recovery_code_invalid") + return + } + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) +} + +func (p *Platform) pass(w http.ResponseWriter, recoveryLeft *int) { + p.open, p.ended = false, "used" + expire(w) + http.SetCookie(w, &http.Cookie{Name: "nan_session", Value: Session, Path: "/", Domain: ".nan.builders", Secure: true, HttpOnly: true}) + writeJSON(w, http.StatusOK, map[string]any{"redirect": "/", "recovery_codes_remaining": recoveryLeft}) +} + +func (p *Platform) fail(w http.ResponseWriter, code string) { + p.attempts-- + if p.attempts <= 0 { + p.open, p.ended = false, "exhausted" + expire(w) + writeJSON(w, http.StatusForbidden, refusal("mfa_challenge_exhausted")) + return + } + body := refusal(code) + body["attempts_remaining"] = p.attempts + writeJSON(w, http.StatusBadRequest, body) +} + +func (p *Platform) locked(w http.ResponseWriter) { + secs := int(time.Until(p.lockedUntil).Seconds()) + 1 + w.Header().Set("Retry-After", fmt.Sprint(secs)) + body := refusal("mfa_locked") + body["locked_until"] = p.lockedUntil.UTC().Format(time.RFC3339) + body["retry_after"] = secs + writeJSON(w, http.StatusTooManyRequests, body) +} + +// canonicalRecovery is the platform's reading of a recovery code: sixteen +// base32 characters in any case, with or without hyphens and spaces. +func canonicalRecovery(s string) (string, bool) { + s = strings.ToLower(strings.NewReplacer("-", "", " ", "").Replace(s)) + if len(s) != 16 { + return "", false + } + for _, r := range s { + if !(r >= 'a' && r <= 'z' || r >= '2' && r <= '7') { + return "", false + } + } + return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16], true +} + +func refusal(code string) map[string]any { + return map[string]any{"error": code, "message": "A sentence the client must not branch on."} +} + +func expire(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: PendingCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/auth/mfa.go b/internal/auth/mfa.go new file mode 100644 index 0000000..e0c8f5a --- /dev/null +++ b/internal/auth/mfa.go @@ -0,0 +1,321 @@ +package auth + +// Two-factor sign-in. +// +// A member who has turned on two-factor authentication does not get a session +// from the link alone. The verify POST answers with a challenge instead - a +// cookie that names it, and a redirect whose reason says so - and the session +// comes back only once a code from the member's authenticator app, or one of +// their recovery codes, is posted against that challenge. +// +// The challenge cookie is a credential for the next ten minutes, so it lives +// in memory and nowhere else: not in session.json, not in an error message, +// not in a log. A process that exits halfway leaves it to expire on the +// platform, and the member asks for a new link. + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + mfaPendingCookie = "__Host-nan_mfa_pending" + mfaRequiredReason = "mfa_required" + + // What ends every message about a sign-in that cannot go on. The link was + // spent when the challenge was opened, so a new one is the only way back. + newLinkHint = "ask for a new link and sign in again" +) + +func mfaChallengeURL() string { return SignInBaseURL + "/api/auth/mfa/challenge" } + +// MFARequiredError is what ExchangeToken returns when the link was good and +// the account asks for a second factor. Answer it with Complete. +type MFARequiredError struct { + pending string +} + +func (e *MFARequiredError) Error() string { + return "this account uses two-factor authentication: enter the 6-digit code from your authenticator app, or one of your recovery codes" +} + +// Format prints the message whatever the verb. Without it %#v - or %d, or +// anything else fmt does not route to Error - prints the struct, and the +// struct is the challenge. +func (e *MFARequiredError) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, e.Error()) } + +// SignIn is a challenge answered. +type SignIn struct { + Session string + // UsedRecoveryCode is set when the answer was a recovery code, and + // RecoveryCodesLeft is then how many the member still has. + UsedRecoveryCode bool + RecoveryCodesLeft int +} + +// ErrNotACode is input that is neither shape the platform takes, refused here +// without spending one of the sign-in's attempts on it. +var ErrNotACode = errors.New("that is neither a 6-digit code nor a recovery code (xxxx-xxxx-xxxx-xxxx) — " + + "type the code your authenticator app shows for nan.builders") + +// MFAError is the platform refusing a code, or the challenge it was posted to. +type MFAError struct { + // The platform's code for it, e.g. "mfa_code_invalid". Empty when the + // answer carried none. + Code string + Status int + Attempts int // attempts left on this sign-in, for a wrong code + LockedTill time.Time + RetryAfter time.Duration +} + +// Retryable reports whether the same challenge can take another answer: the +// code was wrong, already used or not a code at all, and the sign-in is still +// open. Anything else ends it, and the member needs a new link. +func Retryable(err error) bool { + if errors.Is(err, ErrNotACode) { + return true + } + var e *MFAError + if !errors.As(err, &e) { + return false + } + switch e.Code { + case "mfa_code_invalid", "mfa_recovery_code_invalid", "mfa_code_already_used", + // Nothing was spent: the platform could not read what was sent. + "invalid_request": + return true + } + return false +} + +func (e *MFAError) Error() string { + switch e.Code { + case "mfa_code_invalid": + return "that code is not right" + attemptsLeft(e.Attempts) + case "mfa_recovery_code_invalid": + return "that recovery code is not right, or it was already used" + attemptsLeft(e.Attempts) + case "mfa_code_already_used": + return "that code was already used — wait for the next one in your authenticator app" + attemptsLeft(e.Attempts) + case "invalid_request": + return "nan.builders could not read that — type the 6-digit code from your authenticator app, or a recovery code (xxxx-xxxx-xxxx-xxxx)" + case "mfa_challenge_exhausted": + return "too many wrong codes for this sign-in — " + newLinkHint + case "mfa_challenge_expired": + return "this sign-in timed out: the code has to be entered within 10 minutes of using the link — " + newLinkHint + case "mfa_challenge_used": + return "this sign-in is already over (it was finished, or a newer sign-in replaced it) — " + newLinkHint + case "mfa_challenge_missing": + return "nan.builders has no record of this sign-in — " + newLinkHint + case "login_refused": + return "nan.builders refused the sign-in for this account — " + newLinkHint + case "mfa_locked": + return "too many wrong codes on this account: two-factor sign-in is paused " + e.pausedFor() + " — then " + newLinkHint + case "rate_limited": + return "too many sign-in attempts from this network — wait " + e.wait() + " and " + newLinkHint + } + if e.Code == "" || e.Status >= 500 { + return fmt.Sprintf("nan.builders could not check the code (HTTP %d) — %s", e.Status, newLinkHint) + } + // A code from a newer platform than this build still gets said, rather + // than swallowed. + return fmt.Sprintf("nan.builders refused the code: %s (HTTP %d) — %s", + strings.ReplaceAll(e.Code, "_", " "), e.Status, newLinkHint) +} + +// attemptsLeft is the tail of a wrong-code message. The platform always says +// how many are left on those; a body without the number says nothing rather +// than "0 attempts left", which would read as the end of the sign-in. +func attemptsLeft(n int) string { + switch { + case n <= 0: + return "" + case n == 1: + return " (1 attempt left)" + } + return fmt.Sprintf(" (%d attempts left)", n) +} + +// now is the clock the lock message is read against. A variable for the tests. +var now = time.Now + +// pausedFor says when the lock ends. locked_until is the platform's clock and +// retry_after the seconds it counts from now; the second is what the +// countdown uses, so a machine whose clock is off still waits the right time. +func (e *MFAError) pausedFor() string { + if e.LockedTill.IsZero() { + return "for " + e.wait() + } + until, today := e.LockedTill.Local(), now().Local() + layout := "15:04" + if until.YearDay() != today.YearDay() || until.Year() != today.Year() { + layout = "Mon 2 Jan 15:04" + } + if e.RetryAfter <= 0 { + return "until " + until.Format(layout) + } + return "until " + until.Format(layout) + " (" + e.wait() + " from now)" +} + +func (e *MFAError) wait() string { + d := e.RetryAfter + if d <= 0 { + return "a few minutes" + } + if d < time.Minute { + return fmt.Sprintf("%d seconds", int(d.Seconds())) + } + mins := int((d + time.Minute - 1) / time.Minute) + if mins == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", mins) +} + +// normalizeAnswer decides what the member typed, the way the platform does: six +// digits once the spaces are out is an authenticator code, and sixteen +// characters of the RFC 4648 base32 alphabet (A-Z and 2-7) a recovery code - in +// any case, with or without its hyphens. That is the alphabet the platform +// makes them in, so a 0, 1, 8 or 9 is a typo, refused before it costs one of +// the sign-in's attempts. +func normalizeAnswer(input string) (field, value string, err error) { + compact := strings.Map(func(r rune) rune { + switch r { + case ' ', '\t', '-': + return -1 + } + return r + }, strings.TrimSpace(input)) + + if len(compact) == 6 && allASCII(compact, isDigit) { + return "code", compact, nil + } + if len(compact) == 16 && allASCII(compact, isBase32) { + c := strings.ToLower(compact) + return "recovery_code", c[0:4] + "-" + c[4:8] + "-" + c[8:12] + "-" + c[12:16], nil + } + return "", "", ErrNotACode +} + +// isErrorCode is the shape of the platform's codes, ^[a-z0-9_]{1,64}$: the +// error codes of the challenge and the reasons of a refused link alike. Only +// those are echoed to the terminal. +func isErrorCode(s string) bool { + return s != "" && len(s) <= 64 && allASCII(s, func(b byte) bool { + return isDigit(b) || (b >= 'a' && b <= 'z') || b == '_' + }) +} + +func allASCII(s string, ok func(byte) bool) bool { + for i := 0; i < len(s); i++ { + if !ok(s[i]) { + return false + } + } + return true +} + +func isDigit(b byte) bool { return b >= '0' && b <= '9' } + +// isBase32 is the RFC 4648 alphabet, in either case. +func isBase32(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '2' && b <= '7') +} + +// Complete answers the challenge with what the member typed: the code their +// authenticator app shows, or one of their recovery codes. It returns the +// session on success, and an *MFAError (or ErrNotACode) otherwise; Retryable +// says whether this same challenge can take another try. +func (e *MFARequiredError) Complete(input string) (*SignIn, error) { + field, value, err := normalizeAnswer(input) + if err != nil { + return nil, err + } + body, err := json.Marshal(map[string]string{field: value}) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, mfaChallengeURL(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + // JSON is what the platform requires here: a text/plain POST would skip a + // browser's preflight, so anything else is refused before the code is + // looked at. No Origin and no Sec-Fetch-Site - this is not a browser, and + // the platform lets a request through without them only because the + // challenge cookie can be held by nothing but the client that opened it. + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.AddCookie(&http.Cookie{Name: mfaPendingCookie, Value: e.pending}) + + // Never a redirect. Go forwards the Cookie header to a redirect on the + // same host, whatever the port, and a 307 or 308 replays the body with the + // code in it: following one would hand the challenge and its answer to + // wherever it points. The contract has no redirect here, so one is an + // answer like any other refusal. + client := &http.Client{ + Timeout: requestTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("could not reach nan.builders: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + + if resp.StatusCode == http.StatusOK { + var ok struct { + RecoveryCodesRemaining *int `json:"recovery_codes_remaining"` + } + _ = json.Unmarshal(raw, &ok) + for _, c := range resp.Cookies() { + if c.Name == sessionCookie && c.Value != "" { + in := &SignIn{Session: c.Value} + if ok.RecoveryCodesRemaining != nil { + in.UsedRecoveryCode = true + in.RecoveryCodesLeft = *ok.RecoveryCodesRemaining + } + return in, nil + } + } + return nil, errors.New("nan.builders accepted the code but sent no session back — " + newLinkHint) + } + + var refusal struct { + Error string `json:"error"` + AttemptsRemaining int `json:"attempts_remaining"` + LockedUntil string `json:"locked_until"` + RetryAfter int `json:"retry_after"` + } + _ = json.Unmarshal(raw, &refusal) + out := &MFAError{Status: resp.StatusCode, Attempts: refusal.AttemptsRemaining} + // Only something shaped like the platform's codes is taken as one: an + // unknown code is printed, and a body that is not ours - a proxy's error + // page, a 404 from a platform with two-factor sign-in turned off - should + // not put whatever it carries on the member's terminal. + if isErrorCode(refusal.Error) { + out.Code = refusal.Error + } + if t, err := time.Parse(time.RFC3339, refusal.LockedUntil); err == nil { + out.LockedTill = t + } + switch { + case refusal.RetryAfter > 0: + out.RetryAfter = time.Duration(refusal.RetryAfter) * time.Second + default: + if s, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && s > 0 { + out.RetryAfter = time.Duration(s) * time.Second + } + } + return nil, out +} diff --git a/internal/auth/mfa_test.go b/internal/auth/mfa_test.go new file mode 100644 index 0000000..14392cd --- /dev/null +++ b/internal/auth/mfa_test.go @@ -0,0 +1,519 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/nxssie/nan-cli/internal/auth/authtest" +) + +// fakePlatform starts the contract's fake and points the sign-in at it. +func fakePlatform(t *testing.T) *authtest.Platform { + t.Helper() + p := authtest.New(t) + old := SignInBaseURL + SignInBaseURL = p.URL + t.Cleanup(func() { SignInBaseURL = old }) + return p +} + +// challengeFor runs the link exchange against an account with a factor and +// returns the challenge it comes back with. +func challengeFor(t *testing.T, p *authtest.Platform) *MFARequiredError { + t.Helper() + p.Factor = true + _, err := ExchangeToken(authtest.LinkToken) + var ch *MFARequiredError + if !errors.As(err, &ch) { + t.Fatalf("ExchangeToken = %v, want a two-factor challenge", err) + } + return ch +} + +// An account without a factor signs in exactly as before: one POST, the +// session from its cookie. The POST now says this build can answer a +// challenge, and that is the only thing that changed. +func TestNoFactorSignsInAsBefore(t *testing.T) { + p := fakePlatform(t) + got, err := ExchangeToken(authtest.LinkToken) + if err != nil || got != authtest.Session { + t.Fatalf("ExchangeToken = %q, %v", got, err) + } + forms := p.Verifies() + if len(forms) != 1 { + t.Fatalf("%d verify POSTs", len(forms)) + } + f := forms[0] + if f.Get("token") != authtest.LinkToken || f.Get("client") != "cli" || f.Get("mfa") != "1" || f.Has("flow") { + t.Errorf("verify form = %v", f) + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("%d challenge POSTs for an account without a factor", n) + } +} + +// The new CLI announces it can answer, so a platform that follows the +// contract never sends it the "update nan-cli" refusal that older builds get. +func TestTheNewCLINeverMeetsTheUpdateRefusal(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + if ch == nil || p.Verifies()[0].Get("mfa") != "1" { + t.Fatal("no challenge") + } + // And should it meet it anyway, it says what to do in a sentence. + msg := linkReasonMessage("mfa_enabled_update_nan_cli") + for _, want := range []string{"two-factor", "update", "new link"} { + if !strings.Contains(msg, want) { + t.Errorf("%q does not mention %q", msg, want) + } + } +} + +func TestRightCodeSignsIn(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + + in, err := ch.Complete(" 246 810 ") + if err != nil { + t.Fatal(err) + } + if in.Session != authtest.Session || in.UsedRecoveryCode { + t.Errorf("SignIn = %+v", in) + } + + reqs := p.Challenges() + if len(reqs) != 1 { + t.Fatalf("%d challenge POSTs", len(reqs)) + } + r := reqs[0] + if r.Cookie != authtest.Pending { + t.Errorf("challenge cookie = %q", r.Cookie) + } + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q", ct) + } + // Not a browser, and must not pass for one: the platform lets an + // Origin-less request through only because the cookie proves who opened + // the challenge. + for _, h := range []string{"Origin", "Sec-Fetch-Site", "Referer"} { + if v := r.Header.Get(h); v != "" { + t.Errorf("sent %s: %q", h, v) + } + } + var body map[string]string + if err := json.Unmarshal([]byte(r.Body), &body); err != nil || len(body) != 1 || body["code"] != "246810" { + t.Errorf("body = %s", r.Body) + } +} + +func TestWrongCodeThenRight(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + + _, err := ch.Complete("111111") + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_code_invalid" { + t.Fatalf("err = %v", err) + } + if !Retryable(err) { + t.Error("a wrong code ends the sign-in") + } + if !strings.Contains(err.Error(), "4 attempts left") { + t.Errorf("%q does not say how many tries are left", err) + } + + in, err := ch.Complete(authtest.Code) + if err != nil || in.Session != authtest.Session { + t.Fatalf("second try: %+v %v", in, err) + } +} + +// A recovery code is read the way the platform reads it - any case, with or +// without hyphens and spaces - and sent as one, and the answer says how many +// are left. +func TestRecoveryCodeSignsInAndCountsWhatIsLeft(t *testing.T) { + for _, typed := range []string{"q7wa-z2xd-pl4r-6wze", "Q7WA Z2XD PL4R 6WZE", "q7waz2xdpl4r6wze"} { + t.Run(typed, func(t *testing.T) { + p := fakePlatform(t) + p.RecoveryCodes = []string{"aaaa-bbbb-cccc-dddd", "q7wa-z2xd-pl4r-6wze"} + ch := challengeFor(t, p) + + in, err := ch.Complete(typed) + if err != nil { + t.Fatal(err) + } + if !in.UsedRecoveryCode || in.RecoveryCodesLeft != 1 || in.Session != authtest.Session { + t.Errorf("SignIn = %+v", in) + } + var body map[string]string + _ = json.Unmarshal([]byte(p.Challenges()[0].Body), &body) + if len(body) != 1 || body["recovery_code"] != "q7wa-z2xd-pl4r-6wze" { + t.Errorf("body = %s", p.Challenges()[0].Body) + } + }) + } +} + +func TestUsedRecoveryCodeIsRefused(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + _, err := ch.Complete("zzzz-zzzz-zzzz-zzzz") + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_recovery_code_invalid" || !Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "already used") { + t.Errorf("%q", err) + } +} + +// The same 30-second code twice: the platform keeps one use per step, and the +// member is told to wait for the next. +func TestCodeAlreadyUsed(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + if _, err := ch.Complete(authtest.Code); err != nil { + t.Fatal(err) + } + p.NewLink() + ch = challengeFor(t, p) + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_code_already_used" || !Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "wait for the next one") { + t.Errorf("%q", err) + } +} + +// Five wrong codes end the sign-in, and the last answer says so. +func TestFifthWrongCodeEndsTheSignIn(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + var err error + for i := 0; i < 5; i++ { + _, err = ch.Complete("000000") + } + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_challenge_exhausted" || Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "new link") { + t.Errorf("%q", err) + } +} + +func TestExpiredChallenge(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + p.Expire() + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_challenge_expired" || Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "10 minutes") || !strings.Contains(err.Error(), "new link") { + t.Errorf("%q", err) + } +} + +// A lock is said with its end, by the platform's clock and the seconds it +// counts, and it ends this sign-in. +func TestLockedAccount(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + until := time.Now().Add(14*time.Minute + 30*time.Second) + p.Lock(until) + + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_locked" || Retryable(err) { + t.Fatalf("err = %v", err) + } + if refused.Status != http.StatusTooManyRequests || refused.RetryAfter < 14*time.Minute { + t.Errorf("refusal = %+v", refused) + } + msg := err.Error() + for _, want := range []string{"paused", until.Local().Format("15:04"), "15 minutes from now"} { + if !strings.Contains(msg, want) { + t.Errorf("%q does not say %q", msg, want) + } + } +} + +// Every refusal in the contract, as the platform sends it: whether the same +// sign-in can take another answer, and what the member is told. +func TestEveryRefusalInTheContract(t *testing.T) { + for _, c := range []struct { + name string + refusal authtest.Refusal + code string + retryable bool + want []string + }{ + {"wrong code", r(400, `{"error":"mfa_code_invalid","message":"x","attempts_remaining":1}`), "mfa_code_invalid", true, []string{"not right", "1 attempt left"}}, + {"wrong recovery code", r(400, `{"error":"mfa_recovery_code_invalid","message":"x","attempts_remaining":3}`), "mfa_recovery_code_invalid", true, []string{"recovery code", "3 attempts left"}}, + {"replayed code", r(400, `{"error":"mfa_code_already_used","message":"x","attempts_remaining":2}`), "mfa_code_already_used", true, []string{"next one", "2 attempts left"}}, + {"unreadable", r(400, `{"error":"invalid_request","message":"x"}`), "invalid_request", true, []string{"6-digit"}}, + {"exhausted", r(403, `{"error":"mfa_challenge_exhausted","message":"x"}`), "mfa_challenge_exhausted", false, []string{"too many wrong codes", "new link"}}, + {"expired", r(403, `{"error":"mfa_challenge_expired","message":"x"}`), "mfa_challenge_expired", false, []string{"timed out", "new link"}}, + {"used", r(403, `{"error":"mfa_challenge_used","message":"x"}`), "mfa_challenge_used", false, []string{"already over", "new link"}}, + {"missing", r(403, `{"error":"mfa_challenge_missing","message":"x"}`), "mfa_challenge_missing", false, []string{"no record", "new link"}}, + {"refused account", r(403, `{"error":"login_refused","message":"x"}`), "login_refused", false, []string{"refused the sign-in", "new link"}}, + {"locked, body only", r(429, `{"error":"mfa_locked","message":"x","retry_after":600}`), "mfa_locked", false, []string{"paused for 10 minutes"}}, + {"rate limited", rh(429, `{"error":"rate_limited","message":"x","retry_after":60}`, "Retry-After", "60"), "rate_limited", false, []string{"wait 1 minute", "new link"}}, + {"rate limited, header only", rh(429, `{"error":"rate_limited","message":"x"}`, "Retry-After", "45"), "rate_limited", false, []string{"wait 45 seconds"}}, + {"another origin", r(403, `{"error":"forbidden_origin"}`), "forbidden_origin", false, []string{"forbidden origin", "HTTP 403"}}, + {"server error", r(500, `{"error":"internal_error","message":"x"}`), "internal_error", false, []string{"could not check the code", "HTTP 500"}}, + {"two-factor turned off", r(404, "404 page not found\n"), "", false, []string{"HTTP 404", "new link"}}, + {"not our body", r(502, ""+authtest.Pending+"\x1b[31m"), "", false, []string{"HTTP 502"}}, + {"a code that is not one", r(403, `{"error":"Bad \u001b[2J`+authtest.Pending+`"}`), "", false, []string{"HTTP 403"}}, + {"a code from tomorrow", r(403, `{"error":"mfa_something_new","message":"x"}`), "mfa_something_new", false, []string{"mfa something new"}}, + } { + t.Run(c.name, func(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + p.Next = &c.refusal + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) { + t.Fatalf("err = %v (%T)", err, err) + } + if refused.Code != c.code { + t.Errorf("code = %q, want %q", refused.Code, c.code) + } + if Retryable(err) != c.retryable { + t.Errorf("Retryable = %v, want %v", !c.retryable, c.retryable) + } + for _, want := range c.want { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } + if strings.Contains(err.Error(), authtest.Pending) || strings.ContainsRune(err.Error(), '\x1b') { + t.Errorf("the message carries what the body did: %q", err) + } + }) + } +} + +func r(status int, body string) authtest.Refusal { + return authtest.Refusal{Status: status, Body: body, Header: map[string]string{"Content-Type": "application/json"}} +} + +func rh(status int, body, k, v string) authtest.Refusal { + out := r(status, body) + out.Header[k] = v + return out +} + +// Something that is neither a code nor a recovery code is refused here, and +// spends nothing: no request, no attempt. +func TestNotACodeIsNeverSent(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + for _, typed := range []string{"", "12345", "1234567", "abcdef", "q7wa-z2xd-pl4r", "q7wa-z2xd-pl4r-6wze-xxxx", "246810\x00", "246810", + // Sixteen characters, but not the alphabet recovery codes are made + // in: RFC 4648 base32 has no 0, 1, 8 or 9. + "q7wa-z2xd-pl4r-6wz0", "q7wa-z2xd-pl4r-6wz1", "Q7WA Z2XD PL4R 6WZ8", "q7waz2xdpl4r6wz9"} { + if _, err := ch.Complete(typed); !errors.Is(err, ErrNotACode) || !Retryable(err) { + t.Errorf("Complete(%q) = %v", typed, err) + } + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("%d requests for input that is not a code", n) + } +} + +func TestNormalizeAnswer(t *testing.T) { + for _, c := range []struct{ in, field, value string }{ + {"123456", "code", "123456"}, + {" 123 456 ", "code", "123456"}, + {"123-456", "code", "123456"}, + {"q7wa-z2xd-pl4r-6wze", "recovery_code", "q7wa-z2xd-pl4r-6wze"}, + {"Q7WAZ2XDPL4R6WZE", "recovery_code", "q7wa-z2xd-pl4r-6wze"}, + {"q7wa z2xd\tpl4r 6wze", "recovery_code", "q7wa-z2xd-pl4r-6wze"}, + } { + field, value, err := normalizeAnswer(c.in) + if err != nil || field != c.field || value != c.value { + t.Errorf("normalizeAnswer(%q) = %q %q %v", c.in, field, value, err) + } + } +} + +// A redirect from the challenge route is not followed. Go would forward the +// Cookie header to it - same host, other port - and a 307 or 308 would replay +// the body with the code, so the server it points at answers like a +// successful challenge: following it would also look like a sign-in. +func TestTheChallengeDoesNotFollowRedirects(t *testing.T) { + for _, status := range []int{http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(http.StatusText(status), func(t *testing.T) { + var reached []string + var mu sync.Mutex + elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + reached = append(reached, fmt.Sprintf("%s cookie=%q body=%q", r.Method, r.Header.Get("Cookie"), body)) + mu.Unlock() + http.SetCookie(w, &http.Cookie{Name: "nan_session", Value: "a-session-from-elsewhere"}) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"redirect":"/","recovery_codes_remaining":null}`) + })) + defer elsewhere.Close() + + p := fakePlatform(t) + ch := challengeFor(t, p) + p.Next = &authtest.Refusal{Status: status, Header: map[string]string{"Location": elsewhere.URL + "/api/auth/mfa/challenge"}} + + in, err := ch.Complete(authtest.Code) + if err == nil { + t.Errorf("signed in through a %d: %+v", status, in) + } else if Retryable(err) || !strings.Contains(err.Error(), fmt.Sprintf("HTTP %d", status)) { + t.Errorf("err = %v", err) + } + mu.Lock() + defer mu.Unlock() + if len(reached) != 0 { + t.Errorf("followed the %d: the server it pointed at got %v", status, reached) + } + }) + } +} + +// A redirect that asks for the code without the challenge to answer it with +// is a dead end, and says so instead of prompting for nothing. +func TestMFAReasonWithoutTheCookie(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, authtest.FrontendURL+"/mfa?reason=mfa_required", http.StatusFound) + })) + defer srv.Close() + old := SignInBaseURL + SignInBaseURL = srv.URL + defer func() { SignInBaseURL = old }() + + _, err := ExchangeToken(authtest.LinkToken) + var ch *MFARequiredError + if err == nil || errors.As(err, &ch) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "new link") { + t.Errorf("%q", err) + } +} + +// The challenge cookie is a credential for ten minutes. However the +// challenge is printed or logged, the value is not in it. +func TestTheChallengeNeverPrintsItsCookie(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + + var out []string + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%d", "%x"} { + out = append(out, fmt.Sprintf(verb, ch), fmt.Sprintf(verb, error(ch))) + } + out = append(out, ch.Error(), fmt.Errorf("wrapped: %w", ch).Error()) + if b, err := json.Marshal(ch); err == nil { + out = append(out, string(b)) + } + var logged strings.Builder + slog.New(slog.NewTextHandler(&logged, nil)).Info("sign-in", "err", ch, "challenge", ch) + slog.New(slog.NewJSONHandler(&logged, nil)).Info("sign-in", "err", ch, "challenge", ch) + out = append(out, logged.String()) + + for _, s := range out { + if strings.Contains(s, authtest.Pending) { + t.Errorf("the challenge cookie is in %q", s) + } + } +} + +// An account that has not finished signing up and has a factor is sent to the +// browser, with or without mfa=1 ("CLI contract" step 8): no challenge, no +// cookie, and a sentence that says what to do with a link that is spent. +func TestUnfinishedSignUpWithAFactorIsSentToTheBrowser(t *testing.T) { + p := fakePlatform(t) + p.Factor, p.Onboarding = true, true + got, err := ExchangeToken(authtest.LinkToken) + var ch *MFARequiredError + if err == nil || got != "" || errors.As(err, &ch) { + t.Fatalf("ExchangeToken = %q, %v", got, err) + } + if err.Error() != linkReasons["finish_onboarding_in_the_browser"] { + t.Errorf("message = %q", err) + } + for _, want := range []string{"browser", "new link", "cloud.nan.builders", "spent", "sign in here again"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("%d challenge POSTs with no challenge to answer", n) + } +} + +// Without a factor nothing changed: the onboarding token is not a session, +// and the CLI does not take it for one. +func TestUnfinishedSignUpWithoutAFactorGetsNoSession(t *testing.T) { + p := fakePlatform(t) + p.Onboarding = true + got, err := ExchangeToken(authtest.LinkToken) + if err == nil || got != "" || !strings.Contains(err.Error(), "no session came back") { + t.Fatalf("ExchangeToken = %q, %v", got, err) + } +} + +// The reason of a refused link is read out of a redirect's query, decoded, so +// it can be anything. Only the platform's shape of reason is put on the +// terminal; anything else gets a sentence of our own. +func TestOddReasonsNeverReachTheTerminal(t *testing.T) { + for _, c := range []struct { + name, reason string + echoed bool + }{ + {"one of ours, unknown to this build", "a_reason_from_tomorrow", true}, + {"the longest that is still one", strings.Repeat("a", 64), true}, + {"an escape sequence", "\x1b[2J\x1b]0;owned\x07", false}, + {"a new line", "invalid\nlink", false}, + {"capitals", "Invalid_Link", false}, + {"spaces", "please paste your token here", false}, + {"too long", strings.Repeat("a", 65), false}, + {"not ASCII", "enlace_inválido", false}, + } { + t.Run(c.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, authtest.FrontendURL+"/access-denied?reason="+url.QueryEscape(c.reason), http.StatusFound) + })) + defer srv.Close() + old := SignInBaseURL + SignInBaseURL = srv.URL + defer func() { SignInBaseURL = old }() + + _, err := ExchangeToken(authtest.LinkToken) + if err == nil { + t.Fatal("no error") + } + msg := err.Error() + shown := strings.ReplaceAll(c.reason, "_", " ") + if c.echoed != strings.Contains(msg, shown) { + t.Errorf("echoed = %v, want %v: %q", !c.echoed, c.echoed, msg) + } + if strings.ContainsAny(msg, "\x1b\x07\n") { + t.Errorf("control characters reached the message: %q", msg) + } + if !c.echoed && !strings.Contains(msg, "ask for a new link") { + t.Errorf("%q does not say what to do", msg) + } + }) + } +} diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index 0f56b83..581bbbe 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -1980,7 +1980,7 @@ func TestSigningInGivesTheClientTheNewToken(t *testing.T) { t.Fatal(err) } - updated, _ := m.Update(signedInMsg{nil}) + updated, _ := m.Update(signedInMsg{}) after := updated.(model) if after.sess.Token != "a-fresh-session-token" { @@ -2247,7 +2247,7 @@ func TestSigningInLeadsStraightToTheKey(t *testing.T) { t.Fatal(err) } - updated, _ := m.Update(signedInMsg{nil}) + updated, _ := m.Update(signedInMsg{}) after := updated.(model) if !after.editingKey { t.Error("after signing in the member is left to work out that `e` is next") diff --git a/internal/tui/mfa_test.go b/internal/tui/mfa_test.go new file mode 100644 index 0000000..1422161 --- /dev/null +++ b/internal/tui/mfa_test.go @@ -0,0 +1,233 @@ +package tui + +import ( + "bytes" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/nxssie/nan-cli/internal/auth" + "github.com/nxssie/nan-cli/internal/auth/authtest" + "github.com/nxssie/nan-cli/internal/session" +) + +// Two-factor sign-in from the panel: the same flow as `nan auth login`, +// through internal/auth, with the code asked for as a third question. + +const mfaTestLink = "https://cloud-api.nan.builders/api/auth/login/verify?token=" + authtest.LinkToken + +// atTheCode drives the panel up to the code question against a fake platform +// whose account has a factor: the address, the link, and the exchange that +// comes back with a challenge. +func atTheCode(t *testing.T) (model, *authtest.Platform) { + t.Helper() + p := authtest.New(t) + p.Factor = true + old := auth.SignInBaseURL + auth.SignInBaseURL = p.URL + t.Cleanup(func() { auth.SignInBaseURL = old }) + + m := setupModel(t, &session.Session{}) + m.lay = newLayout(100, 40) + m.startLogin() + m.loginEmail = "member@example.com" + mod, _ := m.Update(linkSentMsg{}) + m = mod.(model) + + msg := signIn(mfaTestLink)() + if _, ok := msg.(mfaChallengeMsg); !ok { + t.Fatalf("the link came back as %#v, not a challenge", msg) + } + mod, _ = m.Update(msg) + return mod.(model), p +} + +// enter types value into the field and presses enter, running what that +// starts and handing its answer back to the panel. +func enter(t *testing.T, m model, value string) model { + t.Helper() + m.loginInput.SetValue(value) + mod, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter started nothing") + } + mod, _ = mod.(model).Update(cmd()) + return mod.(model) +} + +func TestThePanelAsksForTheCode(t *testing.T) { + m, _ := atTheCode(t) + if m.loginStage != loginAskCode || m.wizard != wizardLink { + t.Fatalf("stage %v, wizard %v", m.loginStage, m.wizard) + } + if m.loginInput.EchoMode == 0 { + t.Error("the code field echoes: a recovery code works until it is used") + } + flat := strings.Join(strings.Fields(m.View()), " ") + for _, want := range []string{"Authenticator code", "two-factor", "recovery codes"} { + if !strings.Contains(flat, want) { + t.Errorf("the screen does not say %q:\n%s", want, m.View()) + } + } + + m = enter(t, m, authtest.Code) + if m.loginStage != loginOff || m.loginChallenge != nil { + t.Errorf("still signing in after the right code: stage %v", m.loginStage) + } + sess, err := session.Load() + if err != nil || sess.Token != authtest.Session { + t.Fatalf("session = %+v, %v", sess, err) + } + if m.client.Token() != authtest.Session { + t.Error("the panel's client does not carry the new session") + } + assertNoChallengeUnderHome(t) +} + +// A wrong code keeps the same question, empty, with what went wrong and how +// many tries are left. +func TestThePanelAsksAgainAfterAWrongCode(t *testing.T) { + m, p := atTheCode(t) + m = enter(t, m, "000000") + if m.loginStage != loginAskCode { + t.Fatalf("a wrong code left the question: stage %v", m.loginStage) + } + if m.loginInput.Value() != "" { + t.Error("the wrong code is still in the field") + } + if !strings.Contains(m.loginMsg, "4 attempts left") { + t.Errorf("loginMsg = %q", m.loginMsg) + } + m = enter(t, m, authtest.Code) + if m.loginStage != loginOff { + t.Errorf("the right code after a wrong one did not sign in: stage %v", m.loginStage) + } + if n := len(p.Challenges()); n != 2 { + t.Errorf("%d challenge POSTs", n) + } +} + +// A sign-in that cannot go on - timed out, too many wrong codes, refused - +// spent its link, so the panel goes back to the address, filled in: enter +// asks for a new one. +func TestThePanelStartsAgainWhenTheSignInEnds(t *testing.T) { + m, p := atTheCode(t) + p.Expire() + m = enter(t, m, authtest.Code) + if m.loginStage != loginAskEmail || m.wizard != wizardEmail { + t.Fatalf("stage %v, wizard %v", m.loginStage, m.wizard) + } + if m.loginChallenge != nil { + t.Error("the panel keeps a challenge that is over") + } + if m.loginInput.Value() != "member@example.com" || m.loginInput.Prompt != "Email: " { + t.Errorf("field = %q %q", m.loginInput.Prompt, m.loginInput.Value()) + } + if !strings.HasPrefix(m.loginMsg, "error: ") || !strings.Contains(m.loginMsg, "new link") { + t.Errorf("loginMsg = %q", m.loginMsg) + } + if s, err := session.Load(); err == nil && s.Token != "" { + t.Errorf("saved a session: %+v", s) + } +} + +func TestThePanelSaysHowManyRecoveryCodesAreLeft(t *testing.T) { + m, p := atTheCode(t) + p.RecoveryCodes = []string{"q7wa-z2xd-pl4r-6wze", "aaaa-bbbb-cccc-dddd"} + m = enter(t, m, "Q7WA-Z2XD-PL4R-6WZE") + if m.loginStage != loginOff || m.wizard != wizardKey { + t.Fatalf("stage %v, wizard %v", m.loginStage, m.wizard) + } + flat := strings.Join(strings.Fields(m.View()), " ") + if !strings.Contains(flat, "1 left") || !strings.Contains(flat, "Two-factor authentication") { + t.Errorf("the next step does not say how many are left:\n%s", m.View()) + } +} + +// Esc leaves the code question and forgets the challenge; an exchange that +// answers after the member left does not bring the question back. +func TestEscapeForgetsTheChallenge(t *testing.T) { + m, _ := atTheCode(t) + mod, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = mod.(model) + if m.loginStage != loginOff || m.loginChallenge != nil { + t.Errorf("stage %v, challenge kept: %v", m.loginStage, m.loginChallenge != nil) + } + + m2, _ := atTheCode(t) + challenge := m2.loginChallenge + m2.cancelLogin() + mod, _ = m2.Update(mfaChallengeMsg{challenge}) + if got := mod.(model); got.loginStage != loginOff || got.loginChallenge != nil { + t.Errorf("a late challenge reopened the sign-in: stage %v", got.loginStage) + } +} + +// The screen never shows the challenge cookie, and nothing under HOME holds +// it. +func TestThePanelKeepsTheChallengeToItself(t *testing.T) { + m, _ := atTheCode(t) + m = enter(t, m, "000000") + if strings.Contains(m.View(), authtest.Pending) || strings.Contains(m.loginMsg, authtest.Pending) { + t.Error("the challenge cookie is on screen") + } + assertNoChallengeUnderHome(t) +} + +func assertNoChallengeUnderHome(t *testing.T) { + t.Helper() + err := filepath.WalkDir(os.Getenv("HOME"), func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if bytes.Contains(data, []byte(authtest.Pending)) { + t.Errorf("%s holds the challenge cookie", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// An account that has not finished signing up and has a factor: the panel +// stays on the link question with the sentence that sends it to the browser, +// and asks for no code. +func TestThePanelSendsAnUnfinishedSignUpToTheBrowser(t *testing.T) { + p := authtest.New(t) + p.Factor, p.Onboarding = true, true + old := auth.SignInBaseURL + auth.SignInBaseURL = p.URL + t.Cleanup(func() { auth.SignInBaseURL = old }) + + m := setupModel(t, &session.Session{}) + m.lay = newLayout(100, 40) + m.startLogin() + mod, _ := m.Update(linkSentMsg{}) + m = mod.(model) + m = enter(t, m, mfaTestLink) + + if m.loginStage != loginAskLink || m.loginChallenge != nil { + t.Fatalf("stage %v, challenge %v", m.loginStage, m.loginChallenge != nil) + } + const want = "error: your NaN account is not set up yet — finish it in the browser: ask for a new link at " + + "https://cloud.nan.builders (this one is spent), complete the setup there, then sign in here again" + if m.loginMsg != want { + t.Errorf("loginMsg = %q", m.loginMsg) + } + flat := strings.Join(strings.Fields(m.View()), " ") + if !strings.Contains(flat, "finish it in the browser") { + t.Errorf("the screen does not say it:\n%s", m.View()) + } + if s, err := session.Load(); err == nil && s.Token != "" { + t.Errorf("saved a session: %+v", s) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 581fae1..561325d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -153,7 +153,17 @@ type configuredMsg struct { type firstRunMsg struct{} type linkSentMsg struct{ err error } -type signedInMsg struct{ err error } + +// signedInMsg ends the sign-in: err when it failed, and notice for something +// the member should read once it worked (a recovery code spent). +type signedInMsg struct { + err error + notice string +} + +// mfaChallengeMsg is a link that worked for an account with two-factor +// authentication: the session comes once the challenge is answered. +type mfaChallengeMsg struct{ challenge *auth.MFARequiredError } // signedOutMsg is the platform's answer to ending the session (err nil when it // confirmed). @@ -205,6 +215,11 @@ type model struct { loginEmail string loginMsg string loginBusy bool + // The two-factor challenge the link opened, while the code is asked + // for. In memory only, like everything else about it. + loginChallenge *auth.MFARequiredError + // Said on the steps after the sign-in, once: a recovery code was spent. + loginNotice string } // Where a member is in the guided setup. The inner inputs still drive @@ -262,13 +277,15 @@ func (w wizardStep) mood() mascotMood { } // Where a member is in the sign-in flow. It is two questions - an address and -// the link that arrives at it. +// the link that arrives at it - and a third for an account with two-factor +// authentication: the code from the authenticator app. type loginStage int const ( loginOff loginStage = iota loginAskEmail loginAskLink + loginAskCode ) func newModel(client *api.Client, sess *session.Session) model { @@ -425,10 +442,44 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loginInput.EchoCharacter = '•' return m, m.loginInput.Focus() + case mfaChallengeMsg: + m.loginBusy = false + // Left with esc while the link was being exchanged: nothing to ask. + if m.loginStage != loginAskLink { + return m, nil + } + m.loginChallenge = msg.challenge + m.loginStage = loginAskCode + m.loginMsg = "this account uses two-factor authentication — enter the 6-digit code from your" + + " authenticator app, or one of your recovery codes" + m.loginInput.SetValue("") + m.loginInput.Placeholder = "123456" + m.loginInput.Prompt = "Authenticator code: " + // A recovery code works until it is used, so it is not echoed either. + m.loginInput.EchoMode = textinput.EchoPassword + m.loginInput.EchoCharacter = '•' + return m, m.loginInput.Focus() + case signedInMsg: m.loginBusy = false if msg.err != nil { m.loginMsg = "error: " + msg.err.Error() + if m.loginStage == loginAskCode { + if auth.Retryable(msg.err) { + m.loginInput.SetValue("") + } else { + // The challenge is over, and the link that opened it was + // spent opening it: back to the address, filled in, so + // enter asks for a new one. + m.loginChallenge = nil + m.wizard = wizardEmail + m.loginStage = loginAskEmail + m.loginInput.SetValue(m.loginEmail) + m.loginInput.Placeholder = "you@example.com" + m.loginInput.Prompt = "Email: " + m.loginInput.EchoMode = textinput.EchoNormal + } + } return m, m.loginInput.Focus() } // Reload the session so the token is the one just written, and drop @@ -444,6 +495,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.client = api.New(m.sess.Token) wasGuided := m.wizard != wizardOff m.cancelLogin() + m.loginNotice = msg.notice if wasGuided { m.wizard = wizardKey } @@ -507,6 +559,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loginMsg = "sending a link to " + value + "…" return m, sendLink(value) } + if m.loginStage == loginAskCode { + m.loginMsg = "checking the code…" + return m, answerChallenge(m.loginChallenge, value) + } m.loginMsg = "signing in…" return m, signIn(value) case "esc": @@ -771,6 +827,8 @@ func (m *model) startLogin() tea.Cmd { m.wizard = wizardEmail m.loginStage = loginAskEmail m.loginMsg = "" + m.loginNotice = "" + m.loginChallenge = nil // A machine set up with a platform token looks signed in to whoever set // it up. The dashboard cannot use one, and asking for an email with no // word about it reads as the token having been lost. @@ -832,6 +890,7 @@ func (m *model) resumeSetup() tea.Cmd { func (m *model) cancelLogin() { m.wizard = wizardOff m.loginStage = loginOff + m.loginChallenge = nil m.loginBusy = false m.loginInput.Blur() m.loginInput.SetValue("") @@ -846,24 +905,57 @@ func signIn(pasted string) tea.Cmd { return func() tea.Msg { token, err := auth.TokenFromLink(strings.TrimSpace(pasted)) if err != nil { - return signedInMsg{err} + return signedInMsg{err: err} } sessionToken, err := auth.ExchangeToken(token) + var challenge *auth.MFARequiredError + if errors.As(err, &challenge) { + return mfaChallengeMsg{challenge} + } if err != nil { - return signedInMsg{err} + return signedInMsg{err: err} } - current, err := session.Load() + return signedInMsg{err: saveSessionToken(sessionToken)} + } +} + +// answerChallenge posts the code the member typed against the challenge the +// link opened. +func answerChallenge(challenge *auth.MFARequiredError, code string) tea.Cmd { + return func() tea.Msg { + signIn, err := challenge.Complete(code) if err != nil { - current = &session.Session{} + return signedInMsg{err: err} } - current.Token = sessionToken - if err := session.Save(current); err != nil { - return signedInMsg{err} + if err := saveSessionToken(signIn.Session); err != nil { + return signedInMsg{err: err} } - return signedInMsg{nil} + if !signIn.UsedRecoveryCode { + return signedInMsg{} + } + return signedInMsg{notice: recoveryCodesNotice(signIn.RecoveryCodesLeft)} } } +// recoveryCodesNotice is what the panel says after a recovery code got the +// member in: each works once, and running out is the way to be locked out. +func recoveryCodesNotice(left int) string { + const where = "cloud.nan.builders, Settings > Account > Two-factor authentication" + if left == 0 { + return "that was your last recovery code — make new ones before you need one: " + where + } + return fmt.Sprintf("signed in with a recovery code, %d left — you can make new ones at %s", left, where) +} + +func saveSessionToken(token string) error { + current, err := session.Load() + if err != nil { + current = &session.Session{} + } + current.Token = token + return session.Save(current) +} + func (m model) fetchKeyStatus() tea.Cmd { client := m.client return func() tea.Msg { @@ -3380,6 +3472,9 @@ func (m model) renderWizard(l layout) string { b.WriteString("\n" + m.wrapped(l, m.loginMsg) + "\n") } case wizardKey: + if m.loginNotice != "" { + b.WriteString(m.wrapped(l, m.loginNotice) + "\n\n") + } if m.editingKey { b.WriteString(l.indent + m.keyInput.View() + "\n") } @@ -3661,7 +3756,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.34" +const Version = "0.1.35" func renderAbout(l layout) string { var b strings.Builder