Skip to content

Commit 0102dad

Browse files
authored
[auth] Implement device flow auth (#1114)
## Summary This adds a new hidden top level command `devbox auth`. It adds a few other commands: ``` devbox auth login devbox auth logout devbox auth refresh devbox auth whoami ``` This infrastructure will be used to power `devbox global push/pull`. It is customizable so that a user could specify own auth0 tenant. ## How was it tested? Changed refresh token expiration to be 5 seconds so I could test out auto-refresh. ``` devbox auth login devbox auth logout devbox auth refresh devbox auth whoami ```
1 parent b7cfbec commit 0102dad

File tree

8 files changed

+595
-1
lines changed

8 files changed

+595
-1
lines changed

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
cuelang.org/go v0.4.3
77
github.com/AlecAivazis/survey/v2 v2.3.6
88
github.com/MakeNowJust/heredoc/v2 v2.0.1
9+
github.com/MicahParks/keyfunc/v2 v2.1.0
910
github.com/a8m/envsubst v1.4.2
1011
github.com/alessio/shellescape v1.4.1
1112
github.com/bmatcuk/doublestar/v4 v4.6.0
@@ -17,12 +18,14 @@ require (
1718
github.com/fatih/color v1.15.0
1819
github.com/fsnotify/fsnotify v1.6.0
1920
github.com/getsentry/sentry-go v0.20.0
21+
github.com/golang-jwt/jwt/v5 v5.0.0
2022
github.com/google/go-cmp v0.5.9
2123
github.com/google/uuid v1.3.0
2224
github.com/hashicorp/go-envparse v0.1.0
2325
github.com/mattn/go-isatty v0.0.18
2426
github.com/mholt/archiver/v4 v4.0.0-alpha.7
2527
github.com/pelletier/go-toml/v2 v2.0.7
28+
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
2629
github.com/pkg/errors v0.9.1
2730
github.com/rogpeppe/go-internal v1.10.0
2831
github.com/samber/lo v1.38.1
@@ -36,6 +39,8 @@ require (
3639
gopkg.in/yaml.v3 v3.0.1
3740
)
3841

42+
require github.com/lib/pq v1.10.7 // indirect
43+
3944
require (
4045
github.com/InVisionApp/go-health/v2 v2.1.3 // indirect
4146
github.com/InVisionApp/go-logger v1.0.1 // indirect

go.sum

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ github.com/InVisionApp/go-logger v1.0.1 h1:WFL19PViM1mHUmUWfsv5zMo379KSWj2MRmBlz
1010
github.com/InVisionApp/go-logger v1.0.1/go.mod h1:+cGTDSn+P8105aZkeOfIhdd7vFO5X1afUHcjvanY0L8=
1111
github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A=
1212
github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM=
13+
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
14+
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
1315
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
1416
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
1517
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
@@ -68,6 +70,8 @@ github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxI
6870
github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
6971
github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
7072
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
73+
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
74+
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
7175
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
7276
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
7377
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
@@ -97,7 +101,8 @@ github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQ
97101
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
98102
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
99103
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
100-
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
104+
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
105+
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
101106
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
102107
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
103108
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -125,6 +130,8 @@ github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha
125130
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
126131
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
127132
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
133+
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
134+
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
128135
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
129136
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
130137
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -185,6 +192,7 @@ golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5h
185192
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
186193
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
187194
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
195+
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
188196
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
189197
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
190198
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

internal/auth/auth.go

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
// Copyright 2023 Jetpack Technologies Inc and contributors. All rights reserved.
2+
// Use of this source code is governed by the license in the LICENSE file.
3+
4+
package auth
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"net/url"
13+
"path/filepath"
14+
"strings"
15+
"time"
16+
17+
"github.com/pkg/browser"
18+
"github.com/pkg/errors"
19+
"go.jetpack.io/devbox/internal/boxcli/usererr"
20+
"go.jetpack.io/devbox/internal/xdg"
21+
)
22+
23+
const additionalSleepOnSlowDown = 1
24+
25+
type codeResponse struct {
26+
DeviceCode string `json:"device_code"`
27+
UserCode string `json:"user_code"`
28+
VerificationURI string `json:"verification_uri"`
29+
VerificationURIComplete string `json:"verification_uri_complete"`
30+
ExpiresIn int `json:"expires_in"`
31+
Interval int `json:"interval"`
32+
}
33+
34+
type tokenSet struct {
35+
AccessToken string `json:"access_token"`
36+
RefreshToken string `json:"refresh_token"`
37+
IDToken string `json:"id_token"`
38+
TokenType string `json:"token_type"`
39+
ExpiresIn int `json:"expires_in"`
40+
}
41+
42+
// used for both requestToken and refreshToken functions
43+
type requestTokenError struct {
44+
Error string `json:"error"`
45+
ErrorDescription string `json:"error_description"`
46+
}
47+
48+
// showVerificationURL presents a device flow verification URL to the user,
49+
// either by printing it to stdout or opening a web browser.
50+
func (a *Authenticator) showVerificationURL(url string, w io.Writer) {
51+
err := browser.OpenURL(url)
52+
if err == nil {
53+
fmt.Fprintf(w, "Opening your browser to complete the login. "+
54+
"If your browser didn't open, you can go to this URL "+
55+
"and confirm your code manually:\n%s\n\n", url)
56+
return
57+
}
58+
fmt.Fprintf(
59+
w,
60+
"Please go to this URL to confirm this code and login: %s\n\n", url)
61+
}
62+
63+
// requestDeviceCode requests a device code that the user can use to
64+
// authorize the device.
65+
func (a *Authenticator) requestDeviceCode() (*codeResponse, error) {
66+
reqURL := fmt.Sprintf("https://%s/oauth/device/code", a.Domain)
67+
payload := strings.NewReader(fmt.Sprintf(
68+
"client_id=%s&scope=%s&audience=%s",
69+
a.ClientID,
70+
url.QueryEscape(a.Scope),
71+
a.Audience,
72+
))
73+
74+
req, err := http.NewRequest(http.MethodPost, reqURL, payload)
75+
if err != nil {
76+
bytesPayload, _ := io.ReadAll(payload)
77+
return nil, errors.Wrapf(
78+
err,
79+
"failed to send request to URL: %s with payload: %s",
80+
reqURL,
81+
string(bytesPayload),
82+
)
83+
}
84+
85+
req.Header.Add("content-type", "application/x-www-form-urlencoded")
86+
87+
res, err := http.DefaultClient.Do(req)
88+
if err != nil {
89+
return nil, errors.Wrap(err, "failed to send Request")
90+
}
91+
92+
defer res.Body.Close()
93+
body, err := io.ReadAll(res.Body)
94+
if err != nil {
95+
return nil, errors.Wrap(err, "failed to read response body")
96+
}
97+
98+
if res.StatusCode != http.StatusOK {
99+
return nil, errors.Errorf(
100+
"got status code: %d, with body %s",
101+
res.StatusCode,
102+
string(body),
103+
)
104+
}
105+
106+
response := codeResponse{}
107+
return &response, json.Unmarshal(body, &response)
108+
}
109+
110+
// requestTokens polls the Auth0 API for tokens.
111+
func (a *Authenticator) requestTokens(
112+
ctx context.Context,
113+
codeResponse *codeResponse,
114+
) (*tokenSet, error) {
115+
116+
timeToSleep := codeResponse.Interval
117+
ticker := time.NewTicker(time.Duration(timeToSleep) * time.Second)
118+
defer ticker.Stop()
119+
120+
// numTries is a counter to guard against infinite looping.
121+
// In the normal course:
122+
// Status Code 200 OK: we early return within loop
123+
// Known Error scenarios: we continue looping and requesting Auth0 API.
124+
// These are not "errors" so much as "user hasn't yet completed
125+
// browser login flow"
126+
// Unknown Error scenarios: we early return within loop
127+
128+
for numTries := 0; numTries < 100; numTries++ {
129+
select {
130+
case <-ctx.Done():
131+
return nil, errors.WithStack(ctx.Err())
132+
133+
case <-ticker.C:
134+
res, err := a.tryRequestToken(codeResponse)
135+
if err != nil {
136+
return nil, errors.WithStack(err)
137+
}
138+
139+
defer res.Body.Close()
140+
body, err := io.ReadAll(res.Body)
141+
if err != nil {
142+
return nil, errors.WithStack(err)
143+
}
144+
145+
// Handle success
146+
if res.StatusCode == http.StatusOK {
147+
tokens := tokenSet{}
148+
return &tokens, json.Unmarshal(body, &tokens)
149+
}
150+
151+
// Handle failure scenarios
152+
moreSleep, err := handleFailure(body, res.StatusCode)
153+
if err != nil {
154+
return nil, errors.WithStack(err)
155+
}
156+
timeToSleep += moreSleep
157+
ticker.Reset(time.Duration(timeToSleep) * time.Second)
158+
}
159+
}
160+
161+
return nil, usererr.New("max number of tries exceeded")
162+
}
163+
164+
func (a *Authenticator) doRefreshToken(
165+
refreshToken string,
166+
) (*tokenSet, error) {
167+
168+
reqURL := fmt.Sprintf("https://%s/oauth/token", a.Domain)
169+
170+
payload := fmt.Sprintf(
171+
"grant_type=refresh_token&client_id=%s&refresh_token=%s",
172+
a.ClientID,
173+
refreshToken,
174+
)
175+
payloadReader := strings.NewReader(payload)
176+
177+
req, err := http.NewRequest(http.MethodPost, reqURL, payloadReader)
178+
if err != nil {
179+
return nil, errors.Wrapf(
180+
err,
181+
"failed to create request to URL: %s, with payload: %s",
182+
reqURL,
183+
payload,
184+
)
185+
}
186+
187+
req.Header.Add("content-type", "application/x-www-form-urlencoded")
188+
189+
res, err := http.DefaultClient.Do(req)
190+
if err != nil {
191+
return nil, errors.Wrapf(
192+
err,
193+
"failed POST request to reqURL: %s, payload: %s ",
194+
reqURL,
195+
payload,
196+
)
197+
}
198+
199+
defer res.Body.Close()
200+
body, err := io.ReadAll(res.Body)
201+
if err != nil {
202+
return nil, errors.Wrap(err, "failed to read response body")
203+
}
204+
205+
if res.StatusCode == http.StatusOK {
206+
tokens := &tokenSet{}
207+
return tokens, json.Unmarshal(body, tokens)
208+
}
209+
210+
tokenErrorBody := requestTokenError{}
211+
if err := json.Unmarshal(body, &tokenErrorBody); err != nil {
212+
return nil, errors.Wrapf(
213+
err,
214+
"unable to unmarshal requestTokenError from body %s",
215+
body,
216+
)
217+
}
218+
return nil, errors.Errorf(
219+
"refreshing access token returned an error (%s) with description: %s",
220+
tokenErrorBody.Error,
221+
tokenErrorBody.ErrorDescription,
222+
)
223+
}
224+
225+
func (a *Authenticator) tryRequestToken(
226+
codeResponse *codeResponse,
227+
) (*http.Response, error) {
228+
reqURL := fmt.Sprintf("https://%s/oauth/token", a.Domain)
229+
230+
grantType := "urn:ietf:params:oauth:grant-type:device_code"
231+
payload := strings.NewReader(fmt.Sprintf(
232+
"grant_type=%s&device_code=%s&client_id=%s",
233+
url.QueryEscape(grantType),
234+
codeResponse.DeviceCode,
235+
a.ClientID,
236+
))
237+
238+
req, err := http.NewRequest(http.MethodPost, reqURL, payload)
239+
if err != nil {
240+
return nil, errors.WithStack(err)
241+
}
242+
243+
req.Header.Add("content-type", "application/x-www-form-urlencoded")
244+
245+
return http.DefaultClient.Do(req)
246+
}
247+
248+
// handleFailure handles the failure scenarios for requestTokens.
249+
func handleFailure(body []byte, code int) (int, error) {
250+
tokenErrorBody := requestTokenError{}
251+
if err := json.Unmarshal(body, &tokenErrorBody); err != nil {
252+
return 0, errors.WithStack(err)
253+
}
254+
255+
if code == http.StatusTooManyRequests {
256+
if tokenErrorBody.Error != "slow_down" {
257+
return 0, errors.Errorf(
258+
"got status code: %d, response body: %s",
259+
code,
260+
body,
261+
)
262+
}
263+
264+
return additionalSleepOnSlowDown, nil
265+
266+
} else if code == http.StatusForbidden {
267+
268+
// this error is received when waiting for user to take action
269+
// when they are logging in via browser. Continue polling.
270+
if tokenErrorBody.Error != "authorization_pending" {
271+
return 0, errors.Errorf(
272+
"got status code: %d, response body: %s",
273+
code,
274+
body,
275+
)
276+
}
277+
278+
return 0, nil // No slowdown, just keep trying
279+
}
280+
// The user has not authorized the device quickly enough, so
281+
// the `device_code` has expired. Notify the user that the
282+
// flow has expired and prompt them to re-initiate the flow.
283+
// The "expired_token" is returned exactly once. After that,
284+
// the dreaded "invalid_grant" will be returned and device
285+
// must stop polling.
286+
if tokenErrorBody.Error == "expired_token" || tokenErrorBody.Error == "invalid_grant" {
287+
return 0, usererr.New(
288+
"The device code has expired. Please try `devbox auth login` again.")
289+
}
290+
291+
// "access_denied" can be received for:
292+
// 1. user refused to authorize the device.
293+
// 2. Auth server denied the transaction.
294+
// 3. A configured Auth0 "rule" denied access
295+
if tokenErrorBody.Error == "access_denied" {
296+
return 0, usererr.New("Access was denied")
297+
}
298+
299+
// Unknown error
300+
return 0, usererr.New("Unable to login")
301+
}
302+
303+
func getAuthFilePath() string {
304+
return xdg.StateSubpath(filepath.FromSlash("devbox/auth.json"))
305+
}

0 commit comments

Comments
 (0)