Skip to content

Commit

Permalink
feat(cli): prompt when relogging as authed user (#11004, #9329)
Browse files Browse the repository at this point in the history
Prompt the user if they want to re-authenticate if they run
`coder login` when already authenticated.

Does not apply if they provided a token via flag or ENV
variable. A new token should be generated an stored in that situation
unless the `--use-token-as-session` flag was also used.

fix(cli): prompt user on login attempt with invalid ENV token

Fixes issue where if invalid token was set as ENV
variable a user would be unable to login until the ENV
variable was cleared.

User will now be informed that the token is invalid and
prompted to login normally.
  • Loading branch information
elasticspoon committed Apr 7, 2024
1 parent f96ce80 commit f45143b
Show file tree
Hide file tree
Showing 4 changed files with 252 additions and 23 deletions.
84 changes: 62 additions & 22 deletions cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,59 @@ func (r *RootCmd) login() *serpent.Command {
return nil
}

var userResp codersdk.User

// Check for session token from flags or environment.
sessionToken, _ := inv.ParsedFlags().GetString(varToken)
if sessionToken != "" && !useTokenForSession {
// If a session token is provided on the cli, use it to generate
// a new one. This is because the cli `--token` flag provides
// a token for the command being invoked. We should not store
// this token, and `/logout` should not delete it.
// /login should generate a new token and store that.
client.SetSessionToken(sessionToken)
// Use CreateAPIKey over CreateToken because this is a session
// key that should not show on the `tokens` page. This should
// match the same behavior of the `/cli-auth` page for generating
// a session token.
key, err := client.CreateAPIKey(ctx, "me")
if err != nil {
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("Failed to authenticate with provided token '%s'. Login normally?", sessionToken),
IsConfirm: true,
Default: cliui.ConfirmYes,
})
if err != nil {
return xerrors.Errorf("create api key: %w", err)
}
sessionToken = ""
} else {
sessionToken = key.Key
}
}

// Check for existing session token on disk, and validate user data.
// If the token exists but is invalid, then it is probably expired.
// Skip this check if the user has provided a valid token; a new token
// should be generated.
if configToken, _ := r.createConfig().Session().Read(); sessionToken == "" && configToken != "" {
client.SetSessionToken(configToken)
userResp, err = client.User(ctx, codersdk.Me)
if err == nil {
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("You are already authenticated %s. Are you sure you want to log in again?",
pretty.Sprint(cliui.DefaultStyles.Keyword, userResp.Username)),
IsConfirm: true,
Default: cliui.ConfirmYes,
})
if err != nil {
return nil
}
}
sessionToken = ""
}

// If we still don't have a session token, prompt the user for one.
if sessionToken == "" {
authURL := *serverURL
// Don't use filepath.Join, we don't want to use the os separator
Expand All @@ -300,29 +352,16 @@ func (r *RootCmd) login() *serpent.Command {
if err != nil {
return xerrors.Errorf("paste token prompt: %w", err)
}
} else if !useTokenForSession {
// If a session token is provided on the cli, use it to generate
// a new one. This is because the cli `--token` flag provides
// a token for the command being invoked. We should not store
// this token, and `/logout` should not delete it.
// /login should generate a new token and store that.
client.SetSessionToken(sessionToken)
// Use CreateAPIKey over CreateToken because this is a session
// key that should not show on the `tokens` page. This should
// match the same behavior of the `/cli-auth` page for generating
// a session token.
key, err := client.CreateAPIKey(ctx, "me")
if err != nil {
return xerrors.Errorf("create api key: %w", err)
}
sessionToken = key.Key
}

// If we didn't login via session token on disk
// Login to get user data - verify it is OK before persisting
client.SetSessionToken(sessionToken)
resp, err := client.User(ctx, codersdk.Me)
if err != nil {
return xerrors.Errorf("get user: %w", err)
if userResp.Email == "" {
client.SetSessionToken(sessionToken)
userResp, err = client.User(ctx, codersdk.Me)
if err != nil {
return xerrors.Errorf("get user: %w", err)
}
}

config := r.createConfig()
Expand All @@ -335,7 +374,8 @@ func (r *RootCmd) login() *serpent.Command {
return xerrors.Errorf("write server url: %w", err)
}

_, _ = fmt.Fprintf(inv.Stdout, Caret+"Welcome to Coder, %s! You're authenticated.\n", pretty.Sprint(cliui.DefaultStyles.Keyword, resp.Username))
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Welcome to Coder, %s! You're authenticated.\n",
pretty.Sprint(cliui.DefaultStyles.Keyword, userResp.Username))
return nil
},
}
Expand Down Expand Up @@ -366,7 +406,7 @@ func (r *RootCmd) login() *serpent.Command {
},
{
Flag: "use-token-as-session",
Description: "By default, the CLI will generate a new session token when logging in. This flag will instead use the provided token as the session token.",
Description: "By default, the CLI will generate a new session token when logging in. This flag will instead use the provided token as the session token. See `coder --help` for more information on how to set a token.",
Value: serpent.BoolOf(&useTokenForSession),
},
}
Expand Down
188 changes: 188 additions & 0 deletions cli/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ func TestLogin(t *testing.T) {

inv, root := clitest.New(t, "login", "--no-open")
clitest.SetupConfig(t, client, root)
err := root.Session().Delete()
assert.NoError(t, err)

doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
Expand Down Expand Up @@ -291,6 +293,192 @@ func TestLogin(t *testing.T) {
<-doneChan
})

