Skip to content
This repository was archived by the owner on Aug 30, 2024. It is now read-only.

Commit 3253e56

Browse files
authored
feat: add password authentication to coder-sdk (#250)
This enables authenticating to Coder using the coder-sdk using an email/password combination (basic authentication). As it is intended for test purposes only, it is not exposed via coder-cli.
1 parent 64be2fb commit 3253e56

File tree

3 files changed

+193
-5
lines changed

3 files changed

+193
-5
lines changed

coder-sdk/client.go

+56-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package coder
22

33
import (
4+
"context"
45
"errors"
56
"net/http"
67
"net/url"
8+
"time"
9+
10+
"golang.org/x/xerrors"
711
)
812

913
// ensure that DefaultClient implements Client.
@@ -14,15 +18,38 @@ const Me = "me"
1418

1519
// ClientOptions contains options for the Coder SDK Client.
1620
type ClientOptions struct {
17-
// BaseURL is the root URL of the Coder installation.
21+
// BaseURL is the root URL of the Coder installation (required).
1822
BaseURL *url.URL
1923

2024
// Client is the http.Client to use for requests (optional).
25+
//
2126
// If omitted, the http.DefaultClient will be used.
2227
HTTPClient *http.Client
2328

24-
// Token is the API Token used to authenticate
29+
// Token is the API Token used to authenticate (optional).
30+
//
31+
// If Token is provided, the DefaultClient will use it to
32+
// authenticate. If it is not provided, the client requires
33+
// another type of credential, such as an Email/Password pair.
2534
Token string
35+
36+
// Email used to authenticate with Coder.
37+
//
38+
// If you supply an Email and Password pair, NewClient will
39+
// exchange these credentials for a Token during initialization.
40+
// This is only applicable for the built-in authentication
41+
// provider. The client will not retain these credentials in
42+
// memory after NewClient returns.
43+
Email string
44+
45+
// Password used to authenticate with Coder.
46+
//
47+
// If you supply an Email and Password pair, NewClient will
48+
// exchange these credentials for a Token during initialization.
49+
// This is only applicable for the built-in authentication
50+
// provider. The client will not retain these credentials in
51+
// memory after NewClient returns.
52+
Password string
2653
}
2754

2855
// NewClient creates a new default Coder SDK client.
@@ -36,14 +63,38 @@ func NewClient(opts ClientOptions) (*DefaultClient, error) {
3663
return nil, errors.New("the BaseURL parameter is required")
3764
}
3865

39-
if opts.Token == "" {
40-
return nil, errors.New("an API token is required")
66+
token := opts.Token
67+
if token == "" {
68+
if opts.Email == "" || opts.Password == "" {
69+
return nil, errors.New("either an API Token or email/password pair are required")
70+
}
71+
72+
// Exchange the username/password for a token.
73+
// We apply a default timeout of 5 seconds here.
74+
ctx := context.Background()
75+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
76+
defer cancel()
77+
78+
resp, err := LoginWithPassword(ctx, httpClient, opts.BaseURL, &LoginRequest{
79+
Email: opts.Email,
80+
Password: opts.Password,
81+
})
82+
if err != nil {
83+
return nil, xerrors.Errorf("failed to login with email/password: %w", err)
84+
}
85+
86+
token = resp.SessionToken
87+
if token == "" {
88+
return nil, errors.New("server returned an empty session token")
89+
}
4190
}
4291

92+
// TODO: add basic validation to make sure the token looks OK.
93+
4394
client := &DefaultClient{
4495
baseURL: opts.BaseURL,
4596
httpClient: httpClient,
46-
token: opts.Token,
97+
token: token,
4798
}
4899

49100
return client, nil

coder-sdk/client_test.go

+68
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,74 @@ func TestAuthentication(t *testing.T) {
124124
require.NoError(t, err, "failed to get API version information")
125125
}
126126

127+
func TestPasswordAuthentication(t *testing.T) {
128+
t.Parallel()
129+
130+
mux := http.NewServeMux()
131+
mux.HandleFunc("/auth/basic/login", func(w http.ResponseWriter, r *http.Request) {
132+
require.Equal(t, r.Method, http.MethodPost, "login is a POST")
133+
134+
expected := map[string]interface{}{
135+
"email": "user@coder.com",
136+
"password": "coder4all",
137+
}
138+
var request map[string]interface{}
139+
err := json.NewDecoder(r.Body).Decode(&request)
140+
require.NoError(t, err, "error decoding JSON")
141+
require.EqualValues(t, expected, request, "unexpected request data")
142+
143+
response := map[string]interface{}{
144+
"session_token": "g4mtIPUaKt-pPl9Q0xmgKs7acSypHt4Jf",
145+
}
146+
147+
w.WriteHeader(http.StatusOK)
148+
err = json.NewEncoder(w).Encode(response)
149+
require.NoError(t, err, "error encoding JSON")
150+
})
151+
mux.HandleFunc("/api/v0/users/me", func(w http.ResponseWriter, r *http.Request) {
152+
require.Equal(t, http.MethodGet, r.Method, "Users is a GET")
153+
154+
require.Equal(t, "g4mtIPUaKt-pPl9Q0xmgKs7acSypHt4Jf", r.Header.Get("Session-Token"), "expected session token to match return of login")
155+
156+
user := map[string]interface{}{
157+
"id": "default",
158+
"email": "user@coder.com",
159+
"username": "charlie",
160+
"name": "Charlie Root",
161+
"roles": []coder.Role{coder.SiteAdmin},
162+
"temporary_password": false,
163+
"login_type": coder.LoginTypeBuiltIn,
164+
"key_regenerated_at": time.Now(),
165+
"created_at": time.Now(),
166+
"updated_at": time.Now(),
167+
}
168+
169+
w.WriteHeader(http.StatusOK)
170+
err := json.NewEncoder(w).Encode(user)
171+
require.NoError(t, err, "error encoding JSON")
172+
})
173+
server := httptest.NewTLSServer(mux)
174+
t.Cleanup(func() {
175+
server.Close()
176+
})
177+
178+
u, err := url.Parse(server.URL)
179+
require.NoError(t, err, "failed to parse test server URL")
180+
require.Equal(t, "https", u.Scheme, "expected HTTPS base URL")
181+
182+
client, err := coder.NewClient(coder.ClientOptions{
183+
BaseURL: u,
184+
HTTPClient: server.Client(),
185+
Email: "user@coder.com",
186+
Password: "coder4all",
187+
})
188+
require.NoError(t, err, "failed to create Client")
189+
190+
user, err := client.Me(context.Background())
191+
require.NoError(t, err, "failed to get information about current user")
192+
require.Equal(t, "user@coder.com", user.Email, "expected test user")
193+
}
194+
127195
func TestContextRoot(t *testing.T) {
128196
t.Parallel()
129197

coder-sdk/login.go

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package coder
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
12+
"golang.org/x/xerrors"
13+
)
14+
15+
// LoginRequest is a request to authenticate using email
16+
// and password credentials.
17+
//
18+
// This is provided for use in tests, and we recommend users authenticate
19+
// using an API Token.
20+
type LoginRequest struct {
21+
Email string `json:"email"`
22+
Password string `json:"password"`
23+
}
24+
25+
// LoginResponse contains successful response data for an authentication
26+
// request, including an API Token to be used for subsequent requests.
27+
//
28+
// This is provided for use in tests, and we recommend users authenticate
29+
// using an API Token.
30+
type LoginResponse struct {
31+
SessionToken string `json:"session_token"`
32+
}
33+
34+
// LoginWithPassword exchanges the email/password pair for
35+
// a Session Token.
36+
//
37+
// If client is nil, the http.DefaultClient will be used.
38+
func LoginWithPassword(ctx context.Context, client *http.Client, baseURL *url.URL, req *LoginRequest) (resp *LoginResponse, err error) {
39+
if client == nil {
40+
client = http.DefaultClient
41+
}
42+
43+
url := *baseURL
44+
url.Path = fmt.Sprint(strings.TrimSuffix(url.Path, "/"), "/auth/basic/login")
45+
46+
buf := &bytes.Buffer{}
47+
err = json.NewEncoder(buf).Encode(req)
48+
if err != nil {
49+
return nil, xerrors.Errorf("failed to marshal JSON: %w", err)
50+
}
51+
52+
request, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), buf)
53+
if err != nil {
54+
return nil, xerrors.Errorf("failed to create request: %w", err)
55+
}
56+
57+
response, err := client.Do(request)
58+
if err != nil {
59+
return nil, xerrors.Errorf("error processing login request: %w", err)
60+
}
61+
defer response.Body.Close()
62+
63+
err = json.NewDecoder(response.Body).Decode(&resp)
64+
if err != nil {
65+
return nil, xerrors.Errorf("failed to decode response: %w", err)
66+
}
67+
68+
return resp, nil
69+
}

0 commit comments

Comments
 (0)