Skip to content

Commit 18523d5

Browse files
committed
Add gitauth to cliui
1 parent 18488f8 commit 18523d5

File tree

11 files changed

+327
-39
lines changed

11 files changed

+327
-39
lines changed

cli/cliui/gitauth.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package cliui
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"time"
8+
9+
"github.com/briandowns/spinner"
10+
11+
"github.com/coder/coder/codersdk"
12+
)
13+
14+
type GitAuthOptions struct {
15+
Fetch func(context.Context) ([]codersdk.TemplateVersionGitAuth, error)
16+
FetchInterval time.Duration
17+
}
18+
19+
func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error {
20+
if opts.FetchInterval == 0 {
21+
opts.FetchInterval = 500 * time.Millisecond
22+
}
23+
gitAuth, err := opts.Fetch(ctx)
24+
if err != nil {
25+
return err
26+
}
27+
28+
spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen"))
29+
spin.Writer = writer
30+
spin.ForceOutput = true
31+
spin.Suffix = " Waiting for Git authentication..."
32+
defer spin.Stop()
33+
34+
ticker := time.NewTicker(opts.FetchInterval)
35+
defer ticker.Stop()
36+
for _, auth := range gitAuth {
37+
if auth.Authenticated {
38+
return nil
39+
}
40+
41+
_, _ = fmt.Fprintf(writer, "You must authenticate with %s to create a workspace with this template. Visit:\n\n\t%s\n\n", auth.Type.Pretty(), auth.AuthenticateURL)
42+
43+
ticker.Reset(opts.FetchInterval)
44+
spin.Start()
45+
for {
46+
select {
47+
case <-ctx.Done():
48+
return ctx.Err()
49+
case <-ticker.C:
50+
}
51+
gitAuth, err := opts.Fetch(ctx)
52+
if err != nil {
53+
return err
54+
}
55+
var authed bool
56+
for _, a := range gitAuth {
57+
if !a.Authenticated || a.ID != auth.ID {
58+
continue
59+
}
60+
authed = true
61+
break
62+
}
63+
// The user authenticated with the provider!
64+
if authed {
65+
break
66+
}
67+
}
68+
spin.Stop()
69+
_, _ = fmt.Fprintf(writer, "Successfully authenticated with %s!\n\n", auth.Type.Pretty())
70+
}
71+
return nil
72+
}

cli/cliui/gitauth_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package cliui_test
2+
3+
import (
4+
"context"
5+
"net/url"
6+
"sync/atomic"
7+
"testing"
8+
"time"
9+
10+
"github.com/spf13/cobra"
11+
"github.com/stretchr/testify/assert"
12+
13+
"github.com/coder/coder/cli/cliui"
14+
"github.com/coder/coder/codersdk"
15+
"github.com/coder/coder/pty/ptytest"
16+
"github.com/coder/coder/testutil"
17+
)
18+
19+
func TestGitAuth(t *testing.T) {
20+
t.Parallel()
21+
22+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
23+
defer cancel()
24+
25+
ptty := ptytest.New(t)
26+
cmd := &cobra.Command{
27+
RunE: func(cmd *cobra.Command, args []string) error {
28+
var fetched atomic.Bool
29+
return cliui.GitAuth(cmd.Context(), cmd.OutOrStdout(), cliui.GitAuthOptions{
30+
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionGitAuth, error) {
31+
defer fetched.Store(true)
32+
return []codersdk.TemplateVersionGitAuth{{
33+
ID: "github",
34+
Type: codersdk.GitProviderGitHub,
35+
Authenticated: fetched.Load(),
36+
AuthenticateURL: "https://example.com/gitauth/github?redirect=" + url.QueryEscape("/gitauth?notify"),
37+
}}, nil
38+
},
39+
FetchInterval: time.Millisecond,
40+
})
41+
},
42+
}
43+
cmd.SetOutput(ptty.Output())
44+
cmd.SetIn(ptty.Input())
45+
done := make(chan struct{})
46+
go func() {
47+
defer close(done)
48+
err := cmd.Execute()
49+
assert.NoError(t, err)
50+
}()
51+
ptty.ExpectMatchContext(ctx, "You must authenticate with")
52+
ptty.ExpectMatchContext(ctx, "https://example.com/gitauth/github")
53+
ptty.ExpectMatchContext(ctx, "Successfully authenticated with GitHub")
54+
<-done
55+
}

cli/create.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"context"
45
"fmt"
56
"io"
67
"time"
@@ -324,6 +325,15 @@ PromptRichParamLoop:
324325
_, _ = fmt.Fprintln(cmd.OutOrStdout())
325326
}
326327