t.Run("AuthenticatedUserInvalidEnvToken", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)

inv, root := clitest.New(t, "login", "--no-open")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)

invalidToken := "an-invalid-token"
inv.Environ.Set("CODER_SESSION_TOKEN", invalidToken)

doneChan := make(chan struct{})
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()

pty.ExpectMatch(fmt.Sprintf("Failed to authenticate with provided token '%s'. Login normally?", invalidToken))
pty.WriteLine("yes")
pty.ExpectMatch("Are you sure you want to log in again?")
pty.WriteLine("yes")
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
pty.ExpectMatch("Welcome to Coder")
<-doneChan
})

t.Run("ExistingUserExpiredSessionToken", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)

inv, root := clitest.New(t, "login", "--no-open", client.URL.String())
pty := ptytest.New(t).Attach(inv)

err := root.Session().Write("an-expired-token")
assert.NoError(t, err)

doneChan := make(chan struct{})
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()

pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
pty.ExpectMatch("Welcome to Coder")
<-doneChan
})

t.Run("ExistingUserInvalidEnvToken", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)

inv, _ := clitest.New(t, "login", "--no-open", client.URL.String())
pty := ptytest.New(t).Attach(inv)

invalidToken := "an-invalid-token"
inv.Environ.Set("CODER_SESSION_TOKEN", invalidToken)

doneChan := make(chan struct{})
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()

pty.ExpectMatch(fmt.Sprintf("Failed to authenticate with provided token '%s'. Login normally?", invalidToken))
pty.WriteLine("yes")
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
pty.ExpectMatch("Welcome to Coder")
<-doneChan
})

t.Run("ExistingUserRelogin", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)

inv, root := clitest.New(t, "login", "--no-open")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)

doneChan := make(chan struct{})
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()

pty.ExpectMatch("You are already authenticated")
pty.ExpectMatch("Are you sure you want to log in again?")
pty.WriteLine("yes")
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
pty.ExpectMatch("Welcome to Coder")
<-doneChan
})

t.Run("ExistingUserReloginRejection", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)

inv, root := clitest.New(t, "login", "--no-open")
clitest.SetupConfig(t, client, root)

doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()

pty.ExpectMatch("You are already authenticated")
pty.ExpectMatch("Are you sure you want to log in again?")
pty.WriteLine("no")
<-doneChan
})

t.Run("AuthenticatedUserTokenFlagValid", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)

inv, root := clitest.New(t, "login", client.URL.String(), "--no-open", "--token", client.SessionToken())
clitest.SetupConfig(t, client, root)

err := inv.Run()
assert.NoError(t, err)

sessionFile, err := root.Session().Read()
require.NoError(t, err)
// This **should not be equal** to the token we passed in.
require.NotEqual(t, client.SessionToken(), sessionFile)
})

t.Run("AuthenticatedUserTokenFlagValidUseTokenAsSession", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)

inv, root := clitest.New(t, "login", client.URL.String(), "--no-open", "--token", client.SessionToken(), "--use-token-as-session")
clitest.SetupConfig(t, client, root)

err := inv.Run()
assert.NoError(t, err)

sessionFile, err := root.Session().Read()
require.NoError(t, err)
require.Equal(t, client.SessionToken(), sessionFile)
})

t.Run("AuthenticatedUserTokenFlagInvalid", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)

invalidToken := "an-invalid-token"
inv, root := clitest.New(t, "login", client.URL.String(), "--no-open", "--token", invalidToken)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)

doneChan := make(chan struct{})
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()

pty.ExpectMatch(fmt.Sprintf("Failed to authenticate with provided token '%s'. Login normally?", invalidToken))
pty.WriteLine("yes")
pty.ExpectMatch("Are you sure you want to log in again?")
pty.WriteLine("yes")
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
pty.ExpectMatch("Welcome to Coder")
<-doneChan
})

// TokenFlag should generate a new session token and store it in the session file.
t.Run("TokenFlag", func(t *testing.T) {
t.Parallel()
Expand Down
1 change: 1 addition & 0 deletions cli/testdata/coder_login_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ OPTIONS:
--use-token-as-session bool
By default, the CLI will generate a new session token when logging in.
This flag will instead use the provided token as the session token.
See `coder --help` for more information on how to set a token.

———
Run `coder --help` for a list of global options.
2 changes: 1 addition & 1 deletion docs/cli/login.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit f45143b

Please sign in to comment.