diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index fea17cede07e3..e7f4aca6710c9 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -317,3 +317,52 @@ func (c *ApiClient) GetUser() (*UserResponse, error) { } return userResponse, nil } + +type verificationResponse struct { + Token string `json:"token"` + Email string `json:"email"` + TeamID string `json:"teamId,omitempty"` +} + +// VerifiedSSOUser contains data returned from the SSO token verification endpoint +type VerifiedSSOUser struct { + Token string + TeamID string +} + +func (c *ApiClient) VerifySSOToken(token string, tokenName string) (*VerifiedSSOUser, error) { + query := make(url.Values) + query.Add("token", token) + query.Add("tokenName", tokenName) + req, err := retryablehttp.NewRequest(http.MethodGet, c.makeUrl("/registration/verify")+"?"+query.Encode(), nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", c.UserAgent()) + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + io.Copy(ioutil.Discard, resp.Body) + return nil, fmt.Errorf("404 - Not found") // doesn't exist - not an error + } else if resp.StatusCode != http.StatusOK { + b, _ := ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("%s", string(b)) + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("could not read JSON response: %s", string(body)) + } + verificationResponse := &verificationResponse{} + err = json.Unmarshal(body, verificationResponse) + if err != nil { + return nil, fmt.Errorf("failed to unmarshall json response: %v", err) + } + vu := &VerifiedSSOUser{ + Token: verificationResponse.Token, + TeamID: verificationResponse.TeamID, + } + return vu, nil +} diff --git a/cli/internal/config/config_file.go b/cli/internal/config/config_file.go index f3d9fd00b041b..1fafeba56c05a 100644 --- a/cli/internal/config/config_file.go +++ b/cli/internal/config/config_file.go @@ -22,8 +22,8 @@ type TurborepoConfig struct { TeamSlug string `json:"teamSlug,omitempty" envconfig:"team"` } -// WriteUserConfigFile writes config file at a path -func WriteConfigFile(path string, config *TurborepoConfig) error { +// writeConfigFile writes config file at a path +func writeConfigFile(path string, config *TurborepoConfig) error { jsonBytes, marhsallError := json.Marshal(config) if marhsallError != nil { return marhsallError @@ -35,13 +35,21 @@ func WriteConfigFile(path string, config *TurborepoConfig) error { return nil } -// WriteUserConfigFile writes a user config file +// WriteRepoConfigFile is used to write the portion of the config file that is saved +// within the repository itself. +func WriteRepoConfigFile(config *TurborepoConfig) error { + path := filepath.Join(".turbo", "config.json") + return writeConfigFile(path, config) +} + +// WriteUserConfigFile writes a user config file. This may contain a token and so should +// not be saved within the repository to avoid committing sensitive data func WriteUserConfigFile(config *TurborepoConfig) error { path, err := xdg.ConfigFile(filepath.Join("turborepo", "config.json")) if err != nil { return err } - return WriteConfigFile(path, config) + return writeConfigFile(path, config) } // ReadConfigFile reads a config file at a path diff --git a/cli/internal/login/link.go b/cli/internal/login/link.go index 366c23dd26d2b..97aeed62e06ad 100644 --- a/cli/internal/login/link.go +++ b/cli/internal/login/link.go @@ -142,7 +142,7 @@ func (c *LinkCommand) Run(args []string) int { } } fs.EnsureDir(filepath.Join(".turbo", "config.json")) - fsErr := config.WriteConfigFile(filepath.Join(".turbo", "config.json"), &config.TurborepoConfig{ + fsErr := config.WriteRepoConfigFile(&config.TurborepoConfig{ TeamId: chosenTeam.ID, ApiUrl: c.Config.ApiUrl, }) diff --git a/cli/internal/login/login.go b/cli/internal/login/login.go index 102555a5763c6..0c6db8ed5fc5f 100644 --- a/cli/internal/login/login.go +++ b/cli/internal/login/login.go @@ -33,33 +33,46 @@ func (c *LoginCommand) Synopsis() string { return "Login to your Vercel account" } -// Help returns information about the `run` command +// Help returns information about the `run` command. Match the cobra output for now, until +// we can wire up cobra for real func (c *LoginCommand) Help() string { helpText := ` -Usage: turbo login +Login to your Vercel account - Login to your Vercel account +Usage: + turbo login [flags] + +Flags: + --sso-team string attempt to authenticate to the specified team using SSO ` return strings.TrimSpace(helpText) } const defaultHostname = "127.0.0.1" const defaultPort = 9789 +const defaultSSOProvider = "SAML/OIDC Single Sign-On" // Run logs into the api with PKCE and writes the token to turbo user config directory func (c *LoginCommand) Run(args []string) int { + var ssoTeam string loginCommand := &cobra.Command{ Use: "turbo login", Short: "Login to your Vercel account", RunE: func(cmd *cobra.Command, args []string) error { - return run(c.Config, loginDeps{ - ui: c.UI, - openURL: browser.OpenBrowser, - client: c.Config.ApiClient, - writeConfig: config.WriteUserConfigFile, - }) + deps := loginDeps{ + ui: c.UI, + openURL: browser.OpenBrowser, + client: c.Config.ApiClient, + writeUserConfig: config.WriteUserConfigFile, + writeRepoConfig: config.WriteRepoConfigFile, + } + if ssoTeam != "" { + return loginSSO(c.Config, ssoTeam, deps) + } + return run(c.Config, deps) }, } + loginCommand.Flags().StringVar(&ssoTeam, "sso-team", "", "attempt to authenticate to the specified team using SSO") loginCommand.SetArgs(args) err := loginCommand.Execute() if err != nil { @@ -74,18 +87,19 @@ type browserClient = func(url string) error type userClient interface { SetToken(token string) GetUser() (*client.UserResponse, error) + VerifySSOToken(token string, tokenName string) (*client.VerifiedSSOUser, error) } type configWriter = func(cf *config.TurborepoConfig) error type loginDeps struct { - ui *cli.ColoredUi - openURL browserClient - client userClient - writeConfig configWriter + ui *cli.ColoredUi + openURL browserClient + client userClient + writeUserConfig configWriter + writeRepoConfig configWriter } func run(c *config.Config, deps loginDeps) error { - var rawToken string c.Logger.Debug(fmt.Sprintf("turbo v%v", c.TurboVersion)) c.Logger.Debug(fmt.Sprintf("api url: %v", c.ApiUrl)) c.Logger.Debug(fmt.Sprintf("login url: %v", c.LoginUrl)) @@ -95,33 +109,15 @@ func run(c *config.Config, deps loginDeps) error { rootctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() - // Start listening immediately to handle race with user interaction - // This is mostly for testing, but would otherwise still technically be - // a race condition. - addr := defaultHostname + ":" + fmt.Sprint(defaultPort) - l, err := net.Listen("tcp", addr) - if err != nil { - return err - } - - redirectDone := make(chan struct{}) - mux := http.NewServeMux() + var query url.Values - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + oss, err := newOneShotServer(rootctx, func(w http.ResponseWriter, r *http.Request) { query = r.URL.Query() http.Redirect(w, r, c.LoginUrl+"/turborepo/success", http.StatusFound) - close(redirectDone) - }) - - srv := &http.Server{Handler: mux} - var serverErr error - serverDone := make(chan struct{}) - go func() { - if err := srv.Serve(l); err != nil { - serverErr = errors.Wrap(err, "could not activate device. Please try again") - } - close(serverDone) - }() + }, defaultPort) + if err != nil { + return errors.Wrap(err, "failed to start local server") + } s := ui.NewSpinner(os.Stdout) err = deps.openURL(loginURL) @@ -129,20 +125,15 @@ func run(c *config.Config, deps loginDeps) error { return errors.Wrapf(err, "failed to open %v", loginURL) } s.Start("Waiting for your authorization...") - - <-redirectDone - err = srv.Shutdown(rootctx) - // Stop the spinner before we return to ensure terminal is left in a good state - s.Stop("") + err = oss.Wait() if err != nil { - return err - } - <-serverDone - if !errors.Is(serverErr, http.ErrServerClosed) { - return serverErr + return errors.Wrap(err, "failed to shut down local server") } - deps.writeConfig(&config.TurborepoConfig{Token: query.Get("token")}) - rawToken = query.Get("token") + // Stop the spinner before we return to ensure terminal is left in a good state + s.Stop("") + + deps.writeUserConfig(&config.TurborepoConfig{Token: query.Get("token")}) + rawToken := query.Get("token") deps.client.SetToken(rawToken) userResponse, err := deps.client.GetUser() if err != nil { @@ -158,3 +149,182 @@ func run(c *config.Config, deps loginDeps) error { deps.ui.Info("") return nil } + +func loginSSO(c *config.Config, ssoTeam string, deps loginDeps) error { + redirectURL := fmt.Sprintf("http://%v:%v", defaultHostname, defaultPort) + query := make(url.Values) + query.Add("teamId", ssoTeam) + query.Add("mode", "login") + query.Add("next", redirectURL) + loginURL := fmt.Sprintf("%v/api/auth/sso?%v", c.LoginUrl, query.Encode()) + + rootctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + var verificationToken string + oss, err := newOneShotServer(rootctx, func(w http.ResponseWriter, r *http.Request) { + token, location := getTokenAndRedirect(r.URL.Query()) + verificationToken = token + http.Redirect(w, r, location, http.StatusFound) + }, defaultPort) + if err != nil { + return errors.Wrap(err, "failed to start local server") + } + s := ui.NewSpinner(os.Stdout) + err = deps.openURL(loginURL) + if err != nil { + return errors.Wrapf(err, "failed to open %v", loginURL) + } + s.Start("Waiting for your authorization...") + err = oss.Wait() + if err != nil { + return errors.Wrap(err, "failed to shut down local server") + } + // Stop the spinner before we return to ensure terminal is left in a good state + s.Stop("") + // open https://vercel.com/api/auth/sso?teamId=&mode=login + if verificationToken == "" { + return errors.New("no token auth token found") + } + + // We now have a verification token. We need to pass it to the verification endpoint + // to get an actual token. + tokenName, err := makeTokenName() + if err != nil { + return errors.Wrap(err, "failed to make sso token name") + } + verifiedUser, err := deps.client.VerifySSOToken(verificationToken, tokenName) + if err != nil { + return errors.Wrap(err, "failed to verify SSO token") + } + + deps.client.SetToken(verifiedUser.Token) + userResponse, err := deps.client.GetUser() + if err != nil { + return errors.Wrap(err, "could not get user information") + } + err = deps.writeUserConfig(&config.TurborepoConfig{Token: verifiedUser.Token}) + if err != nil { + return errors.Wrap(err, "failed to save auth token") + } + deps.ui.Info("") + deps.ui.Info(util.Sprintf("%s Turborepo CLI authorized for %s${RESET}", ui.Rainbow(">>> Success!"), userResponse.User.Email)) + deps.ui.Info("") + if verifiedUser.TeamID != "" { + err = deps.writeRepoConfig(&config.TurborepoConfig{TeamId: verifiedUser.TeamID, ApiUrl: c.ApiUrl}) + if err != nil { + return errors.Wrap(err, "failed to save teamId") + } + } else { + + deps.ui.Info(util.Sprintf("${CYAN}To connect to your Remote Cache. Run the following in the${RESET}")) + deps.ui.Info(util.Sprintf("${CYAN}root of any turborepo:${RESET}")) + deps.ui.Info("") + deps.ui.Info(util.Sprintf(" ${BOLD}npx turbo link${RESET}")) + } + deps.ui.Info("") + return nil +} + +func getTokenAndRedirect(params url.Values) (string, string) { + locationStub := "https://vercel.com/notifications/cli-login-" + if loginError := params.Get("loginError"); loginError != "" { + outParams := make(url.Values) + outParams.Add("loginError", loginError) + return "", locationStub + "failed?" + outParams.Encode() + } + if ssoEmail := params.Get("ssoEmail"); ssoEmail != "" { + outParams := make(url.Values) + outParams.Add("ssoEmail", ssoEmail) + if teamName := params.Get("teamName"); teamName != "" { + outParams.Add("teamName", teamName) + } + if ssoType := params.Get("ssoType"); ssoType != "" { + outParams.Add("ssoType", ssoType) + } + return "", locationStub + "incomplete?" + outParams.Encode() + } + token := params.Get("token") + location := locationStub + "success" + if email := params.Get("email"); email != "" { + outParams := make(url.Values) + outParams.Add("email", email) + location += "?" + outParams.Encode() + } + return token, location +} + +type oneShotServer struct { + Port uint16 + requestDone chan struct{} + serverDone chan struct{} + serverErr error + ctx context.Context + srv *http.Server +} + +func newOneShotServer(ctx context.Context, handler http.HandlerFunc, port uint16) (*oneShotServer, error) { + requestDone := make(chan struct{}) + serverDone := make(chan struct{}) + mux := http.NewServeMux() + srv := &http.Server{Handler: mux} + oss := &oneShotServer{ + Port: port, + requestDone: requestDone, + serverDone: serverDone, + ctx: ctx, + srv: srv, + } + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + handler(w, r) + close(oss.requestDone) + }) + err := oss.start(handler) + if err != nil { + return nil, err + } + return oss, nil +} + +func (oss *oneShotServer) start(handler http.HandlerFunc) error { + // Start listening immediately to handle race with user interaction + // This is mostly for testing, but would otherwise still technically be + // a race condition. + addr := defaultHostname + ":" + fmt.Sprint(oss.Port) + l, err := net.Listen("tcp", addr) + if err != nil { + return err + } + go func() { + if err := oss.srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) { + oss.serverErr = errors.Wrap(err, "could not activate device. Please try again") + } + close(oss.serverDone) + }() + return nil +} + +func (oss *oneShotServer) Wait() error { + select { + case <-oss.requestDone: + case <-oss.ctx.Done(): + } + return oss.closeServer() +} + +func (oss *oneShotServer) closeServer() error { + err := oss.srv.Shutdown(oss.ctx) + if err != nil { + return err + } + <-oss.serverDone + return oss.serverErr +} + +func makeTokenName() (string, error) { + host, err := os.Hostname() + if err != nil { + return "", err + } + return fmt.Sprintf("Turbo CLI on %v via %v", host, defaultSSOProvider), nil +} diff --git a/cli/internal/login/login_test.go b/cli/internal/login/login_test.go index 6d617816c7f98..6e434d04e974f 100644 --- a/cli/internal/login/login_test.go +++ b/cli/internal/login/login_test.go @@ -3,6 +3,8 @@ package login import ( "fmt" "net/http" + "net/url" + "os" "testing" "github.com/hashicorp/go-hclog" @@ -12,7 +14,8 @@ import ( ) type dummyClient struct { - setToken string + setToken string + createdSSOTokenName string } func (d *dummyClient) SetToken(t string) { @@ -23,71 +26,134 @@ func (d *dummyClient) GetUser() (*client.UserResponse, error) { return &client.UserResponse{}, nil } -func Test_run(t *testing.T) { - logger := hclog.Default() - cf := &config.Config{ - Logger: logger, - TurboVersion: "test", - ApiUrl: "api-url", - LoginUrl: "login-url", - } +func (d *dummyClient) VerifySSOToken(token string, tokenName string) (*client.VerifiedSSOUser, error) { + d.createdSSOTokenName = tokenName + return &client.VerifiedSSOUser{ + Token: "actual-sso-token", + TeamID: "sso-team-id", + }, nil +} + +var logger = hclog.Default() +var cf = &config.Config{ + Logger: logger, + TurboVersion: "test", + ApiUrl: "api-url", + LoginUrl: "login-url", +} + +type testResult struct { + clientErr error + userConfigWritten *config.TurborepoConfig + repoConfigWritten *config.TurborepoConfig + clientTokenWritten string + openedURL string + stepCh chan struct{} + client dummyClient +} - ch := make(chan struct{}, 1) - openedURL := "" +func (tr *testResult) Deps() loginDeps { urlOpener := func(url string) error { - openedURL = url - ch <- struct{}{} + tr.openedURL = url + tr.stepCh <- struct{}{} return nil } + return loginDeps{ + ui: ui.Default(), + openURL: urlOpener, + client: &tr.client, + writeUserConfig: func(cf *config.TurborepoConfig) error { + tr.userConfigWritten = cf + return nil + }, + writeRepoConfig: func(cf *config.TurborepoConfig) error { + tr.repoConfigWritten = cf + return nil + }, + } +} - // When we get the ping, send a token - var clientErr error +func newTest(redirectedURL string) *testResult { + stepCh := make(chan struct{}, 1) + tr := &testResult{ + stepCh: stepCh, + } + // When it's time, do the redirect go func() { - <-ch + <-tr.stepCh client := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } - resp, err := client.Get("http://127.0.0.1:9789/?token=my-token") + resp, err := client.Get(redirectedURL) if err != nil { - clientErr = err + tr.clientErr = err } else if resp != nil && resp.StatusCode != http.StatusFound { - clientErr = fmt.Errorf("invalid status %v", resp.StatusCode) + tr.clientErr = fmt.Errorf("invalid status %v", resp.StatusCode) } - ch <- struct{}{} + tr.stepCh <- struct{}{} }() + return tr +} - var writtenConfig *config.TurborepoConfig - writeConfig := func(cf *config.TurborepoConfig) error { - writtenConfig = cf - return nil - } - - client := &dummyClient{} - err := run(cf, loginDeps{ - openURL: urlOpener, - ui: ui.Default(), - writeConfig: writeConfig, - client: client, - }) +func Test_run(t *testing.T) { + test := newTest("http://127.0.0.1:9789/?token=my-token") + err := run(cf, test.Deps()) if err != nil { t.Errorf("expected to succeed, got error %v", err) } - <-ch - if clientErr != nil { - t.Errorf("test client had error %v", clientErr) + <-test.stepCh + if test.clientErr != nil { + t.Errorf("test client had error %v", test.clientErr) } expectedURL := "login-url/turborepo/token?redirect_uri=http://127.0.0.1:9789" - if openedURL != expectedURL { - t.Errorf("openedURL got %v, want %v", openedURL, expectedURL) + if test.openedURL != expectedURL { + t.Errorf("openedURL got %v, want %v", test.openedURL, expectedURL) } - if writtenConfig.Token != "my-token" { - t.Errorf("config token got %v, want my-token", writtenConfig.Token) + if test.userConfigWritten.Token != "my-token" { + t.Errorf("config token got %v, want my-token", test.userConfigWritten.Token) + } + if test.client.setToken != "my-token" { + t.Errorf("user client token got %v, want my-token", test.client.setToken) + } +} + +func Test_sso(t *testing.T) { + redirectParams := make(url.Values) + redirectParams.Add("token", "verification-token") + redirectParams.Add("email", "test@example.com") + test := newTest("http://127.0.0.1:9789/?" + redirectParams.Encode()) + err := loginSSO(cf, "my-team", test.Deps()) + if err != nil { + t.Errorf("expected to succeed, got error %v", err) + } + <-test.stepCh + if test.clientErr != nil { + t.Errorf("test client had error %v", test.clientErr) + } + host, err := os.Hostname() + if err != nil { + t.Errorf("failed to get hostname %v", err) + } + expectedTokenName := fmt.Sprintf("Turbo CLI on %v via SAML/OIDC Single Sign-On", host) + if test.client.createdSSOTokenName != expectedTokenName { + t.Errorf("created sso token got %v want %v", test.client.createdSSOTokenName, expectedTokenName) + } + expectedToken := "actual-sso-token" + if test.client.setToken != expectedToken { + t.Errorf("user client token got %v, want %v", test.client.setToken, expectedToken) + } + if test.userConfigWritten.Token != expectedToken { + t.Errorf("user config token got %v want %v", test.userConfigWritten.Token, expectedToken) + } + expectedTeamID := "sso-team-id" + if test.repoConfigWritten.TeamId != expectedTeamID { + t.Errorf("repo config team id got %v want %v", test.repoConfigWritten.TeamId, expectedTeamID) } - if client.setToken != "my-token" { - t.Errorf("user client token got %v, want my-token", client.setToken) + if test.repoConfigWritten.Token != "" { + t.Errorf("repo config file token, got %v want empty string", test.repoConfigWritten.Token) } } diff --git a/cli/internal/login/unlink.go b/cli/internal/login/unlink.go index e9adff8818cf5..f376821707fac 100644 --- a/cli/internal/login/unlink.go +++ b/cli/internal/login/unlink.go @@ -2,8 +2,8 @@ package login import ( "fmt" - "path/filepath" "strings" + "github.com/vercel/turborepo/cli/internal/config" "github.com/vercel/turborepo/cli/internal/ui" "github.com/vercel/turborepo/cli/internal/util" @@ -36,7 +36,7 @@ Usage: turbo unlink // Run executes tasks in the monorepo func (c *UnlinkCommand) Run(args []string) int { - if err := config.WriteConfigFile(filepath.Join(".turbo", "config.json"), &config.TurborepoConfig{}); err != nil { + if err := config.WriteRepoConfigFile(&config.TurborepoConfig{}); err != nil { c.logError(c.Config.Logger, "", fmt.Errorf("could not unlink. Something went wrong: %w", err)) return 1 }