Compare commits

...

5 Commits

Author SHA1 Message Date
elasticspoon 218bc230d1
Merge 341feddf1b into 15157c1c40 2024-04-26 17:43:38 -04:00
elasticspoon 341feddf1b fixup!: remove check for ENV token validity in login
I made an incorrect assumption, we can check if
a flag/env token is valid on login and create a new
token if the provided one is invalid.

However, on subsequent requests the invalid ENV token
will still take priority over the valid token stored
on disk. Therefore, this behavior does not work.
2024-04-26 17:23:14 -04:00
Colin Adler 15157c1c40
chore: add network integration test suite scaffolding (#13072)
* chore: add network integration test suite scaffolding

* dean comments
2024-04-26 17:48:41 +00:00
elasticspoon f0616ea5e5 fixup!: handle cliui.Canceled error
cliui should return nil when returning cliui.Canceled (CTRL-C) and
return error others

fix nits
2024-04-24 20:23:18 -04:00
elasticspoon f45143bfc7 feat(cli): prompt when relogging as authed user (#11004, #9329)
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.
2024-04-06 20:14:50 -04:00
6 changed files with 485 additions and 23 deletions

View File

@ -274,7 +274,52 @@ func (r *RootCmd) login() *serpent.Command {
return nil
}
// 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, codersdk.Me)
if err != nil {
return xerrors.Errorf("create api key: %w", err)
}
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.
var userResp codersdk.User
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 errors.Is(err, cliui.Canceled) {
return nil
}
if err != nil {
return err
}
}
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
@ -300,29 +345,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()
@ -335,7 +367,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
},
}
@ -366,7 +399,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),
},
}

View File

@ -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)
@ -291,6 +293,110 @@ func TestLogin(t *testing.T) {
<-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("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)
})
// TokenFlag should generate a new session token and store it in the session file.
t.Run("TokenFlag", func(t *testing.T) {
t.Parallel()

View File

@ -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
docs/cli/login.md generated
View File

@ -54,4 +54,4 @@ Specifies whether a trial license should be provisioned for the Coder deployment
| ---- | ----------------- |
| Type | <code>bool</code> |
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.
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.

View File

@ -0,0 +1,128 @@
package integration
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"tailscale.com/tailcfg"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/testutil"
)
func NetworkSetupDefault(*testing.T) {}
func DERPMapTailscale(ctx context.Context, t *testing.T) *tailcfg.DERPMap {
ctx, cancel := context.WithTimeout(ctx, testutil.WaitShort)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://controlplane.tailscale.com/derpmap/default", nil)
require.NoError(t, err)
res, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
dm := &tailcfg.DERPMap{}
dec := json.NewDecoder(res.Body)
err = dec.Decode(dm)
require.NoError(t, err)
return dm
}
func CoordinatorInMemory(t *testing.T, logger slog.Logger, dm *tailcfg.DERPMap) (coord tailnet.Coordinator, url string) {
coord = tailnet.NewCoordinator(logger)
var coordPtr atomic.Pointer[tailnet.Coordinator]
coordPtr.Store(&coord)
t.Cleanup(func() { _ = coord.Close() })
csvc, err := tailnet.NewClientService(logger, &coordPtr, 10*time.Minute, func() *tailcfg.DERPMap {
return dm
})
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/")
id, err := uuid.Parse(idStr)
if err != nil {
httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{
Message: "Bad agent id.",
Detail: err.Error(),
})
return
}
conn, err := websocket.Accept(w, r, nil)
if err != nil {
httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{
Message: "Failed to accept websocket.",
Detail: err.Error(),
})
return
}
ctx, wsNetConn := codersdk.WebsocketNetConn(r.Context(), conn, websocket.MessageBinary)
defer wsNetConn.Close()
err = csvc.ServeConnV2(ctx, wsNetConn, tailnet.StreamID{
Name: "client-" + id.String(),
ID: id,
Auth: tailnet.SingleTailnetCoordinateeAuth{},
})
if err != nil && !xerrors.Is(err, io.EOF) && !xerrors.Is(err, context.Canceled) {
_ = conn.Close(websocket.StatusInternalError, err.Error())
return
}
}))
t.Cleanup(srv.Close)
return coord, srv.URL
}
func TailnetSetupDRPC(ctx context.Context, t *testing.T, logger slog.Logger,
id, agentID uuid.UUID,
coordinateURL string,
dm *tailcfg.DERPMap,
) *tailnet.Conn {
ip := tailnet.IPFromUUID(id)
conn, err := tailnet.NewConn(&tailnet.Options{
Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)},
DERPMap: dm,
Logger: logger,
})
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
//nolint:bodyclose
ws, _, err := websocket.Dial(ctx, coordinateURL+"/"+id.String(), nil)
require.NoError(t, err)
client, err := tailnet.NewDRPCClient(
websocket.NetConn(ctx, ws, websocket.MessageBinary),
logger,
)
require.NoError(t, err)
coord, err := client.Coordinate(ctx)
require.NoError(t, err)
coordination := tailnet.NewRemoteCoordination(logger, coord, conn, agentID)
t.Cleanup(func() { _ = coordination.Close() })
return conn
}

View File

