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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 6 additions & 92 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,15 @@ package cmd

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"

"github.com/nxssie/nan-cli/internal/auth"
"github.com/nxssie/nan-cli/internal/session"
"github.com/spf13/cobra"
)

const (
loginRequestURL = "https://cloud-api.nan.builders/api/auth/login/request"
loginVerifyURL = "https://cloud-api.nan.builders/api/auth/login/verify"
sessionCookie = "nan_session"
)

var (
tokenFlag string
emailFlag string
Expand Down Expand Up @@ -65,11 +56,11 @@ func runLogin(cmd *cobra.Command, args []string) error {
// answer a prompt: a script, a CI step, or a terminal that runs one command
// at a time.
if linkFlag != "" {
token, err := tokenFromLink(strings.TrimSpace(linkFlag))
token, err := auth.TokenFromLink(strings.TrimSpace(linkFlag))
if err != nil {
return err
}
sessionToken, err := exchangeToken(token)
sessionToken, err := auth.ExchangeToken(token)
if err != nil {
return err
}
Expand Down Expand Up @@ -103,7 +94,7 @@ func runLogin(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not an email address: %q — %s", email, emailHint)
}

if err := requestSignInLink(email); err != nil {
if err := auth.RequestSignInLink(email); err != nil {
return err
}

Expand All @@ -128,95 +119,18 @@ func runLogin(cmd *cobra.Command, args []string) error {
return nil
}

token, err := tokenFromLink(strings.TrimSpace(in.Text()))
token, err := auth.TokenFromLink(strings.TrimSpace(in.Text()))
if err != nil {
return err
}

sessionToken, err := exchangeToken(token)
sessionToken, err := auth.ExchangeToken(token)
if err != nil {
return err
}
return saveToken(sessionToken)
}

func requestSignInLink(email string) error {
body, err := json.Marshal(map[string]string{"email": email})
if err != nil {
return err
}
resp, err := http.Post(loginRequestURL, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("could not reach nan.builders: %w", err)
}
defer resp.Body.Close()

switch {
case resp.StatusCode == http.StatusTooManyRequests:
return fmt.Errorf("too many sign-in attempts, wait a few minutes")
case resp.StatusCode >= 400:
return fmt.Errorf("could not send the sign-in link (HTTP %d)", resp.StatusCode)
}
// 202 comes back whether or not the address belongs to a member, so a
// successful call here is not proof that an email is on the way.
return nil
}

// Accepts the whole link, or just the token if the mail client mangled it.
func tokenFromLink(pasted string) (string, error) {
if pasted == "" {
return "", fmt.Errorf("nothing pasted")
}
if strings.Contains(pasted, "://") {
u, err := url.Parse(pasted)
if err != nil {
return "", fmt.Errorf("that does not parse as a link: %w", err)
}
token := u.Query().Get("token")
if token == "" {
return "", fmt.Errorf("that link carries no token: %s", pasted)
}
return token, nil
}
if strings.ContainsAny(pasted, " \t") {
return "", fmt.Errorf("that is neither a link nor a token")
}
return pasted, nil
}

// The browser flow ends on a page that POSTs the token and gets the session
// 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.
func exchangeToken(token string) (string, error) {
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
form := url.Values{"token": {token}}
resp, err := client.PostForm(loginVerifyURL, form)
if err != nil {
return "", fmt.Errorf("could not reach nan.builders: %w", err)
}
defer resp.Body.Close()

for _, c := range resp.Cookies() {
if c.Name == sessionCookie && c.Value != "" {
return c.Value, nil
}
}

// 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.
if location, err := resp.Location(); err == nil {
if reason := location.Query().Get("reason"); reason != "" {
return "", fmt.Errorf("the link did not work: %s", strings.ReplaceAll(reason, "_", " "))
}
}
return "", fmt.Errorf("no session came back (HTTP %d)", resp.StatusCode)
}

func runLogout(cmd *cobra.Command, args []string) error {
if err := session.Delete(); err != nil {
return err
Expand Down
101 changes: 101 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Package auth is the sign-in flow, shared by the `nan auth login` command and
// by the panel.
//
// It lived inside cmd/ until the panel learned to sign a member in on its own.
// Leaving it there would have meant the TUI importing a cobra command package
// to make three HTTP calls, or a second copy of the flow that drifts from the
// first - and the whole point of doing it in the panel is that it is the same
// flow, reached without having to quit and come back.
package auth

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)

const (
loginRequestURL = "https://cloud-api.nan.builders/api/auth/login/request"
loginVerifyURL = "https://cloud-api.nan.builders/api/auth/login/verify"
sessionCookie = "nan_session"
)

func RequestSignInLink(email string) error {
body, err := json.Marshal(map[string]string{"email": email})
if err != nil {
return err
}
resp, err := http.Post(loginRequestURL, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("could not reach nan.builders: %w", err)
}
defer resp.Body.Close()

switch {
case resp.StatusCode == http.StatusTooManyRequests:
return fmt.Errorf("too many sign-in attempts, wait a few minutes")
case resp.StatusCode >= 400:
return fmt.Errorf("could not send the sign-in link (HTTP %d)", resp.StatusCode)
}
// 202 comes back whether or not the address belongs to a member, so a
// successful call here is not proof that an email is on the way.
return nil
}

// Accepts the whole link, or just the token if the mail client mangled it.
func TokenFromLink(pasted string) (string, error) {
if pasted == "" {
return "", fmt.Errorf("nothing pasted")
}
if strings.Contains(pasted, "://") {
u, err := url.Parse(pasted)
if err != nil {
return "", fmt.Errorf("that does not parse as a link: %w", err)
}
token := u.Query().Get("token")
if token == "" {
return "", fmt.Errorf("that link carries no token: %s", pasted)
}
return token, nil
}
if strings.ContainsAny(pasted, " \t") {
return "", fmt.Errorf("that is neither a link nor a token")
}
return pasted, nil
}

// The browser flow ends on a page that POSTs the token and gets the session
// 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.
func ExchangeToken(token string) (string, error) {
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
form := url.Values{"token": {token}}
resp, err := client.PostForm(loginVerifyURL, form)
if err != nil {
return "", fmt.Errorf("could not reach nan.builders: %w", err)
}
defer resp.Body.Close()

for _, c := range resp.Cookies() {
if c.Name == sessionCookie && c.Value != "" {
return c.Value, nil
}
}

// 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.
if location, err := resp.Location(); err == nil {
if reason := location.Query().Get("reason"); reason != "" {
return "", fmt.Errorf("the link did not work: %s", strings.ReplaceAll(reason, "_", " "))
}
}
return "", fmt.Errorf("no session came back (HTTP %d)", resp.StatusCode)
}
12 changes: 6 additions & 6 deletions internal/tui/banner.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,16 +193,16 @@ func renderNextStep(l layout, loggedIn, hasKey bool) string {
var b strings.Builder
b.WriteString(l.indent + section.Render("Start here") + "\n\n")

// Every one of these is a key to press right here. The list used to open
// with "q, quit, so you have your shell back", because signing in meant
// leaving the panel for a subcommand and two prompts on stdin - which is
// where people got stuck, and the reason the panel signs you in itself now.
steps := []struct {
what, why string
done bool
}{
// Quitting comes first because it is not a key inside the panel: the
// next step is a command in the shell, and you cannot run one from here.
{"q", "quit, so you have your shell back", loggedIn},
{"nan auth login", "sign in - a link goes to your email", loggedIn},
{"nan", "come back, and press right for Setup", loggedIn},
{"e", "paste your API key in Setup", hasKey},
{"s", "sign in - a link goes to your email", loggedIn},
{"e", "paste your API key, in Setup", hasKey},
{"space, then c", "pick your tools and apply", false},
}

Expand Down
86 changes: 78 additions & 8 deletions internal/tui/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1021,10 +1021,14 @@ func TestDataTabsSayToLogInRatherThanUnauthorized(t *testing.T) {
}
}

// And the message is the one that names the command to run.
// And the message names the key, not a shell command: there is one for
// this, and quitting to run something else is the detour it replaced.
msg, ok := m.fetchTab(tabProfile)().(fetchErrMsg)
if !ok || !errors.Is(msg.err, session.ErrNotLoggedIn) {
t.Errorf("Profile reports %v, want the error that says `nan auth login`", msg.err)
if !ok || !errors.Is(msg.err, errNotSignedIn) {
t.Errorf("Profile reports %v, want the one that says which key to press", msg.err)
}
if !strings.Contains(errNotSignedIn.Error(), "press s") {
t.Errorf("the message is %q, which does not say what to press", errNotSignedIn)
}
}

Expand Down Expand Up @@ -1059,15 +1063,19 @@ func TestModelsStillLoadsWithAKeyAndNoSession(t *testing.T) {
func TestHomeLeadsWithSigningInWhenThereIsNoSession(t *testing.T) {
out := renderHome(newLayout(90, 40), false, false)

for _, want := range []string{"Start here", "nan auth login", "stay empty until you sign in"} {
for _, want := range []string{"Start here", "sign in", "stay empty until you sign in"} {
if !strings.Contains(out, want) {
t.Errorf("Home does not mention %q to someone with no session", want)
}
}
// Quitting is step one and not an afterthought: the next step is a command
// in the shell, which cannot be run with this panel open.
if !strings.Contains(out, "quit, so you have your shell back") {
t.Error("Home tells a member to run a shell command without telling them to leave first")
// Every step is a key to press here. This list used to open with "q, quit,
// so you have your shell back", because signing in meant leaving for a
// subcommand - which is exactly where people got stuck.
if strings.Contains(out, "quit, so you have your shell back") {
t.Error("Home still sends a member out to the shell to sign in")
}
if strings.Contains(out, "nan auth login") {
t.Error("Home names a shell command for something the panel does itself")
}
}

Expand Down Expand Up @@ -1100,3 +1108,65 @@ func TestHomeDropsTheGuideWhenSetupIsDone(t *testing.T) {
t.Error("the rest of Home went with it")
}
}

// ── signing in without leaving the panel ─────────────────────────────────────

// The flow used to be: read Home, quit, run `nan auth login`, answer two
// prompts on stdin, start the panel again. Reported twice from a real machine,
// stuck at different steps of it. The panel owns the keyboard already, so it
// asks the same two questions itself.
func TestSigningInHappensInsideThePanel(t *testing.T) {
m := setupModel(t, &session.Session{})

if m.loginStage != loginOff {
t.Fatal("the panel opens mid-login")
}
m.startLogin()
if m.loginStage != loginAskEmail {
t.Fatal("s does not start the sign-in")
}

out := m.renderLogin(newLayout(90, 24))
for _, want := range []string{"Sign in", "Step 1 of 2", "Email"} {
if !strings.Contains(out, want) {
t.Errorf("the first step does not show %q", want)
}
}

// Second question, once the link is on its way.
m.loginStage = loginAskLink
m.loginInput.Prompt = "Paste the link: "
out = m.renderLogin(newLayout(90, 24))
if !strings.Contains(out, "Step 2 of 2") || !strings.Contains(out, "Paste the link") {
t.Errorf("the second step does not ask for the link:\n%s", out)
}
}

func TestEscapeLeavesTheSignInAlone(t *testing.T) {
m := setupModel(t, &session.Session{})
m.startLogin()
m.loginInput.SetValue("half typed@")
m.cancelLogin()

if m.loginStage != loginOff {
t.Error("esc does not leave the sign-in")
}
if m.loginInput.Value() != "" {
t.Error("a cancelled sign-in keeps what was typed into it")
}
}

// A member who is already signed in has nothing to start, and the key that
// starts it is `s` because `l` is the vim spelling of "next tab".
func TestSignInKeyIsNotOneThatAlreadyMoves(t *testing.T) {
out := renderHelp()
if !strings.Contains(out, "sign in") {
t.Error("the help screen does not mention signing in")
}
if strings.Contains(out, "l") && strings.Contains(out, "sign in, when there is no session") {
// only a smoke check that the description is the one bound to s
if !strings.Contains(out, "s") {
t.Error("sign-in is not bound to s")
}
}
}
Loading
Loading