Skip to content

Commit 124f78b

Browse files
authored
Refactor: extract oauth from login (#325)
* Refactor: extract oauth from login * Lint
1 parent 1b145e7 commit 124f78b

14 files changed

Lines changed: 324 additions & 187 deletions

File tree

cmd/login/login.go

Lines changed: 15 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,10 @@ package login
22

33
import (
44
"context"
5-
"crypto/rand"
6-
"crypto/sha256"
7-
"embed"
8-
"encoding/base64"
9-
"encoding/json"
105
"fmt"
11-
"io"
126
"net"
137
"net/http"
148
"net/url"
15-
"os/exec"
169
rt "runtime"
1710
"strings"
1811
"time"
@@ -24,28 +17,19 @@ import (
2417
"github.com/smartcontractkit/cre-cli/internal/constants"
2518
"github.com/smartcontractkit/cre-cli/internal/credentials"
2619
"github.com/smartcontractkit/cre-cli/internal/environments"
20+
"github.com/smartcontractkit/cre-cli/internal/oauth"
2721
"github.com/smartcontractkit/cre-cli/internal/runtime"
2822
"github.com/smartcontractkit/cre-cli/internal/tenantctx"
2923
"github.com/smartcontractkit/cre-cli/internal/ui"
3024
)
3125

3226
var (
33-
httpClient = &http.Client{Timeout: 10 * time.Second}
34-
errorPage = "htmlPages/error.html"
35-
successPage = "htmlPages/success.html"
36-
waitingPage = "htmlPages/waiting.html"
37-
stylePage = "htmlPages/output.css"
38-
3927
// OrgMembershipErrorSubstring is the error message substring returned by Auth0
4028
// when a user doesn't belong to any organization during the auth flow.
4129
// This typically happens during sign-up when the organization hasn't been created yet.
4230
OrgMembershipErrorSubstring = "user does not belong to any organization"
4331
)
4432

45-
//go:embed htmlPages/*.html
46-
//go:embed htmlPages/*.css
47-
var htmlFiles embed.FS
48-
4933
func New(runtimeCtx *runtime.Context) *cobra.Command {
5034
cmd := &cobra.Command{
5135
Use: "login",
@@ -102,7 +86,7 @@ func (h *handler) execute() error {
10286

10387
// Use spinner for the token exchange
10488
h.spinner.Start("Exchanging authorization code...")
105-
tokenSet, err := h.exchangeCodeForTokens(context.Background(), code)
89+
tokenSet, err := oauth.ExchangeAuthorizationCode(context.Background(), nil, h.environmentSet, code, h.lastPKCEVerifier)
10690
if err != nil {
10791
h.spinner.StopAll()
10892
h.log.Error().Err(err).Msg("code exchange failed")
@@ -162,13 +146,13 @@ func (h *handler) startAuthFlow() (string, error) {
162146
}
163147
}()
164148

165-
verifier, challenge, err := generatePKCE()
149+
verifier, challenge, err := oauth.GeneratePKCE()
166150
if err != nil {
167151
h.spinner.Stop()
168152
return "", err
169153
}
170154
h.lastPKCEVerifier = verifier
171-
h.lastState = randomState()
155+
h.lastState = oauth.RandomState()
172156

173157
authURL := h.buildAuthURL(challenge, h.lastState)
174158

@@ -180,7 +164,7 @@ func (h *handler) startAuthFlow() (string, error) {
180164
ui.URL(authURL)
181165
ui.Line()
182166

183-
if err := openBrowser(authURL, rt.GOOS); err != nil {
167+
if err := oauth.OpenBrowser(authURL, rt.GOOS); err != nil {
184168
ui.Warning("Could not open browser automatically")
185169
ui.Dim("Please open the URL above in your browser")
186170
ui.Line()
@@ -199,19 +183,7 @@ func (h *handler) startAuthFlow() (string, error) {
199183
}
200184

201185
func (h *handler) setupServer(codeCh chan string) (*http.Server, net.Listener, error) {
202-
mux := http.NewServeMux()
203-
mux.HandleFunc("/callback", h.callbackHandler(codeCh))
204-
205-
// TODO: Add a fallback port in case the default port is in use
206-
listener, err := net.Listen("tcp", constants.AuthListenAddr)
207-
if err != nil {
208-
return nil, nil, fmt.Errorf("failed to listen on %s: %w", constants.AuthListenAddr, err)
209-
}
210-
211-
return &http.Server{
212-
Handler: mux,
213-
ReadHeaderTimeout: 5 * time.Second,
214-
}, listener, nil
186+
return oauth.NewCallbackHTTPServer(constants.AuthListenAddr, h.callbackHandler(codeCh))
215187
}
216188

217189
func (h *handler) callbackHandler(codeCh chan string) http.HandlerFunc {
@@ -225,120 +197,52 @@ func (h *handler) callbackHandler(codeCh chan string) http.HandlerFunc {
225197
if strings.Contains(errorDesc, OrgMembershipErrorSubstring) {
226198
if h.retryCount >= maxOrgNotFoundRetries {
227199
h.log.Error().Int("retries", h.retryCount).Msg("organization setup timed out after maximum retries")
228-
h.serveEmbeddedHTML(w, errorPage, http.StatusBadRequest)
200+
oauth.ServeEmbeddedHTML(h.log, w, oauth.PageError, http.StatusBadRequest)
229201
return
230202
}
231203

232204
// Generate new authentication credentials for the retry
233-
verifier, challenge, err := generatePKCE()
205+
verifier, challenge, err := oauth.GeneratePKCE()
234206
if err != nil {
235207
h.log.Error().Err(err).Msg("failed to prepare authentication retry")
236-
h.serveEmbeddedHTML(w, errorPage, http.StatusInternalServerError)
208+
oauth.ServeEmbeddedHTML(h.log, w, oauth.PageError, http.StatusInternalServerError)
237209
return
238210
}
239211
h.lastPKCEVerifier = verifier
240-
h.lastState = randomState()
212+
h.lastState = oauth.RandomState()
241213
h.retryCount++
242214

243215
// Build the new auth URL for redirect
244216
authURL := h.buildAuthURL(challenge, h.lastState)
245217

246218
h.log.Debug().Int("attempt", h.retryCount).Int("max", maxOrgNotFoundRetries).Msg("organization setup in progress, retrying")
247-
h.serveWaitingPage(w, authURL)
219+
oauth.ServeWaitingPage(h.log, w, authURL)
248220
return
249221
}
250222

251223
// Generic Auth0 error
252224
h.log.Error().Str("error", errorParam).Str("description", errorDesc).Msg("auth error in callback")
253-
h.serveEmbeddedHTML(w, errorPage, http.StatusBadRequest)
225+
oauth.ServeEmbeddedHTML(h.log, w, oauth.PageError, http.StatusBadRequest)
254226
return
255227
}
256228

257229
if st := r.URL.Query().Get("state"); st == "" || h.lastState == "" || st != h.lastState {
258230
h.log.Error().Msg("invalid state in response")
259-
h.serveEmbeddedHTML(w, errorPage, http.StatusBadRequest)
231+
oauth.ServeEmbeddedHTML(h.log, w, oauth.PageError, http.StatusBadRequest)
260232
return
261233
}
262234
code := r.URL.Query().Get("code")
263235
if code == "" {
264236
h.log.Error().Msg("no code in response")
265-
h.serveEmbeddedHTML(w, errorPage, http.StatusBadRequest)
237+
oauth.ServeEmbeddedHTML(h.log, w, oauth.PageError, http.StatusBadRequest)
266238
return
267239
}
268240

269-
h.serveEmbeddedHTML(w, successPage, http.StatusOK)
241+
oauth.ServeEmbeddedHTML(h.log, w, oauth.PageSuccess, http.StatusOK)
270242
codeCh <- code
271243
}
272244
}
273245

274-
func (h *handler) serveEmbeddedHTML(w http.ResponseWriter, filePath string, status int) {
275-
htmlContent, err := htmlFiles.ReadFile(filePath)
276-
if err != nil {
277-
h.log.Error().Err(err).Str("file", filePath).Msg("failed to read embedded HTML file")
278-
h.sendHTTPError(w)
279-
return
280-
}
281-
282-
cssContent, err := htmlFiles.ReadFile(stylePage)
283-
if err != nil {
284-
h.log.Error().Err(err).Str("file", stylePage).Msg("failed to read embedded CSS file")
285-
h.sendHTTPError(w)
286-
return
287-
}
288-
289-
modified := strings.Replace(
290-
string(htmlContent),
291-
`<link rel="stylesheet" href="./output.css" />`,
292-
fmt.Sprintf("<style>%s</style>", string(cssContent)),
293-
1,
294-
)
295-
296-
w.Header().Set("Content-Type", "text/html")
297-
w.WriteHeader(status)
298-
if _, err := w.Write([]byte(modified)); err != nil {
299-
h.log.Error().Err(err).Msg("failed to write HTML response")
300-
}
301-
}
302-
303-
// serveWaitingPage serves the waiting page with the redirect URL injected.
304-
// This is used when handling organization membership errors during sign-up flow.
305-
func (h *handler) serveWaitingPage(w http.ResponseWriter, redirectURL string) {
306-
htmlContent, err := htmlFiles.ReadFile(waitingPage)
307-
if err != nil {
308-
h.log.Error().Err(err).Str("file", waitingPage).Msg("failed to read waiting page HTML file")
309-
h.sendHTTPError(w)
310-
return
311-
}
312-
313-
cssContent, err := htmlFiles.ReadFile(stylePage)
314-
if err != nil {
315-
h.log.Error().Err(err).Str("file", stylePage).Msg("failed to read embedded CSS file")
316-
h.sendHTTPError(w)
317-
return
318-
}
319-
320-
// Inject CSS inline
321-
modified := strings.Replace(
322-
string(htmlContent),
323-
`<link rel="stylesheet" href="./output.css" />`,
324-
fmt.Sprintf("<style>%s</style>", string(cssContent)),
325-
1,
326-
)
327-
328-
// Inject the redirect URL
329-
modified = strings.Replace(modified, "{{REDIRECT_URL}}", redirectURL, 1)
330-
331-
w.Header().Set("Content-Type", "text/html")
332-
w.WriteHeader(http.StatusOK)
333-
if _, err := w.Write([]byte(modified)); err != nil {
334-
h.log.Error().Err(err).Msg("failed to write waiting page response")
335-
}
336-
}
337-
338-
func (h *handler) sendHTTPError(w http.ResponseWriter) {
339-
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
340-
}
341-
342246
func (h *handler) buildAuthURL(codeChallenge, state string) string {
343247
params := url.Values{}
344248
params.Set("client_id", h.environmentSet.ClientID)
@@ -355,41 +259,6 @@ func (h *handler) buildAuthURL(codeChallenge, state string) string {
355259
return h.environmentSet.AuthBase + constants.AuthAuthorizePath + "?" + params.Encode()
356260
}
357261

358-
func (h *handler) exchangeCodeForTokens(ctx context.Context, code string) (*credentials.CreLoginTokenSet, error) {
359-
form := url.Values{}
360-
form.Set("grant_type", "authorization_code")
361-
form.Set("client_id", h.environmentSet.ClientID)
362-
form.Set("code", code)
363-
form.Set("redirect_uri", constants.AuthRedirectURI)
364-
form.Set("code_verifier", h.lastPKCEVerifier)
365-
366-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.environmentSet.AuthBase+constants.AuthTokenPath, strings.NewReader(form.Encode()))
367-
if err != nil {
368-
return nil, fmt.Errorf("create request: %w", err)
369-
}
370-
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
371-
372-
resp, err := httpClient.Do(req) // #nosec G704 -- URL is from trusted environment config
373-
if err != nil {
374-
return nil, fmt.Errorf("perform request: %w", err)
375-
}
376-
defer resp.Body.Close()
377-
378-
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
379-
if err != nil {
380-
return nil, fmt.Errorf("read response: %w", err)
381-
}
382-
if resp.StatusCode != http.StatusOK {
383-
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
384-
}
385-
386-
var tokenSet credentials.CreLoginTokenSet
387-
if err := json.Unmarshal(body, &tokenSet); err != nil {
388-
return nil, fmt.Errorf("unmarshal token set: %w", err)
389-
}
390-
return &tokenSet, nil
391-
}
392-
393262
func (h *handler) fetchTenantConfig(tokenSet *credentials.CreLoginTokenSet) error {
394263
creds := &credentials.Credentials{
395264
Tokens: tokenSet,
@@ -404,35 +273,3 @@ func (h *handler) fetchTenantConfig(tokenSet *credentials.CreLoginTokenSet) erro
404273

405274
return tenantctx.FetchAndWriteContext(context.Background(), gqlClient, envName, h.log)
406275
}
407-
408-
func openBrowser(urlStr string, goos string) error {
409-
switch goos {
410-
case "darwin":
411-
return exec.Command("open", urlStr).Start()
412-
case "linux":
413-
return exec.Command("xdg-open", urlStr).Start()
414-
case "windows":
415-
return exec.Command("rundll32", "url.dll,FileProtocolHandler", urlStr).Start()
416-
default:
417-
return fmt.Errorf("unsupported OS: %s", goos)
418-
}
419-
}
420-
421-
func generatePKCE() (verifier, challenge string, err error) {
422-
b := make([]byte, 32)
423-
if _, err = rand.Read(b); err != nil {
424-
return "", "", err
425-
}
426-
verifier = base64.RawURLEncoding.EncodeToString(b)
427-
sum := sha256.Sum256([]byte(verifier))
428-
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
429-
return verifier, challenge, nil
430-
}
431-
432-
func randomState() string {
433-
b := make([]byte, 16)
434-
if _, err := rand.Read(b); err != nil {
435-
return fmt.Sprintf("%d", time.Now().UnixNano())
436-
}
437-
return base64.RawURLEncoding.EncodeToString(b)
438-
}

cmd/login/login_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/smartcontractkit/cre-cli/internal/credentials"
1616
"github.com/smartcontractkit/cre-cli/internal/environments"
17+
"github.com/smartcontractkit/cre-cli/internal/oauth"
1718
"github.com/smartcontractkit/cre-cli/internal/ui"
1819
)
1920

@@ -51,18 +52,18 @@ func TestSaveCredentials_WritesYAML(t *testing.T) {
5152
}
5253

5354
func TestGeneratePKCE_ReturnsValidChallenge(t *testing.T) {
54-
verifier, challenge, err := generatePKCE()
55+
verifier, challenge, err := oauth.GeneratePKCE()
5556
if err != nil {
56-
t.Fatalf("generatePKCE error: %v", err)
57+
t.Fatalf("GeneratePKCE error: %v", err)
5758
}
5859
if verifier == "" || challenge == "" {
5960
t.Error("PKCE verifier or challenge is empty")
6061
}
6162
}
6263

6364
func TestRandomState_IsRandomAndNonEmpty(t *testing.T) {
64-
state1 := randomState()
65-
state2 := randomState()
65+
state1 := oauth.RandomState()
66+
state2 := oauth.RandomState()
6667
if state1 == "" || state2 == "" {
6768
t.Error("randomState returned empty string")
6869
}
@@ -72,16 +73,16 @@ func TestRandomState_IsRandomAndNonEmpty(t *testing.T) {
7273
}
7374

7475
func TestOpenBrowser_UnsupportedOS(t *testing.T) {
75-
err := openBrowser("http://example.com", "plan9")
76+
err := oauth.OpenBrowser("http://example.com", "plan9")
7677
if err == nil || !strings.Contains(err.Error(), "unsupported OS") {
7778
t.Errorf("expected unsupported OS error, got %v", err)
7879
}
7980
}
8081

8182
func TestServeEmbeddedHTML_ErrorOnMissingFile(t *testing.T) {
82-
h := &handler{log: &zerolog.Logger{}, spinner: ui.NewSpinner()}
83+
log := zerolog.Nop()
8384
w := httptest.NewRecorder()
84-
h.serveEmbeddedHTML(w, "htmlPages/doesnotexist.html", http.StatusOK)
85+
oauth.ServeEmbeddedHTML(&log, w, "htmlPages/doesnotexist.html", http.StatusOK)
8586
resp := w.Result()
8687
if resp.StatusCode != http.StatusInternalServerError {
8788
t.Errorf("expected 500 error, got %d", resp.StatusCode)
@@ -274,12 +275,11 @@ func TestCallbackHandler_GenericAuth0Error(t *testing.T) {
274275

275276
func TestServeWaitingPage(t *testing.T) {
276277
logger := zerolog.Nop()
277-
h := &handler{log: &logger, spinner: ui.NewSpinner()}
278278

279279
w := httptest.NewRecorder()
280280
redirectURL := "https://auth.example.com/authorize?client_id=test&state=abc123"
281281

282-
h.serveWaitingPage(w, redirectURL)
282+
oauth.ServeWaitingPage(&logger, w, redirectURL)
283283

284284
resp := w.Result()
285285
body, _ := io.ReadAll(resp.Body)

internal/oauth/browser.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package oauth
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
)
7+
8+
// OpenBrowser opens urlStr in the default browser for the given GOOS value.
9+
func OpenBrowser(urlStr string, goos string) error {
10+
switch goos {
11+
case "darwin":
12+
return exec.Command("open", urlStr).Start()
13+
case "linux":
14+
return exec.Command("xdg-open", urlStr).Start()
15+
case "windows":
16+
return exec.Command("rundll32", "url.dll,FileProtocolHandler", urlStr).Start()
17+
default:
18+
return fmt.Errorf("unsupported OS: %s", goos)
19+
}
20+
}

0 commit comments

Comments
 (0)