Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 2606fda

Browse files
authored
Merge branch 'main' into workspaceagent
2 parents fa7489a + 3f77814 commit 2606fda

16 files changed

+448
-80
lines changed

cli/login.go

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,39 @@ package cli
22

33
import (
44
"fmt"
5+
"io/ioutil"
56
"net/url"
7+
"os/exec"
68
"os/user"
9+
"runtime"
710
"strings"
811

912
"github.com/fatih/color"
1013
"github.com/go-playground/validator/v10"
1114
"github.com/manifoldco/promptui"
15+
"github.com/pkg/browser"
1216
"github.com/spf13/cobra"
1317
"golang.org/x/xerrors"
1418

1519
"github.com/coder/coder/coderd"
1620
"github.com/coder/coder/codersdk"
1721
)
1822

23+
const (
24+
goosWindows = "windows"
25+
goosDarwin = "darwin"
26+
)
27+
28+
func init() {
29+
// Hide output from the browser library,
30+
// otherwise we can get really verbose and non-actionable messages
31+
// when in SSH or another type of headless session
32+
// NOTE: This needs to be in `init` to prevent data races
33+
// (multiple threads trying to set the global browser.Std* variables)
34+
browser.Stderr = ioutil.Discard
35+
browser.Stdout = ioutil.Discard
36+
}
37+
1938
func login() *cobra.Command {
2039
return &cobra.Command{
2140
Use: "login <url>",
@@ -116,8 +135,10 @@ func login() *cobra.Command {
116135
if err != nil {
117136
return xerrors.Errorf("login with password: %w", err)
118137
}
138+
139+
sessionToken := resp.SessionToken
119140
config := createConfig(cmd)
120-
err = config.Session().Write(resp.SessionToken)
141+
err = config.Session().Write(sessionToken)
121142
if err != nil {
122143
return xerrors.Errorf("write session token: %w", err)
123144
}
@@ -130,7 +151,82 @@ func login() *cobra.Command {
130151
return nil
131152
}
132153

154+
authURL := *serverURL
155+
authURL.Path = serverURL.Path + "/cli-auth"
156+
if err := openURL(authURL.String()); err != nil {
157+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Open the following in your browser:\n\n\t%s\n\n", authURL.String())
158+
} else {
159+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String())
160+
}
161+
162+
sessionToken, err := prompt(cmd, &promptui.Prompt{
163+
Label: "Paste your token here:",
164+
Mask: '*',
165+
Validate: func(token string) error {
166+
client.SessionToken = token
167+
_, err := client.User(cmd.Context(), "me")
168+
if err != nil {
169+
return xerrors.New("That's not a valid token!")
170+
}
171+
return err
172+
},
173+
})
174+
if err != nil {
175+
return xerrors.Errorf("paste token prompt: %w", err)
176+
}
177+
178+
// Login to get user data - verify it is OK before persisting
179+
client.SessionToken = sessionToken
180+
resp, err := client.User(cmd.Context(), "me")
181+
if err != nil {
182+
return xerrors.Errorf("get user: %w", err)
183+
}
184+
185+
config := createConfig(cmd)
186+
err = config.Session().Write(sessionToken)
187+
if err != nil {
188+
return xerrors.Errorf("write session token: %w", err)
189+
}
190+
err = config.URL().Write(serverURL.String())
191+
if err != nil {
192+
return xerrors.Errorf("write server url: %w", err)
193+
}
194+
195+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(resp.Username))
133196
return nil
134197
},
135198
}
136199
}
200+
201+
// isWSL determines if coder-cli is running within Windows Subsystem for Linux
202+
func isWSL() (bool, error) {
203+
if runtime.GOOS == goosDarwin || runtime.GOOS == goosWindows {
204+
return false, nil
205+
}
206+
data, err := ioutil.ReadFile("/proc/version")
207+
if err != nil {
208+
return false, xerrors.Errorf("read /proc/version: %w", err)
209+
}
210+
return strings.Contains(strings.ToLower(string(data)), "microsoft"), nil
211+
}
212+
213+
// openURL opens the provided URL via user's default browser
214+
func openURL(urlToOpen string) error {
215+
var cmd string
216+
var args []string
217+
218+
wsl, err := isWSL()
219+
if err != nil {
220+
return xerrors.Errorf("test running Windows Subsystem for Linux: %w", err)
221+
}
222+
223+
if wsl {
224+
cmd = "cmd.exe"
225+
args = []string{"/c", "start"}
226+
urlToOpen = strings.ReplaceAll(urlToOpen, "&", "^&")
227+
args = append(args, urlToOpen)
228+
return exec.Command(cmd, args...).Start()
229+
}
230+
231+
return browser.OpenURL(urlToOpen)
232+
}

cli/login_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package cli_test
22