@ -0,0 +1,194 @@
package integration
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
"strconv"
"syscall"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/testutil"
)
var (
isChild = flag.Bool("child", false, "Run tests as a child")
childTestID = flag.Int("child-test-id", 0, "Which test is being run")
childCoordinateURL = flag.String("child-coordinate-url", "", "The coordinate url to connect back to")
childAgentID = flag.String("child-agent-id", "", "The agent id of the child")
)
func TestMain(m *testing.M) {
if run := os.Getenv("CODER_TAILNET_TESTS"); run == "" {
_, _ = fmt.Println("skipping tests...")
return
}
if os.Getuid() != 0 {
_, _ = fmt.Println("networking integration tests must run as root")
return
}
flag.Parse()
os.Exit(m.Run())
}
var tests = []Test{{
Name: "Normal",
DERPMap: DERPMapTailscale,
Coordinator: CoordinatorInMemory,
Parent: Parent{
NetworkSetup: NetworkSetupDefault,
TailnetSetup: TailnetSetupDRPC,
Run: func(ctx context.Context, t *testing.T, opts ParentOpts) {
reach := opts.Conn.AwaitReachable(ctx, tailnet.IPFromUUID(opts.AgentID))
assert.True(t, reach)
},
},
Child: Child{
NetworkSetup: NetworkSetupDefault,
TailnetSetup: TailnetSetupDRPC,
Run: func(ctx context.Context, t *testing.T, opts ChildOpts) {
// wait until the parent kills us
<-make(chan struct{})
},
},
}}
//nolint:paralleltest
func TestIntegration(t *testing.T) {
if *isChild {
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
t.Cleanup(cancel)
agentID, err := uuid.Parse(*childAgentID)
require.NoError(t, err)
test := tests[*childTestID]
test.Child.NetworkSetup(t)
dm := test.DERPMap(ctx, t)
conn := test.Child.TailnetSetup(ctx, t, logger, agentID, uuid.Nil, *childCoordinateURL, dm)
test.Child.Run(ctx, t, ChildOpts{
Logger: logger,
Conn: conn,
AgentID: agentID,
})
return
}
for id, test := range tests {
t.Run(test.Name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
t.Cleanup(cancel)
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
parentID, childID := uuid.New(), uuid.New()
dm := test.DERPMap(ctx, t)
_, coordURL := test.Coordinator(t, logger, dm)
child, waitChild := execChild(ctx, id, coordURL, childID)
test.Parent.NetworkSetup(t)
conn := test.Parent.TailnetSetup(ctx, t, logger, parentID, childID, coordURL, dm)
test.Parent.Run(ctx, t, ParentOpts{
Logger: logger,
Conn: conn,
ClientID: parentID,
AgentID: childID,
})
child.Process.Signal(syscall.SIGINT)
<-waitChild
})
}
}
type Test struct {
// Name is the name of the test.
Name string
// DERPMap returns the DERP map to use for both the parent and child. It is
// called once at the beginning of the test.
DERPMap func(ctx context.Context, t *testing.T) *tailcfg.DERPMap
// Coordinator returns a running tailnet coordinator, and the url to reach
// it on.
Coordinator func(t *testing.T, logger slog.Logger, dm *tailcfg.DERPMap) (coord tailnet.Coordinator, url string)
Parent Parent
Child Child
}
// Parent is the struct containing all of the parent specific configurations.
// Functions are invoked in order of struct definition.
type Parent struct {
// NetworkSetup is run before all test code. It can be used to setup
// networking scenarios.
NetworkSetup func(t *testing.T)
// TailnetSetup creates a tailnet network.
TailnetSetup func(
ctx context.Context, t *testing.T, logger slog.Logger,
id, agentID uuid.UUID, coordURL string, dm *tailcfg.DERPMap,
) *tailnet.Conn
Run func(ctx context.Context, t *testing.T, opts ParentOpts)
}
// Child is the struct containing all of the child specific configurations.
// Functions are invoked in order of struct definition.
type Child struct {
// NetworkSetup is run before all test code. It can be used to setup
// networking scenarios.
NetworkSetup func(t *testing.T)
// TailnetSetup creates a tailnet network.
TailnetSetup func(
ctx context.Context, t *testing.T, logger slog.Logger,
id, agentID uuid.UUID, coordURL string, dm *tailcfg.DERPMap,
) *tailnet.Conn
// Run runs the actual test. Parents and children run in separate processes,
// so it's important to ensure no communication happens over memory between
// run functions of parents and children.
Run func(ctx context.Context, t *testing.T, opts ChildOpts)
}
type ParentOpts struct {
Logger slog.Logger
Conn *tailnet.Conn
ClientID uuid.UUID
AgentID uuid.UUID
}
type ChildOpts struct {
Logger slog.Logger
Conn *tailnet.Conn
AgentID uuid.UUID
}
func execChild(ctx context.Context, testID int, coordURL string, agentID uuid.UUID) (*exec.Cmd, <-chan error) {
ch := make(chan error)
binary := os.Args[0]
args := os.Args[1:]
args = append(args,
"--child=true",
"--child-test-id="+strconv.Itoa(testID),
"--child-coordinate-url="+coordURL,
"--child-agent-id="+agentID.String(),
)
cmd := exec.CommandContext(ctx, binary, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
go func() {
ch <- cmd.Run()
}()
return cmd, ch
}