328+
err = cliui.GitAuth(ctx, cmd.OutOrStdout(), cliui.GitAuthOptions{
329+
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionGitAuth, error) {
330+
return client.TemplateVersionGitAuth(ctx, templateVersion.ID)
331+
},
332+
})
333+
if err != nil {
334+
return nil, xerrors.Errorf("template version git auth: %w", err)
335+
}
336+
327337
// Run a dry-run with the given parameters to check correctness
328338
dryRun, err := client.CreateTemplateVersionDryRun(cmd.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
329339
WorkspaceName: args.NewWorkspaceName,

cli/create_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,21 @@ package cli_test
33
import (
44
"context"
55
"fmt"
6+
"net/http"
7+
"net/url"
68
"os"
9+
"regexp"
710
"testing"
811
"time"
912

1013
"github.com/stretchr/testify/assert"
1114
"github.com/stretchr/testify/require"
15+
"golang.org/x/oauth2"
1216

1317
"github.com/coder/coder/cli/clitest"
1418
"github.com/coder/coder/coderd/coderdtest"
19+
"github.com/coder/coder/coderd/database"
20+
"github.com/coder/coder/coderd/gitauth"
1521
"github.com/coder/coder/codersdk"
1622
"github.com/coder/coder/provisioner/echo"
1723
"github.com/coder/coder/provisionersdk/proto"
@@ -603,6 +609,61 @@ func TestCreateValidateRichParameters(t *testing.T) {
603609
})
604610
}
605611

612+
func TestCreateWithGitAuth(t *testing.T) {
613+
t.Parallel()
614+
echoResponses := &echo.Responses{
615+
Parse: echo.ParseComplete,
616+
ProvisionPlan: []*proto.Provision_Response{
617+
{
618+
Type: &proto.Provision_Response_Complete{
619+
Complete: &proto.Provision_Complete{
620+
GitAuthProviders: []string{"github"},
621+
},
622+
},
623+
},
624+
},
625+
ProvisionApply: []*proto.Provision_Response{{
626+
Type: &proto.Provision_Response_Complete{
627+
Complete: &proto.Provision_Complete{},
628+
},
629+
}},
630+
}
631+
632+
client := coderdtest.New(t, &coderdtest.Options{
633+
GitAuthConfigs: []*gitauth.Config{{
634+
OAuth2Config: &oauth2Config{},
635+
ID: "github",
636+
Regex: regexp.MustCompile(`github\.com`),
637+
Type: codersdk.GitProviderGitHub,
638+
}},
639+
IncludeProvisionerDaemon: true,
640+
})
641+
user := coderdtest.CreateFirstUser(t, client)
642+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses)
643+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
644+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
645+
646+
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
647+
clitest.SetupConfig(t, client, root)
648+
doneChan := make(chan struct{})
649+
pty := ptytest.New(t)
650+
cmd.SetIn(pty.Input())
651+
cmd.SetOut(pty.Output())
652+
go func() {
653+
defer close(doneChan)
654+
err := cmd.Execute()
655+
assert.NoError(t, err)
656+
}()
657+
658+
pty.ExpectMatch("You must authenticate with GitHub to create a workspace")
659+
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
660+
_ = resp.Body.Close()
661+
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
662+
pty.ExpectMatch("Confirm create?")
663+
pty.WriteLine("yes")
664+
<-doneChan
665+
}
666+
606667
func createTestParseResponseWithDefault(defaultValue string) []*proto.Parse_Response {
607668
return []*proto.Parse_Response{{
608669
Type: &proto.Parse_Response_Complete{
@@ -638,3 +699,40 @@ func createTestParseResponseWithDefault(defaultValue string) []*proto.Parse_Resp
638699
},
639700
}}
640701
}
702+
703+
type oauth2Config struct {
704+
token *oauth2.Token
705+
}
706+
707+
func (*oauth2Config) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string {
708+
return "/?state=" + url.QueryEscape(state)
709+
}
710+
711+
func (o *oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
712+
return &oauth2.Token{
713+
AccessToken: "token",
714+
RefreshToken: "refresh",
715+
Expiry: database.Now().Add(time.Hour),
716+
}, nil
717+
}
718+
719+
func (o *oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource {
720+
return &oauth2TokenSource{
721+
token: o.token,
722+
}
723+
}
724+
725+
type oauth2TokenSource struct {
726+
token *oauth2.Token
727+
}
728+
729+
func (o *oauth2TokenSource) Token() (*oauth2.Token, error) {
730+
if o.token != nil {
731+
return o.token, nil
732+
}
733+
return &oauth2.Token{
734+
AccessToken: "token",
735+
RefreshToken: "refresh",
736+
Expiry: database.Now().Add(time.Hour),
737+
}, nil
738+
}

cmd/cliui/main.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"errors"
66
"fmt"
77
"io"
8+
"net/url"
89
"os"
910
"strings"
11+
"sync/atomic"
1012
"time"
1113

1214
"github.com/spf13/cobra"
@@ -235,6 +237,37 @@ func main() {
235237
},
236238
})
237239

