Skip to content

Commit 2606fda

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

File tree

16 files changed

+448
-80
lines changed

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+
Email: "test-user@coder.com",
62+
Organization: "acme-corp",
63+
Password: "password",
64+
})
65+
require.NoError(t, err)
66+
token, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
67+
Email: "test-user@coder.com",
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+
Email: "test-user@coder.com",
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy