blob: 8389f2462949176657ad7aff7a9a732d2fc9940c [file] [log] [blame]
Zack Williamse940c7a2019-08-21 14:25:39 -07001// Copyright 2014 The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5package internal
6
7import (
8 "context"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
Zack Williamse940c7a2019-08-21 14:25:39 -070013 "math"
14 "mime"
15 "net/http"
16 "net/url"
17 "strconv"
18 "strings"
19 "sync"
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +053020 "sync/atomic"
Zack Williamse940c7a2019-08-21 14:25:39 -070021 "time"
Zack Williamse940c7a2019-08-21 14:25:39 -070022)
23
24// Token represents the credentials used to authorize
25// the requests to access protected resources on the OAuth 2.0
26// provider's backend.
27//
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +053028// This type is a mirror of [golang.org/x/oauth2.Token] and exists to break
Zack Williamse940c7a2019-08-21 14:25:39 -070029// an otherwise-circular dependency. Other internal packages
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +053030// should convert this Token into an [golang.org/x/oauth2.Token] before use.
Zack Williamse940c7a2019-08-21 14:25:39 -070031type Token struct {
32 // AccessToken is the token that authorizes and authenticates
33 // the requests.
34 AccessToken string
35
36 // TokenType is the type of token.
37 // The Type method returns either this or "Bearer", the default.
38 TokenType string
39
40 // RefreshToken is a token that's used by the application
41 // (as opposed to the user) to refresh the access token
42 // if it expires.
43 RefreshToken string
44
45 // Expiry is the optional expiration time of the access token.
46 //
47 // If zero, TokenSource implementations will reuse the same
48 // token forever and RefreshToken or equivalent
49 // mechanisms for that TokenSource will not be used.
50 Expiry time.Time
51
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +053052 // ExpiresIn is the OAuth2 wire format "expires_in" field,
53 // which specifies how many seconds later the token expires,
54 // relative to an unknown time base approximately around "now".
55 // It is the application's responsibility to populate
56 // `Expiry` from `ExpiresIn` when required.
57 ExpiresIn int64 `json:"expires_in,omitempty"`
58
Zack Williamse940c7a2019-08-21 14:25:39 -070059 // Raw optionally contains extra metadata from the server
60 // when updating a token.
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +053061 Raw any
Zack Williamse940c7a2019-08-21 14:25:39 -070062}
63
64// tokenJSON is the struct representing the HTTP response from OAuth2
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +053065// providers returning a token or error in JSON form.
66// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
Zack Williamse940c7a2019-08-21 14:25:39 -070067type tokenJSON struct {
68 AccessToken string `json:"access_token"`
69 TokenType string `json:"token_type"`
70 RefreshToken string `json:"refresh_token"`
71 ExpiresIn expirationTime `json:"expires_in"` // at least PayPal returns string, while most return number
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +053072 // error fields
73 // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
74 ErrorCode string `json:"error"`
75 ErrorDescription string `json:"error_description"`
76 ErrorURI string `json:"error_uri"`
Zack Williamse940c7a2019-08-21 14:25:39 -070077}
78
79func (e *tokenJSON) expiry() (t time.Time) {
80 if v := e.ExpiresIn; v != 0 {
81 return time.Now().Add(time.Duration(v) * time.Second)
82 }
83 return
84}
85
86type expirationTime int32
87
88func (e *expirationTime) UnmarshalJSON(b []byte) error {
89 if len(b) == 0 || string(b) == "null" {
90 return nil
91 }
92 var n json.Number
93 err := json.Unmarshal(b, &n)
94 if err != nil {
95 return err
96 }
97 i, err := n.Int64()
98 if err != nil {
99 return err
100 }
101 if i > math.MaxInt32 {
102 i = math.MaxInt32
103 }
104 *e = expirationTime(i)
105 return nil
106}
107
Zack Williamse940c7a2019-08-21 14:25:39 -0700108// AuthStyle is a copy of the golang.org/x/oauth2 package's AuthStyle type.
109type AuthStyle int
110
111const (
112 AuthStyleUnknown AuthStyle = 0
113 AuthStyleInParams AuthStyle = 1
114 AuthStyleInHeader AuthStyle = 2
115)
116
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530117// LazyAuthStyleCache is a backwards compatibility compromise to let Configs
118// have a lazily-initialized AuthStyleCache.
119//
120// The two users of this, oauth2.Config and oauth2/clientcredentials.Config,
121// both would ideally just embed an unexported AuthStyleCache but because both
122// were historically allowed to be copied by value we can't retroactively add an
123// uncopyable Mutex to them.
124//
125// We could use an atomic.Pointer, but that was added recently enough (in Go
126// 1.18) that we'd break Go 1.17 users where the tests as of 2023-08-03
127// still pass. By using an atomic.Value, it supports both Go 1.17 and
128// copying by value, even if that's not ideal.
129type LazyAuthStyleCache struct {
130 v atomic.Value // of *AuthStyleCache
131}
132
133func (lc *LazyAuthStyleCache) Get() *AuthStyleCache {
134 if c, ok := lc.v.Load().(*AuthStyleCache); ok {
135 return c
136 }
137 c := new(AuthStyleCache)
138 if !lc.v.CompareAndSwap(nil, c) {
139 c = lc.v.Load().(*AuthStyleCache)
140 }
141 return c
142}
143
144type authStyleCacheKey struct {
145 url string
146 clientID string
147}
148
149// AuthStyleCache is the set of tokenURLs we've successfully used via
Zack Williamse940c7a2019-08-21 14:25:39 -0700150// RetrieveToken and which style auth we ended up using.
151// It's called a cache, but it doesn't (yet?) shrink. It's expected that
152// the set of OAuth2 servers a program contacts over time is fixed and
153// small.
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530154type AuthStyleCache struct {
155 mu sync.Mutex
156 m map[authStyleCacheKey]AuthStyle
Zack Williamse940c7a2019-08-21 14:25:39 -0700157}
158
159// lookupAuthStyle reports which auth style we last used with tokenURL
160// when calling RetrieveToken and whether we have ever done so.
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530161func (c *AuthStyleCache) lookupAuthStyle(tokenURL, clientID string) (style AuthStyle, ok bool) {
162 c.mu.Lock()
163 defer c.mu.Unlock()
164 style, ok = c.m[authStyleCacheKey{tokenURL, clientID}]
Zack Williamse940c7a2019-08-21 14:25:39 -0700165 return
166}
167
168// setAuthStyle adds an entry to authStyleCache, documented above.
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530169func (c *AuthStyleCache) setAuthStyle(tokenURL, clientID string, v AuthStyle) {
170 c.mu.Lock()
171 defer c.mu.Unlock()
172 if c.m == nil {
173 c.m = make(map[authStyleCacheKey]AuthStyle)
Zack Williamse940c7a2019-08-21 14:25:39 -0700174 }
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530175 c.m[authStyleCacheKey{tokenURL, clientID}] = v
Zack Williamse940c7a2019-08-21 14:25:39 -0700176}
177
178// newTokenRequest returns a new *http.Request to retrieve a new token
179// from tokenURL using the provided clientID, clientSecret, and POST
180// body parameters.
181//
182// inParams is whether the clientID & clientSecret should be encoded
183// as the POST body. An 'inParams' value of true means to send it in
184// the POST body (along with any values in v); false means to send it
185// in the Authorization header.
186func newTokenRequest(tokenURL, clientID, clientSecret string, v url.Values, authStyle AuthStyle) (*http.Request, error) {
187 if authStyle == AuthStyleInParams {
188 v = cloneURLValues(v)
189 if clientID != "" {
190 v.Set("client_id", clientID)
191 }
192 if clientSecret != "" {
193 v.Set("client_secret", clientSecret)
194 }
195 }
196 req, err := http.NewRequest("POST", tokenURL, strings.NewReader(v.Encode()))
197 if err != nil {
198 return nil, err
199 }
200 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
201 if authStyle == AuthStyleInHeader {
202 req.SetBasicAuth(url.QueryEscape(clientID), url.QueryEscape(clientSecret))
203 }
204 return req, nil
205}
206
207func cloneURLValues(v url.Values) url.Values {
208 v2 := make(url.Values, len(v))
209 for k, vv := range v {
210 v2[k] = append([]string(nil), vv...)
211 }
212 return v2
213}
214
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530215func RetrieveToken(ctx context.Context, clientID, clientSecret, tokenURL string, v url.Values, authStyle AuthStyle, styleCache *AuthStyleCache) (*Token, error) {
216 needsAuthStyleProbe := authStyle == AuthStyleUnknown
Zack Williamse940c7a2019-08-21 14:25:39 -0700217 if needsAuthStyleProbe {
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530218 if style, ok := styleCache.lookupAuthStyle(tokenURL, clientID); ok {
Zack Williamse940c7a2019-08-21 14:25:39 -0700219 authStyle = style
220 needsAuthStyleProbe = false
221 } else {
222 authStyle = AuthStyleInHeader // the first way we'll try
223 }
224 }
225 req, err := newTokenRequest(tokenURL, clientID, clientSecret, v, authStyle)
226 if err != nil {
227 return nil, err
228 }
229 token, err := doTokenRoundTrip(ctx, req)
230 if err != nil && needsAuthStyleProbe {
231 // If we get an error, assume the server wants the
232 // clientID & clientSecret in a different form.
233 // See https://code.google.com/p/goauth2/issues/detail?id=31 for background.
234 // In summary:
235 // - Reddit only accepts client secret in the Authorization header
236 // - Dropbox accepts either it in URL param or Auth header, but not both.
237 // - Google only accepts URL param (not spec compliant?), not Auth header
238 // - Stripe only accepts client secret in Auth header with Bearer method, not Basic
239 //
240 // We used to maintain a big table in this code of all the sites and which way
241 // they went, but maintaining it didn't scale & got annoying.
242 // So just try both ways.
243 authStyle = AuthStyleInParams // the second way we'll try
244 req, _ = newTokenRequest(tokenURL, clientID, clientSecret, v, authStyle)
245 token, err = doTokenRoundTrip(ctx, req)
246 }
247 if needsAuthStyleProbe && err == nil {
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530248 styleCache.setAuthStyle(tokenURL, clientID, authStyle)
Zack Williamse940c7a2019-08-21 14:25:39 -0700249 }
250 // Don't overwrite `RefreshToken` with an empty value
251 // if this was a token refreshing request.
252 if token != nil && token.RefreshToken == "" {
253 token.RefreshToken = v.Get("refresh_token")
254 }
255 return token, err
256}
257
258func doTokenRoundTrip(ctx context.Context, req *http.Request) (*Token, error) {
Akash Reddy Kankanalac0014632025-05-21 17:12:20 +0530259 r, err := ContextClient(ctx).Do(req.WithContext(ctx))
Zack Williamse940c7a2019-08-21 14:25:39 -0700260 if err != nil {
261 return nil, err
262 }
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530263 body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
Zack Williamse940c7a2019-08-21 14:25:39 -0700264 r.Body.Close()
265 if err != nil {
266 return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err)
267 }
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530268
269 failureStatus := r.StatusCode < 200 || r.StatusCode > 299
270 retrieveError := &RetrieveError{
271 Response: r,
272 Body: body,
273 // attempt to populate error detail below
Zack Williamse940c7a2019-08-21 14:25:39 -0700274 }
275
276 var token *Token
277 content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
278 switch content {
279 case "application/x-www-form-urlencoded", "text/plain":
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530280 // some endpoints return a query string
Zack Williamse940c7a2019-08-21 14:25:39 -0700281 vals, err := url.ParseQuery(string(body))
282 if err != nil {
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530283 if failureStatus {
284 return nil, retrieveError
285 }
286 return nil, fmt.Errorf("oauth2: cannot parse response: %v", err)
Zack Williamse940c7a2019-08-21 14:25:39 -0700287 }
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530288 retrieveError.ErrorCode = vals.Get("error")
289 retrieveError.ErrorDescription = vals.Get("error_description")
290 retrieveError.ErrorURI = vals.Get("error_uri")
Zack Williamse940c7a2019-08-21 14:25:39 -0700291 token = &Token{
292 AccessToken: vals.Get("access_token"),
293 TokenType: vals.Get("token_type"),
294 RefreshToken: vals.Get("refresh_token"),
295 Raw: vals,
296 }
297 e := vals.Get("expires_in")
298 expires, _ := strconv.Atoi(e)
299 if expires != 0 {
300 token.Expiry = time.Now().Add(time.Duration(expires) * time.Second)
301 }
302 default:
303 var tj tokenJSON
304 if err = json.Unmarshal(body, &tj); err != nil {
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530305 if failureStatus {
306 return nil, retrieveError
307 }
308 return nil, fmt.Errorf("oauth2: cannot parse json: %v", err)
Zack Williamse940c7a2019-08-21 14:25:39 -0700309 }
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530310 retrieveError.ErrorCode = tj.ErrorCode
311 retrieveError.ErrorDescription = tj.ErrorDescription
312 retrieveError.ErrorURI = tj.ErrorURI
Zack Williamse940c7a2019-08-21 14:25:39 -0700313 token = &Token{
314 AccessToken: tj.AccessToken,
315 TokenType: tj.TokenType,
316 RefreshToken: tj.RefreshToken,
317 Expiry: tj.expiry(),
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530318 ExpiresIn: int64(tj.ExpiresIn),
319 Raw: make(map[string]any),
Zack Williamse940c7a2019-08-21 14:25:39 -0700320 }
321 json.Unmarshal(body, &token.Raw) // no error checks for optional fields
322 }
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530323 // according to spec, servers should respond status 400 in error case
324 // https://www.rfc-editor.org/rfc/rfc6749#section-5.2
325 // but some unorthodox servers respond 200 in error case
326 if failureStatus || retrieveError.ErrorCode != "" {
327 return nil, retrieveError
328 }
Zack Williamse940c7a2019-08-21 14:25:39 -0700329 if token.AccessToken == "" {
330 return nil, errors.New("oauth2: server response missing access_token")
331 }
332 return token, nil
333}
334
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530335// mirrors oauth2.RetrieveError
Zack Williamse940c7a2019-08-21 14:25:39 -0700336type RetrieveError struct {
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530337 Response *http.Response
338 Body []byte
339 ErrorCode string
340 ErrorDescription string
341 ErrorURI string
Zack Williamse940c7a2019-08-21 14:25:39 -0700342}
343
344func (r *RetrieveError) Error() string {
balaji.nagarajan8a2a7ee2026-06-19 22:31:13 +0530345 if r.ErrorCode != "" {
346 s := fmt.Sprintf("oauth2: %q", r.ErrorCode)
347 if r.ErrorDescription != "" {
348 s += fmt.Sprintf(" %q", r.ErrorDescription)
349 }
350 if r.ErrorURI != "" {
351 s += fmt.Sprintf(" %q", r.ErrorURI)
352 }
353 return s
354 }
Zack Williamse940c7a2019-08-21 14:25:39 -0700355 return fmt.Sprintf("oauth2: cannot fetch token: %v\nResponse: %s", r.Response.Status, r.Body)
356}