240+
root.AddCommand(&cobra.Command{
241+
Use: "git-auth",
242+
RunE: func(cmd *cobra.Command, args []string) error {
243+
var count atomic.Int32
244+
var githubAuthed atomic.Bool
245+
var gitlabAuthed atomic.Bool
246+
go func() {
247+
time.Sleep(time.Second)
248+
githubAuthed.Store(true)
249+
time.Sleep(time.Second * 2)
250+
gitlabAuthed.Store(true)
251+
}()
252+
return cliui.GitAuth(cmd.Context(), cmd.OutOrStdout(), cliui.GitAuthOptions{
253+
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionGitAuth, error) {
254+
count.Add(1)
255+
return []codersdk.TemplateVersionGitAuth{{
256+
ID: "github",
257+
Type: codersdk.GitProviderGitHub,
258+
Authenticated: githubAuthed.Load(),
259+
AuthenticateURL: "https://example.com/gitauth/github?redirect=" + url.QueryEscape("/gitauth?notify"),
260+
}, {
261+
ID: "gitlab",
262+
Type: codersdk.GitProviderGitLab,
263+
Authenticated: gitlabAuthed.Load(),
264+
AuthenticateURL: "https://example.com/gitauth/gitlab?redirect=" + url.QueryEscape("/gitauth?notify"),
265+
}}, nil
266+
},
267+
})
268+
},
269+
})
270+
238271
err := root.Execute()
239272
if err != nil {
240273
_, _ = fmt.Println(err.Error())

coderd/coderdtest/coderdtest.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,33 @@ func MustWorkspace(t *testing.T, client *codersdk.Client, workspaceID uuid.UUID)
717717
return ws
718718
}
719719

720+
// RequestGitAuthCallback makes a request with the proper OAuth2 state cookie
721+
// to the git auth callback endpoint.
722+
func RequestGitAuthCallback(t *testing.T, providerID string, client *codersdk.Client) *http.Response {
723+
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
724+
return http.ErrUseLastResponse
725+
}
726+
state := "somestate"
727+
oauthURL, err := client.URL.Parse(fmt.Sprintf("/gitauth/%s/callback?code=asd&state=%s", providerID, state))
728+
require.NoError(t, err)
729+
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
730+
require.NoError(t, err)
731+
req.AddCookie(&http.Cookie{
732+
Name: codersdk.OAuth2StateCookie,
733+
Value: state,
734+
})
735+
req.AddCookie(&http.Cookie{
736+
Name: codersdk.SessionTokenCookie,
737+
Value: client.SessionToken(),
738+
})
739+
res, err := client.HTTPClient.Do(req)
740+
require.NoError(t, err)
741+
t.Cleanup(func() {
742+
_ = res.Body.Close()
743+
})
744+
return res
745+
}
746+
720747
// NewGoogleInstanceIdentity returns a metadata client and ID token validator for faking
721748
// instance authentication for Google Cloud.
722749
// nolint:revive

coderd/templateversions.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,14 @@ func (api *API) templateVersionGitAuth(rw http.ResponseWriter, r *http.Request)
292292
})
293293
return
294294
}
295+
query := redirectURL.Query()
296+
// The frontend uses a BroadcastChannel to notify listening pages for
297+
// Git auth updates if the "notify" query parameter is set.
298+
//
299+
// It's important we do this in the backend, because the same endpoint
300+
// is used for CLI authentication.
301+
query.Add("redirect", "/gitauth?notify")
302+
redirectURL.RawQuery = query.Encode()
295303

296304
provider := codersdk.TemplateVersionGitAuth{
297305
ID: config.ID,

coderd/templateversions_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ func TestTemplateVersionsGitAuth(t *testing.T) {
485485
require.False(t, providers[0].Authenticated)
486486

487487
// Perform the Git auth callback to authenticate the user...
488-
resp := gitAuthCallback(t, "github", client)
488+
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
489489
_ = resp.Body.Close()
490490
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
491491

0 commit comments

Comments
 (0)