33
import (
4+
"context"
45
"testing"
56

67
"github.com/stretchr/testify/require"
78

89
"github.com/coder/coder/cli/clitest"
10+
"github.com/coder/coder/coderd"
911
"github.com/coder/coder/coderd/coderdtest"
1012
"github.com/coder/coder/pty/ptytest"
1113
)
@@ -50,4 +52,60 @@ func TestLogin(t *testing.T) {
5052
}
5153
pty.ExpectMatch("Welcome to Coder")
5254
})
55+
56+
t.Run("ExistingUserValidTokenTTY", func(t *testing.T) {
57+
t.Parallel()
58+
client := coderdtest.New(t)
59+
_, err := client.CreateInitialUser(context.Background(), coderd.CreateInitialUserRequest{
60+
Username: "test-user",
61+
62+
Organization: "acme-corp",
63+
Password: "password",
64+
})
65+
require.NoError(t, err)
66+
token, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
67+
68+
Password: "password",
69+
})
70+
require.NoError(t, err)
71+
72+
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty")
73+
pty := ptytest.New(t)
74+
root.SetIn(pty.Input())
75+
root.SetOut(pty.Output())
76+
go func() {
77+
err := root.Execute()
78+
require.NoError(t, err)
79+
}()
80+
81+
pty.ExpectMatch("Paste your token here:")
82+
pty.WriteLine(token.SessionToken)
83+
pty.ExpectMatch("Welcome to Coder")
84+
})
85+
86+
t.Run("ExistingUserInvalidTokenTTY", func(t *testing.T) {
87+
t.Parallel()
88+
client := coderdtest.New(t)
89+
_, err := client.CreateInitialUser(context.Background(), coderd.CreateInitialUserRequest{
90+
Username: "test-user",
91+
92+
Organization: "acme-corp",
93+
Password: "password",
94+
})
95+
require.NoError(t, err)
96+
97+
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty")
98+
pty := ptytest.New(t)
99+
root.SetIn(pty.Input())
100+
root.SetOut(pty.Output())
101+
go func() {
102+
err := root.Execute()
103+
// An error is expected in this case, since the login wasn't successful:
104+
require.Error(t, err)
105+
}()
106+
107+
pty.ExpectMatch("Paste your token here:")
108+
pty.WriteLine("an-invalid-token")
109+
pty.ExpectMatch("That's not a valid token!")
110+
})
53111
}

