Skip to content

Commit e575d07

Browse files
Merge branch 'golang:master' into pr
2 parents bd60803 + 84cb9f7 commit e575d07

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+3483
-1619
lines changed

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ See pkg.go.dev for further documentation and examples.
1919
* [pkg.go.dev/golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2)
2020
* [pkg.go.dev/golang.org/x/oauth2/google](https://pkg.go.dev/golang.org/x/oauth2/google)
2121

22-
## Policy for new packages
22+
## Policy for new endpoints
2323

2424
We no longer accept new provider-specific packages in this repo if all
2525
they do is add a single endpoint variable. If you just want to add a
@@ -29,8 +29,12 @@ package.
2929

3030
## Report Issues / Send Patches
3131

32-
This repository uses Gerrit for code changes. To learn how to submit changes to
33-
this repository, see https://golang.org/doc/contribute.html.
34-
3532
The main issue tracker for the oauth2 repository is located at
3633
https://github.com/golang/oauth2/issues.
34+
35+
This repository uses Gerrit for code changes. To learn how to submit changes to
36+
this repository, see https://golang.org/doc/contribute.html. In particular:
37+
38+
* Excluding trivial changes, all contributions should be connected to an existing issue.
39+
* API changes must go through the [change proposal process](https://go.dev/s/proposal-process) before they can be accepted.
40+
* The code owners are listed at [dev.golang.org/owners](https://dev.golang.org/owners#:~:text=x/oauth2).

clientcredentials/clientcredentials.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ type Config struct {
4747
// client ID & client secret sent. The zero value means to
4848
// auto-detect.
4949
AuthStyle oauth2.AuthStyle
50+
51+
// authStyleCache caches which auth style to use when Endpoint.AuthStyle is
52+
// the zero value (AuthStyleAutoDetect).
53+
authStyleCache internal.LazyAuthStyleCache
5054
}
5155

5256
// Token uses client credentials to retrieve a token.
@@ -103,7 +107,7 @@ func (c *tokenSource) Token() (*oauth2.Token, error) {
103107
v[k] = p
104108
}
105109

106-
tk, err := internal.RetrieveToken(c.ctx, c.conf.ClientID, c.conf.ClientSecret, c.conf.TokenURL, v, internal.AuthStyle(c.conf.AuthStyle))
110+
tk, err := internal.RetrieveToken(c.ctx, c.conf.ClientID, c.conf.ClientSecret, c.conf.TokenURL, v, internal.AuthStyle(c.conf.AuthStyle), c.conf.authStyleCache.Get())
107111
if err != nil {
108112
if rErr, ok := err.(*internal.RetrieveError); ok {
109113
return nil, (*oauth2.RetrieveError)(rErr)

clientcredentials/clientcredentials_test.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ import (
1212
"net/http/httptest"
1313
"net/url"
1414
"testing"
15-
16-
"golang.org/x/oauth2/internal"
1715
)
1816

1917
func newConf(serverURL string) *Config {
@@ -114,7 +112,6 @@ func TestTokenRequest(t *testing.T) {
114112
}
115113

116114
func TestTokenRefreshRequest(t *testing.T) {
117-
internal.ResetAuthCache()
118115
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
119116
if r.URL.String() == "/somethingelse" {
120117
return

deviceauth.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package oauth2
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
"time"
13+
14+
"golang.org/x/oauth2/internal"
15+
)
16+
17+
// https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
18+
const (
19+
errAuthorizationPending = "authorization_pending"
20+
errSlowDown = "slow_down"
21+
errAccessDenied = "access_denied"
22+
errExpiredToken = "expired_token"
23+
)
24+
25+
// DeviceAuthResponse describes a successful RFC 8628 Device Authorization Response
26+
// https://datatracker.ietf.org/doc/html/rfc8628#section-3.2
27+
type DeviceAuthResponse struct {
28+
// DeviceCode
29+
DeviceCode string `json:"device_code"`
30+
// UserCode is the code the user should enter at the verification uri
31+
UserCode string `json:"user_code"`
32+
// VerificationURI is where user should enter the user code
33+
VerificationURI string `json:"verification_uri"`
34+
// VerificationURIComplete (if populated) includes the user code in the verification URI. This is typically shown to the user in non-textual form, such as a QR code.
35+
VerificationURIComplete string `json:"verification_uri_complete,omitempty"`
36+
// Expiry is when the device code and user code expire
37+
Expiry time.Time `json:"expires_in,omitempty"`
38+
// Interval is the duration in seconds that Poll should wait between requests
39+
Interval int64 `json:"interval,omitempty"`
40+
}
41+
42+
func (d DeviceAuthResponse) MarshalJSON() ([]byte, error) {
43+
type Alias DeviceAuthResponse
44+
var expiresIn int64
45+
if !d.Expiry.IsZero() {
46+
expiresIn = int64(time.Until(d.Expiry).Seconds())
47+
}
48+
return json.Marshal(&struct {
49+
ExpiresIn int64 `json:"expires_in,omitempty"`
50+
*Alias
51+
}{
52+
ExpiresIn: expiresIn,
53+
Alias: (*Alias)(&d),
54+
})
55+
56+
}
57+
58+
func (c *DeviceAuthResponse) UnmarshalJSON(data []byte) error {
59+
type Alias DeviceAuthResponse
60+
aux := &struct {
61+
ExpiresIn int64 `json:"expires_in"`
62+
// workaround misspelling of verification_uri
63+
VerificationURL string `json:"verification_url"`
64+
*Alias
65+
}{
66+
Alias: (*Alias)(c),
67+
}
68+
if err := json.Unmarshal(data, &aux); err != nil {
69+
return err
70+
}
71+
if aux.ExpiresIn != 0 {
72+
c.Expiry = time.Now().UTC().Add(time.Second * time.Duration(aux.ExpiresIn))
73+
}
74+
if c.VerificationURI == "" {
75+
c.VerificationURI = aux.VerificationURL
76+
}
77+
return nil
78+
}
79+
80+
// DeviceAuth returns a device auth struct which contains a device code
81+
// and authorization information provided for users to enter on another device.
82+
func (c *Config) DeviceAuth(ctx context.Context, opts ...AuthCodeOption) (*DeviceAuthResponse, error) {
83+
// https://datatracker.ietf.org/doc/html/rfc8628#section-3.1
84+
v := url.Values{
85+
"client_id": {c.ClientID},
86+
}
87+
if len(c.Scopes) > 0 {
88+
v.Set("scope", strings.Join(c.Scopes, " "))
89+
}
90+
for _, opt := range opts {
91+
opt.setValue(v)
92+
}
93+
return retrieveDeviceAuth(ctx, c, v)
94+
}
95+
96+
func retrieveDeviceAuth(ctx context.Context, c *Config, v url.Values) (*DeviceAuthResponse, error) {
97+
if c.Endpoint.DeviceAuthURL == "" {
98+
return nil, errors.New("endpoint missing DeviceAuthURL")
99+
}
100+
101+
req, err := http.NewRequest("POST", c.Endpoint.DeviceAuthURL, strings.NewReader(v.Encode()))
102+
if err != nil {
103+
return nil, err
104+
}
105+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
106+
req.Header.Set("Accept", "application/json")
107+
108+
t := time.Now()
109+
r, err := internal.ContextClient(ctx).Do(req)
110+
if err != nil {
111+
return nil, err
112+
}
113+
114+
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
115+
if err != nil {
116+
return nil, fmt.Errorf("oauth2: cannot auth device: %v", err)
117+
}
118+
if code := r.StatusCode; code < 200 || code > 299 {
119+
return nil, &RetrieveError{
120+
Response: r,
121+
Body: body,
122+
}
123+
}
124+
125+
da := &DeviceAuthResponse{}
126+
err = json.Unmarshal(body, &da)
127+
if err != nil {
128+
return nil, fmt.Errorf("unmarshal %s", err)
129+
}
130+
131+
if !da.Expiry.IsZero() {
132+
// Make a small adjustment to account for time taken by the request
133+
da.Expiry = da.Expiry.Add(-time.Since(t))
134+
}
135+
136+
return da, nil
137+
}
138+
139+
// DeviceAccessToken polls the server to exchange a device code for a token.
140+
func (c *Config) DeviceAccessToken(ctx context.Context, da *DeviceAuthResponse, opts ...AuthCodeOption) (*Token, error) {
141+
if !da.Expiry.IsZero() {
142+
var cancel context.CancelFunc
143+
ctx, cancel = context.WithDeadline(ctx, da.Expiry)
144+
defer cancel()
145+
}
146+
147+
// https://datatracker.ietf.org/doc/html/rfc8628#section-3.4
148+
v := url.Values{
149+
"client_id": {c.ClientID},
150+
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
151+
"device_code": {da.DeviceCode},
152+
}
153+
if len(c.Scopes) > 0 {
154+
v.Set("scope", strings.Join(c.Scopes, " "))
155+
}
156+
for _, opt := range opts {
157+
opt.setValue(v)
158+
}
159+
160+
// "If no value is provided, clients MUST use 5 as the default."
161+
// https://datatracker.ietf.org/doc/html/rfc8628#section-3.2
162+
interval := da.Interval
163+
if interval == 0 {
164+
interval = 5
165+
}
166+
167+
ticker := time.NewTicker(time.Duration(interval) * time.Second)
168+
defer ticker.Stop()
169+
for {
170+
select {
171+
case <-ctx.Done():
172+
return nil, ctx.Err()
173+
case <-ticker.C:
174+
tok, err := retrieveToken(ctx, c, v)
175+
if err == nil {
176+
return tok, nil
177+
}
178+
179+
e, ok := err.(*RetrieveError)
180+
if !ok {
181+
return nil, err
182+
}
183+
switch e.ErrorCode {
184+
case errSlowDown:
185+
// https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
186+
// "the interval MUST be increased by 5 seconds for this and all subsequent requests"
187+
interval += 5
188+
ticker.Reset(time.Duration(interval) * time.Second)
189+
case errAuthorizationPending:
190+
// Do nothing.
191+
case errAccessDenied, errExpiredToken:
192+
fallthrough
193+
default:
194+
return tok, err
195+
}
196+
}
197+
}
198+
}

deviceauth_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package oauth2
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/google/go-cmp/cmp"
12+
"github.com/google/go-cmp/cmp/cmpopts"
13+
)
14+
15+
func TestDeviceAuthResponseMarshalJson(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
response DeviceAuthResponse
19+
want string
20+
}{
21+
{
22+
name: "empty",
23+
response: DeviceAuthResponse{},
24+
want: `{"device_code":"","user_code":"","verification_uri":""}`,
25+
},
26+
{
27+
name: "soon",
28+
response: DeviceAuthResponse{
29+
Expiry: time.Now().Add(100*time.Second + 999*time.Millisecond),
30+
},
31+
want: `{"expires_in":100,"device_code":"","user_code":"","verification_uri":""}`,
32+
},
33+
}
34+
for _, tc := range tests {
35+
t.Run(tc.name, func(t *testing.T) {
36+
begin := time.Now()
37+
gotBytes, err := json.Marshal(tc.response)
38+
if err != nil {
39+
t.Fatal(err)
40+
}
41+
if strings.Contains(tc.want, "expires_in") && time.Since(begin) > 999*time.Millisecond {
42+
t.Skip("test ran too slowly to compare `expires_in`")
43+
}
44+
got := string(gotBytes)
45+
if got != tc.want {
46+
t.Errorf("want=%s, got=%s", tc.want, got)
47+
}
48+
})
49+
}
50+
}
51+
52+
func TestDeviceAuthResponseUnmarshalJson(t *testing.T) {
53+
tests := []struct {
54+
name string
55+
data string
56+
want DeviceAuthResponse
57+
}{
58+
{
59+
name: "empty",
60+
data: `{}`,
61+
want: DeviceAuthResponse{},
62+
},
63+
{
64+
name: "soon",
65+
data: `{"expires_in":100}`,
66+
want: DeviceAuthResponse{Expiry: time.Now().UTC().Add(100 * time.Second)},
67+
},
68+
}
69+
for _, tc := range tests {
70+
t.Run(tc.name, func(t *testing.T) {
71+
begin := time.Now()
72+
got := DeviceAuthResponse{}
73+
err := json.Unmarshal([]byte(tc.data), &got)
74+
if err != nil {
75+
t.Fatal(err)
76+
}
77+
if !cmp.Equal(got, tc.want, cmpopts.IgnoreUnexported(DeviceAuthResponse{}), cmpopts.EquateApproxTime(time.Second+time.Since(begin))) {
78+
t.Errorf("want=%#v, got=%#v", tc.want, got)
79+
}
80+
})
81+
}
82+
}
83+
84+
func ExampleConfig_DeviceAuth() {
85+
var config Config
86+
ctx := context.Background()
87+
response, err := config.DeviceAuth(ctx)
88+
if err != nil {
89+
panic(err)
90+
}
91+
fmt.Printf("please enter code %s at %s\n", response.UserCode, response.VerificationURI)
92+
token, err := config.DeviceAccessToken(ctx, response)
93+
if err != nil {
94+
panic(err)
95+
}
96+
fmt.Println(token)
97+
}

endpoints/endpoints.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@ var Fitbit = oauth2.Endpoint{
5555

5656
// GitHub is the endpoint for Github.
5757
var GitHub = oauth2.Endpoint{
58-
AuthURL: "https://github.com/login/oauth/authorize",
59-
TokenURL: "https://github.com/login/oauth/access_token",
58+
AuthURL: "https://github.com/login/oauth/authorize",
59+
TokenURL: "https://github.com/login/oauth/access_token",
60+
DeviceAuthURL: "https://github.com/login/device/code",
6061
}
6162

6263
// GitLab is the endpoint for GitLab.
@@ -67,8 +68,9 @@ var GitLab = oauth2.Endpoint{
6768

6869
// Google is the endpoint for Google.
6970
var Google = oauth2.Endpoint{
70-
AuthURL: "https://accounts.google.com/o/oauth2/auth",
71-
TokenURL: "https://oauth2.googleapis.com/token",
71+
AuthURL: "https://accounts.google.com/o/oauth2/auth",
72+
TokenURL: "https://oauth2.googleapis.com/token",
73+
DeviceAuthURL: "https://oauth2.googleapis.com/device/code",
7274
}
7375

7476
// Heroku is the endpoint for Heroku.
@@ -225,8 +227,9 @@ func AzureAD(tenant string) oauth2.Endpoint {
225227
tenant = "common"
226228
}
227229
return oauth2.Endpoint{
228-
AuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
229-
TokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
230+
AuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
231+
TokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
232+
DeviceAuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/devicecode",
230233
}
231234
}
232235

0 commit comments

Comments
 (0)