Skip to content

Commit 3f27a7a

Browse files
authored
Merge pull request #21 from speakeasy-api/feat/basic-auth-token-server
feat: basic auth token server
2 parents 8ff1825 + d52ac9f commit 3f27a7a

File tree

3 files changed

+183
-3
lines changed

3 files changed

+183
-3
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
.idea
1+
.idea
2+
*.iml

internal/clientcredentials/service.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package clientcredentials
22

33
import (
4+
"encoding/base64"
45
"encoding/json"
56
"net/http"
67
"slices"
@@ -15,21 +16,57 @@ const (
1516
secondAccessToken = "second-super-duper-access-token"
1617
)
1718

19+
func handleBasicAuth(authHeader string) (clientID, clientSecret string, ok bool) {
20+
// Remove "Basic " prefix (case-insensitive)
21+
if !strings.HasPrefix(strings.ToLower(authHeader), "basic ") {
22+
return "", "", false
23+
}
24+
encodedCreds := strings.TrimSpace(authHeader[6:])
25+
26+
// Decode base64
27+
decodedCreds, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encodedCreds))
28+
if err != nil {
29+
return "", "", false
30+
}
31+
32+
// Split into username:password
33+
creds := strings.SplitN(string(decodedCreds), ":", 2)
34+
if len(creds) != 2 {
35+
return "", "", false
36+
}
37+
38+
return creds[0], creds[1], true
39+
}
40+
41+
1842
func HandleTokenRequest(w http.ResponseWriter, r *http.Request) {
43+
var clientID, clientSecret string
1944
err := r.ParseForm()
2045
if err != nil {
2146
http.Error(w, "invalid_request", http.StatusBadRequest)
2247
return
2348
}
2449

50+
// Check for Basic Auth header
51+
if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(strings.ToLower(authHeader), "basic ") {
52+
var ok bool
53+
clientID, clientSecret, ok = handleBasicAuth(authHeader)
54+
if !ok {
55+
http.Error(w, "invalid_client", http.StatusUnauthorized)
56+
return
57+
}
58+
} else {
59+
clientID = r.Form.Get("client_id")
60+
clientSecret = r.Form.Get("client_secret")
61+
}
2562
grant_type := r.Form.Get("grant_type")
2663
if grant_type != "client_credentials" {
2764
http.Error(w, "invalid_grant", http.StatusBadRequest)
2865
return
2966
}
3067

31-
clientID := r.Form.Get("client_id")
32-
clientSecret := r.Form.Get("client_secret")
68+
69+
3370
if clientID == "" || clientSecret == "" {
3471
http.Error(w, "invalid_request", http.StatusBadRequest)
3572
return
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package clientcredentials
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"strings"
10+
"testing"
11+
)
12+
13+
func TestHandleTokenRequest(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
setupRequest func() *http.Request
17+
wantStatus int
18+
wantAccessToken string
19+
}{
20+
{
21+
name: "valid form credentials",
22+
setupRequest: func() *http.Request {
23+
form := url.Values{}
24+
form.Set("grant_type", "client_credentials")
25+
form.Set("client_id", "speakeasy-sdks")
26+
form.Set("client_secret", "supersecret-123")
27+
form.Set("scope", "read write")
28+
29+
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
30+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
31+
return req
32+
},
33+
wantStatus: http.StatusOK,
34+
wantAccessToken: firstAccessToken,
35+
},
36+
{
37+
name: "valid basic auth",
38+
setupRequest: func() *http.Request {
39+
form := url.Values{}
40+
form.Set("grant_type", "client_credentials")
41+
form.Set("scope", "read write")
42+
43+
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
44+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
45+
46+
// Create basic auth header
47+
creds := base64.StdEncoding.EncodeToString([]byte("speakeasy-sdks:supersecret-123"))
48+
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", creds))
49+
50+
return req
51+
},
52+
wantStatus: http.StatusOK,
53+
wantAccessToken: firstAccessToken,
54+
},
55+
{
56+
name: "invalid basic auth format",
57+
setupRequest: func() *http.Request {
58+
req := httptest.NewRequest(http.MethodPost, "/token", nil)
59+
req.Header.Set("Authorization", "Basic invalid-base64")
60+
return req
61+
},
62+
wantStatus: http.StatusUnauthorized,
63+
},
64+
{
65+
name: "missing credentials in basic auth",
66+
setupRequest: func() *http.Request {
67+
req := httptest.NewRequest(http.MethodPost, "/token", nil)
68+
// Encode just username without password
69+
creds := base64.StdEncoding.EncodeToString([]byte("speakeasy-sdks"))
70+
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", creds))
71+
return req
72+
},
73+
wantStatus: http.StatusUnauthorized,
74+
},
75+
{
76+
name: "invalid credentials in basic auth",
77+
setupRequest: func() *http.Request {
78+
form := url.Values{}
79+
form.Set("grant_type", "client_credentials")
80+
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
81+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
82+
creds := base64.StdEncoding.EncodeToString([]byte("wrong:wrong"))
83+
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", creds))
84+
85+
return req
86+
},
87+
wantStatus: http.StatusUnauthorized,
88+
},
89+
{
90+
name: "missing required scope in basic auth",
91+
setupRequest: func() *http.Request {
92+
form := url.Values{}
93+
form.Set("grant_type", "client_credentials")
94+
form.Set("scope", "read") // missing write scope
95+
96+
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
97+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
98+
99+
creds := base64.StdEncoding.EncodeToString([]byte("speakeasy-sdks:supersecret-123"))
100+
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", creds))
101+
102+
return req
103+
},
104+
wantStatus: http.StatusBadRequest,
105+
},
106+
{
107+
name: "case insensitive basic prefix",
108+
setupRequest: func() *http.Request {
109+
form := url.Values{}
110+
form.Set("grant_type", "client_credentials")
111+
form.Set("scope", "read write")
112+
113+
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
114+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
115+
116+
creds := base64.StdEncoding.EncodeToString([]byte("speakeasy-sdks:supersecret-123"))
117+
req.Header.Set("Authorization", fmt.Sprintf("BASIC %s", creds))
118+
119+
return req
120+
},
121+
wantStatus: http.StatusOK,
122+
wantAccessToken: firstAccessToken,
123+
},
124+
}
125+
126+
for _, tt := range tests {
127+
t.Run(tt.name, func(t *testing.T) {
128+
w := httptest.NewRecorder()
129+
HandleTokenRequest(w, tt.setupRequest())
130+
131+
if got := w.Code; got != tt.wantStatus {
132+
t.Errorf("HandleTokenRequest() status = %v, want %v", got, tt.wantStatus)
133+
}
134+
135+
if tt.wantStatus == http.StatusOK {
136+
if !strings.Contains(w.Body.String(), tt.wantAccessToken) {
137+
t.Errorf("HandleTokenRequest() response doesn't contain expected access token")
138+
}
139+
}
140+
})
141+
}
142+
}

0 commit comments

Comments
 (0)