coderd/coderd.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func New(options *Options) http.Handler {
3636
})
3737
r.Post("/login", api.postLogin)
3838
r.Post("/logout", api.postLogout)
39+
3940
// Used for setup.
4041
r.Get("/user", api.user)
4142
r.Post("/user", api.postUser)
@@ -44,10 +45,12 @@ func New(options *Options) http.Handler {
4445
httpmw.ExtractAPIKey(options.Database, nil),
4546
)
4647
r.Post("/", api.postUsers)
47-
r.Group(func(r chi.Router) {
48+
49+
r.Route("/{user}", func(r chi.Router) {
4850
r.Use(httpmw.ExtractUserParam(options.Database))
49-
r.Get("/{user}", api.userByName)
50-
r.Get("/{user}/organizations", api.organizationsByUser)
51+
r.Get("/", api.userByName)
52+
r.Get("/organizations", api.organizationsByUser)
53+
r.Post("/keys", api.postKeyForUser)
5154
})
5255
})
5356
r.Route("/projects", func(r chi.Router) {

coderd/users.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ type LoginWithPasswordResponse struct {
5555
SessionToken string `json:"session_token" validate:"required"`
5656
}
5757

58+
// GenerateAPIKeyResponse contains an API key for a user.
59+
type GenerateAPIKeyResponse struct {
60+
Key string `json:"key"`
61+
}
62+
5863
// Returns whether the initial user has been created or not.
5964
func (api *api) user(rw http.ResponseWriter, r *http.Request) {
6065
userCount, err := api.Database.GetUserCount(r.Context())
@@ -312,6 +317,50 @@ func (api *api) postLogin(rw http.ResponseWriter, r *http.Request) {
312317
})
313318
}
314319

320+
// Creates a new session key, used for logging in via the CLI
321+
func (api *api) postKeyForUser(rw http.ResponseWriter, r *http.Request) {
322+
user := httpmw.UserParam(r)
323+
apiKey := httpmw.APIKey(r)
324+
325+
if user.ID != apiKey.UserID {
326+
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
327+
Message: "Keys can only be generated for the authenticated user",
328+
})
329+
return
330+
}
331+
332+
keyID, keySecret, err := generateAPIKeyIDSecret()
333+
if err != nil {
334+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
335+
Message: fmt.Sprintf("generate api key parts: %s", err.Error()),
336+
})
337+
return
338+
}
339+
hashed := sha256.Sum256([]byte(keySecret))
340+
341+
_, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
342+
ID: keyID,
343+
UserID: apiKey.UserID,
344+
ExpiresAt: database.Now().AddDate(1, 0, 0), // Expire after 1 year (same as v1)
345+
CreatedAt: database.Now(),
346+
UpdatedAt: database.Now(),
347+
HashedSecret: hashed[:],
348+
LoginType: database.LoginTypeBuiltIn,
349+
})
350+
if err != nil {
351+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
352+
Message: fmt.Sprintf("insert api key: %s", err.Error()),
353+
})
354+
return
355+
}
356+
357+
// This format is consumed by the APIKey middleware.
358+
generatedAPIKey := fmt.Sprintf("%s-%s", keyID, keySecret)
359+
360+
render.Status(r, http.StatusCreated)
361+
render.JSON(rw, r, GenerateAPIKeyResponse{Key: generatedAPIKey})
362+
}
363+
315364
// Clear the user's session cookie
316365
func (*api) postLogout(rw http.ResponseWriter, r *http.Request) {
317366
// Get a blank token cookie

coderd/users_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,33 @@ func TestOrganizationsByUser(t *testing.T) {
119119
require.Len(t, orgs, 1)
120120
}
121121

122+
func TestPostKey(t *testing.T) {
123+
t.Parallel()
124+
t.Run("InvalidUser", func(t *testing.T) {
125+
t.Parallel()
126+
client := coderdtest.New(t)
127+
_ = coderdtest.CreateInitialUser(t, client)
128+
129+
// Clear session token
130+
client.SessionToken = ""
131+
// ...and request an API key
132+
_, err := client.CreateAPIKey(context.Background())
133+
var apiErr *codersdk.Error
134+
require.ErrorAs(t, err, &apiErr)
135+
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
136+
})
137+
138+
t.Run("Success", func(t *testing.T) {
139+
t.Parallel()
140+
client := coderdtest.New(t)
141+
_ = coderdtest.CreateInitialUser(t, client)
142+
apiKey, err := client.CreateAPIKey(context.Background())
143+
require.NotNil(t, apiKey)
144+
require.GreaterOrEqual(t, len(apiKey.Key), 2)
145+
require.NoError(t, err)
146+
})
147+
}
148+
122149
func TestPostLogin(t *testing.T) {
123150
t.Parallel()
124151
t.Run("InvalidUser", func(t *testing.T) {

codersdk/users.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,20 @@ func (c *Client) CreateUser(ctx context.Context, req coderd.CreateUserRequest) (
5656
return user, json.NewDecoder(res.Body).Decode(&user)
5757
}
5858

59+
// CreateAPIKey calls the /api-key API
60+
func (c *Client) CreateAPIKey(ctx context.Context) (*coderd.GenerateAPIKeyResponse, error) {
61+
res, err := c.request(ctx, http.MethodPost, "/api/v2/users/me/keys", nil)
62+
if err != nil {
63+
return nil, err
64+
}
65+
defer res.Body.Close()
66+
if res.StatusCode > http.StatusCreated {
67+
return nil, readBodyAsError(res)
68+
}
69+
apiKey := &coderd.GenerateAPIKeyResponse{}
70+
return apiKey, json.NewDecoder(res.Body).Decode(apiKey)
71+
}
72+
5973
// LoginWithPassword creates a session token authenticating with an email and password.
6074
// Call `SetSessionToken()` to apply the newly acquired token to the client.
6175
func (c *Client) LoginWithPassword(ctx context.Context, req coderd.LoginWithPasswordRequest) (coderd.LoginWithPasswordResponse, error) {

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ require (
115115
github.com/pion/stun v0.3.5 // indirect
116116
github.com/pion/turn/v2 v2.0.6 // indirect
117117
github.com/pion/udp v0.1.1 // indirect
118+
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
118119
github.com/pkg/errors v0.9.1 // indirect
119120
github.com/pmezard/go-difflib v1.0.0 // indirect
120121
github.com/sirupsen/logrus v1.8.1 // indirect

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,7 @@ github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M
10621062
github.com/pion/webrtc/v3 v3.1.23 h1:suyNiF9o2/6SBsyWA1UweraUWYkaHCNJdt/16b61I5w=
10631063
github.com/pion/webrtc/v3 v3.1.23/go.mod h1:L5S/oAhL0Fzt/rnftVQRrP80/j5jygY7XRZzWwFx6P4=
10641064
github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
1065+
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
10651066
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
10661067
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
10671068
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

site/api.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,16 @@ export const logout = async (): Promise<void> => {
139139

140140
return
141141
}
142+
143+
export const getApiKey = async (): Promise<{ key: string }> => {
144+
const response = await fetch("/api/v2/users/me/keys", {
145+
method: "POST",
146+
})
147+
148+
if (!response.ok) {
149+
const body = await response.json()
150+
throw new Error(body.message)
151+
}
152+
153+
return await response.json()
154+
}

0 commit comments

Comments
 (0)