From aed15fd74597d2b25aaf92d9c4def5e9832ad10f Mon Sep 17 00:00:00 2001 From: cmP Date: Mon, 7 Jan 2019 21:56:42 +0800 Subject: [PATCH 1/2] oauth2: add device flow support --- deviceauth.go | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++ oauth2.go | 63 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 deviceauth.go diff --git a/deviceauth.go b/deviceauth.go new file mode 100644 index 000000000..edca0b6d5 --- /dev/null +++ b/deviceauth.go @@ -0,0 +1,79 @@ +package oauth2 + +import ( + "context" + "encoding/json" + "fmt" + "golang.org/x/net/context/ctxhttp" + "io" + "io/ioutil" + "net/http" + "net/url" + "strings" +) + +const ( + errAuthorizationPending = "authorization_pending" + errSlowDown = "slow_down" + errAccessDenied = "access_denied" + errExpiredToken = "expired_token" +) + +type DeviceAuth struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri,verification_url"` + VerificationURIComplete string `json:"verification_uri_complete,omitempty"` + ExpiresIn int `json:"expires_in,string"` + Interval int `json:"interval,string,omitempty"` + raw map[string]interface{} +} + +func retrieveDeviceAuth(ctx context.Context, c *Config, v url.Values) (*DeviceAuth, error) { + req, err := http.NewRequest("POST", c.Endpoint.DeviceAuthURL, strings.NewReader(v.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + r, err := ctxhttp.Do(ctx, nil, req) + if err != nil { + return nil, err + } + + body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("oauth2: cannot auth device: %v", err) + } + if code := r.StatusCode; code < 200 || code > 299 { + return nil, &RetrieveError{ + Response: r, + Body: body, + } + } + + var da = &DeviceAuth{} + err = json.Unmarshal(body, &da) + if err != nil { + return nil, err + } + + _ = json.Unmarshal(body, &da.raw) + + // Azure AD supplies verification_url instead of verification_uri + if da.VerificationURI == "" { + da.VerificationURI, _ = da.raw["verification_url"].(string) + } + + return da, nil +} + +func parseError(err error) string { + e, ok := err.(*RetrieveError) + if ok { + eResp := make(map[string]string) + _ = json.Unmarshal(e.Body, &eResp) + return eResp["error"] + } + return "" +} diff --git a/oauth2.go b/oauth2.go index 291df5c83..b0e19cefd 100644 --- a/oauth2.go +++ b/oauth2.go @@ -16,6 +16,7 @@ import ( "net/url" "strings" "sync" + "time" "golang.org/x/oauth2/internal" ) @@ -70,8 +71,9 @@ type TokenSource interface { // Endpoint represents an OAuth 2.0 provider's authorization and token // endpoint URLs. type Endpoint struct { - AuthURL string - TokenURL string + AuthURL string + DeviceAuthURL string + TokenURL string // AuthStyle optionally specifies how the endpoint wants the // client ID & client secret sent. The zero value means to @@ -224,6 +226,63 @@ func (c *Config) Exchange(ctx context.Context, code string, opts ...AuthCodeOpti return retrieveToken(ctx, c, v) } +// AuthDevice returns a device auth struct which contains a device code +// and authorization information provided for users to enter on another device. +func (c *Config) AuthDevice(ctx context.Context, opts ...AuthCodeOption) (*DeviceAuth, error) { + v := url.Values{ + "client_id": {c.ClientID}, + } + if len(c.Scopes) > 0 { + v.Set("scope", strings.Join(c.Scopes, " ")) + } + for _, opt := range opts { + opt.setValue(v) + } + return retrieveDeviceAuth(ctx, c, v) +} + +// Poll does a polling to exchange an device code for a token. +func (c *Config) Poll(ctx context.Context, da *DeviceAuth, opts ...AuthCodeOption) (*Token, error) { + v := url.Values{ + "client_id": {c.ClientID}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {da.DeviceCode}, + "code": {da.DeviceCode}, + } + if len(c.Scopes) > 0 { + v.Set("scope", strings.Join(c.Scopes, " ")) + } + for _, opt := range opts { + opt.setValue(v) + } + + // If no interval was provided, the client MUST use a reasonable default polling interval. + // See https://tools.ietf.org/html/draft-ietf-oauth-device-flow-07#section-3.5 + interval := da.Interval + if interval == 0 { + interval = 5 + } + + for { + time.Sleep(time.Duration(interval) * time.Second) + + tok, err := retrieveToken(ctx, c, v) + if err == nil { + return tok, nil + } + + errTyp := parseError(err) + switch errTyp { + case errAccessDenied, errExpiredToken: + return tok, errors.New("oauth2: " + errTyp) + case errSlowDown: + interval += 5 + fallthrough + case errAuthorizationPending: + } + } +} + // Client returns an HTTP client using the provided token. // The token will auto-refresh as necessary. The underlying // HTTP transport will be obtained using the provided context. From 4df03c74c02367a301c154a2f1efdb1127729a59 Mon Sep 17 00:00:00 2001 From: Marcus Pettersen Irgens Date: Wed, 16 Feb 2022 23:22:10 +0100 Subject: [PATCH 2/2] oauth2: Comply with RFC8628 Fixes various small errors, possibly due to changes betweend the device flow draft and the final RFC. Also includes a fix to avoid being stuck in an endless poll when the token endpoint does not return a well-formed, expected error. The following is the example response payload in RFC8628: HTTP/1.1 200 OK Content-Type: application/json Cache-Control: no-store { "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", "user_code": "WDJB-MJHT", "verification_uri": "https://example.com/device", "verification_uri_complete": "https://example.com/device?user_code=WDJB-MJHT", "expires_in": 1800, "interval": 5 } Using `json:"expires_in,string"` causes issues when an unquoted int is encountered. --- deviceauth.go | 7 ++++--- oauth2.go | 11 +++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/deviceauth.go b/deviceauth.go index edca0b6d5..fe8a47103 100644 --- a/deviceauth.go +++ b/deviceauth.go @@ -4,12 +4,13 @@ import ( "context" "encoding/json" "fmt" - "golang.org/x/net/context/ctxhttp" "io" "io/ioutil" "net/http" "net/url" "strings" + + "golang.org/x/net/context/ctxhttp" ) const ( @@ -24,8 +25,8 @@ type DeviceAuth struct { UserCode string `json:"user_code"` VerificationURI string `json:"verification_uri,verification_url"` VerificationURIComplete string `json:"verification_uri_complete,omitempty"` - ExpiresIn int `json:"expires_in,string"` - Interval int `json:"interval,string,omitempty"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval,omitempty"` raw map[string]interface{} } diff --git a/oauth2.go b/oauth2.go index b0e19cefd..7f4194d9e 100644 --- a/oauth2.go +++ b/oauth2.go @@ -241,23 +241,19 @@ func (c *Config) AuthDevice(ctx context.Context, opts ...AuthCodeOption) (*Devic return retrieveDeviceAuth(ctx, c, v) } -// Poll does a polling to exchange an device code for a token. +// Poll for a token using the device authorization flow. func (c *Config) Poll(ctx context.Context, da *DeviceAuth, opts ...AuthCodeOption) (*Token, error) { v := url.Values{ "client_id": {c.ClientID}, "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, "device_code": {da.DeviceCode}, - "code": {da.DeviceCode}, - } - if len(c.Scopes) > 0 { - v.Set("scope", strings.Join(c.Scopes, " ")) } for _, opt := range opts { opt.setValue(v) } // If no interval was provided, the client MUST use a reasonable default polling interval. - // See https://tools.ietf.org/html/draft-ietf-oauth-device-flow-07#section-3.5 + // See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 interval := da.Interval if interval == 0 { interval = 5 @@ -279,6 +275,9 @@ func (c *Config) Poll(ctx context.Context, da *DeviceAuth, opts ...AuthCodeOptio interval += 5 fallthrough case errAuthorizationPending: + // nop + default: + return nil, err } } }