feat: add external-auth cli (#10052)

* feat: add `external-auth` cli

* Add subcommands

* Improve descriptions

* Add external-auth subcommand

* Fix docs

* Fix gen

* Fix comment

* Fix golden file
This commit is contained in:
Kyle Carberry
2023-10-09 23:04:35 +00:00
committed by GitHub
parent 20438ae6c2
commit 35538e1051
22 changed files with 613 additions and 105 deletions
+91
View File
@@ -0,0 +1,91 @@
package cli
import (
"os/signal"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk/agentsdk"
)
func (r *RootCmd) externalAuth() *clibase.Cmd {
return &clibase.Cmd{
Use: "external-auth",
Short: "Manage external authentication",
Long: "Authenticate with external services inside of a workspace.",
Handler: func(i *clibase.Invocation) error {
return i.Command.HelpHandler(i)
},
Children: []*clibase.Cmd{
r.externalAuthAccessToken(),
},
}
}
func (r *RootCmd) externalAuthAccessToken() *clibase.Cmd {
var silent bool
return &clibase.Cmd{
Use: "access-token <provider>",
Short: "Print auth for an external provider",
Long: "Print an access-token for an external auth provider. " +
"The access-token will be validated and sent to stdout with exit code 0. " +
"If a valid access-token cannot be obtained, the URL to authenticate will be sent to stdout with exit code 1\n" + formatExamples(
example{
Description: "Ensure that the user is authenticated with GitHub before cloning.",
Command: `#!/usr/bin/env sh
OUTPUT=$(coder external-auth access-token github)
if [ $? -eq 0 ]; then
echo "Authenticated with GitHub"
else
echo "Please authenticate with GitHub:"
echo $OUTPUT
fi
`,
},
),
Options: clibase.OptionSet{{
Name: "Silent",
Flag: "s",
Description: "Do not print the URL or access token.",
Value: clibase.BoolOf(&silent),
}},
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
ctx, stop := signal.NotifyContext(ctx, InterruptSignals...)
defer stop()
client, err := r.createAgentClient()
if err != nil {
return xerrors.Errorf("create agent client: %w", err)
}
token, err := client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
ID: inv.Args[0],
})
if err != nil {
return xerrors.Errorf("get external auth token: %w", err)
}
if !silent {
if token.URL != "" {
_, err = inv.Stdout.Write([]byte(token.URL))
} else {
_, err = inv.Stdout.Write([]byte(token.AccessToken))
}
if err != nil {
return err
}
}
if token.URL != "" {
return cliui.Canceled
}
return nil
},
}
}
+49
View File
@@ -0,0 +1,49 @@
package cli_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/pty/ptytest"
)
func TestExternalAuth(t *testing.T) {
t.Parallel()
t.Run("CanceledWithURL", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
URL: "https://github.com",
})
}))
t.Cleanup(srv.Close)
url := srv.URL
inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github")
pty := ptytest.New(t)
inv.Stdout = pty.Output()
waiter := clitest.StartWithWaiter(t, inv)
pty.ExpectMatch("https://github.com")
waiter.RequireIs(cliui.Canceled)
})
t.Run("SuccessWithToken", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
AccessToken: "bananas",
})
}))
t.Cleanup(srv.Close)
url := srv.URL
inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github")
pty := ptytest.New(t)
inv.Stdout = pty.Output()
clitest.Start(t, inv)
pty.ExpectMatch("bananas")
})
}
+8 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/gitauth"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/retry"
)
@@ -38,7 +39,9 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd {
return xerrors.Errorf("create agent client: %w", err)
}
token, err := client.GitAuth(ctx, host, false)
token, err := client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
Match: host,
})
if err != nil {
var apiError *codersdk.Error
if errors.As(err, &apiError) && apiError.StatusCode() == http.StatusNotFound {
@@ -63,7 +66,10 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd {
}
for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); {
token, err = client.GitAuth(ctx, host, true)
token, err = client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
Match: host,
Listen: true,
})
if err != nil {
continue
}
+4 -4
View File
@@ -23,7 +23,7 @@ func TestGitAskpass(t *testing.T) {
t.Run("UsernameAndPassword", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.GitAuthResponse{
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
Username: "something",
Password: "bananas",
})
@@ -65,8 +65,8 @@ func TestGitAskpass(t *testing.T) {
t.Run("Poll", func(t *testing.T) {
t.Parallel()
resp := atomic.Pointer[agentsdk.GitAuthResponse]{}
resp.Store(&agentsdk.GitAuthResponse{
resp := atomic.Pointer[agentsdk.ExternalAuthResponse]{}
resp.Store(&agentsdk.ExternalAuthResponse{
URL: "https://something.org",
})
poll := make(chan struct{}, 10)
@@ -96,7 +96,7 @@ func TestGitAskpass(t *testing.T) {
}()
<-poll
stderr.ExpectMatch("Open the following URL to authenticate")
resp.Store(&agentsdk.GitAuthResponse{
resp.Store(&agentsdk.ExternalAuthResponse{
Username: "username",
Password: "password",
})
+1
View File
@@ -83,6 +83,7 @@ func (r *RootCmd) Core() []*clibase.Cmd {
// Please re-sort this list alphabetically if you change it!
return []*clibase.Cmd{
r.dotfiles(),
r.externalAuth(),
r.login(),
r.logout(),
r.netcheck(),
+1
View File
@@ -20,6 +20,7 @@ SUBCOMMANDS:
delete Delete a workspace
dotfiles Personalize your workspace by applying a canonical
dotfiles repository
external-auth Manage external authentication
list List workspaces
login Authenticate with Coder deployment
logout Unauthenticate your local session
+14
View File
@@ -0,0 +1,14 @@
coder v0.0.0-devel
USAGE:
coder external-auth
Manage external authentication
Authenticate with external services inside of a workspace.
SUBCOMMANDS:
access-token Print auth for an external provider
———
Run `coder --help` for a list of global options.
@@ -0,0 +1,28 @@
coder v0.0.0-devel
USAGE:
coder external-auth access-token [flags] <provider>
Print auth for an external provider
Print an access-token for an external auth provider. The access-token will be
validated and sent to stdout with exit code 0. If a valid access-token cannot
be obtained, the URL to authenticate will be sent to stdout with exit code 1
- Ensure that the user is authenticated with GitHub before cloning.:
$ #!/usr/bin/env sh
OUTPUT=$(coder external-auth access-token github)
if [ $? -eq 0 ]; then
echo "Authenticated with GitHub"
else
echo "Please authenticate with GitHub:"
echo $OUTPUT
fi
OPTIONS:
--s bool
Do not print the URL or access token.
———
Run `coder --help` for a list of global options.