// Copyright 2014 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package internal import ( "encoding/json" "errors" "fmt" "io" "io/ioutil" "mime" "net/http" "net/url" "strconv" "strings" "time" "golang.org/x/net/context" "golang.org/x/net/context/ctxhttp" ) // Token represents the credentials used to authorize // the requests to access protected resources on the OAuth 2.0 // provider's backend. // // This type is a mirror of oauth2.Token and exists to break // an otherwise-circular dependency. Other internal packages // should convert this Token into an oauth2.Token before use. type Token struct { // AccessToken is the token that authorizes and authenticates // the requests. AccessToken string // TokenType is the type of token. // The Type method returns either this or "Bearer", the default. TokenType string // RefreshToken is a token that's used by the application // (as opposed to the user) to refresh the access token // if it expires. RefreshToken string // Expiry is the optional expiration time of the access token. // // If zero, TokenSource implementations will reuse the same // token forever and RefreshToken or equivalent // mechanisms for that TokenSource will not be used. Expiry time.Time // Raw optionally contains extra metadata from the server // when updating a token. Raw interface{} } // tokenJSON is the struct representing the HTTP response from OAuth2 // providers returning a token in JSON form. type tokenJSON struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` RefreshToken string `json:"refresh_token"` ExpiresIn expirationTime `json:"expires_in"` // at least PayPal returns string, while most return number Expires expirationTime `json:"expires"` // broken Facebook spelling of expires_in } func (e *tokenJSON) expiry() (t time.Time) { if v := e.ExpiresIn; v != 0 { return time.Now().Add(time.Duration(v) * time.Second) } if v := e.Expires; v != 0 { return time.Now().Add(time.Duration(v) * time.Second) } return } type expirationTime int32 func (e *expirationTime) UnmarshalJSON(b []byte) error { var n json.Number err := json.Unmarshal(b, &n) if err != nil { return err } i, err := n.Int64() if err != nil { return err } *e = expirationTime(i) return nil } var brokenAuthHeaderProviders = []string{ "https://accounts.google.com/", "https://api.codeswholesale.com/oauth/token", "https://api.dropbox.com/", "https://api.dropboxapi.com/", "https://api.instagram.com/", "https://api.netatmo.net/", "https://api.odnoklassniki.ru/", "https://api.pushbullet.com/", "https://api.soundcloud.com/", "https://api.twitch.tv/", "https://id.twitch.tv/", "https://app.box.com/", "https://connect.stripe.com/", "https://login.mailchimp.com/", "https://login.microsoftonline.com/", "https://login.salesforce.com/", "https://login.windows.net", "https://login.live.com/", "https://oauth.sandbox.trainingpeaks.com/", "https://oauth.trainingpeaks.com/", "https://oauth.vk.com/", "https://openapi.baidu.com/", "https://slack.com/", "https://test-sandbox.auth.corp.google.com", "https://test.salesforce.com/", "https://user.gini.net/", "https://www.douban.com/", "https://www.googleapis.com/", "https://www.linkedin.com/", "https://www.strava.com/oauth/", "https://www.wunderlist.com/oauth/", "https://api.patreon.com/", "https://sandbox.codeswholesale.com/oauth/token", "https://api.sipgate.com/v1/authorization/oauth", "https://api.medium.com/v1/tokens", "https://log.finalsurge.com/oauth/token", "https://multisport.todaysplan.com.au/rest/oauth/access_token", "https://whats.todaysplan.com.au/rest/oauth/access_token", } // brokenAuthHeaderDomains lists broken providers that issue dynamic endpoints. var brokenAuthHeaderDomains = []string{ ".auth0.com", ".force.com", ".myshopify.com", ".okta.com", ".oktapreview.com", } func RegisterBrokenAuthHeaderProvider(tokenURL string) { brokenAuthHeaderProviders = append(brokenAuthHeaderProviders, tokenURL) } // providerAuthHeaderWorks reports whether the OAuth2 server identified by the tokenURL // implements the OAuth2 spec correctly // See https://code.google.com/p/goauth2/issues/detail?id=31 for background. // In summary: // - Reddit only accepts client secret in the Authorization header // - Dropbox accepts either it in URL param or Auth header, but not both. // - Google only accepts URL param (not spec compliant?), not Auth header // - Stripe only accepts client secret in Auth header with Bearer method, not Basic func providerAuthHeaderWorks(tokenURL string) bool { for _, s := range brokenAuthHeaderProviders { if strings.HasPrefix(tokenURL, s) { // Some sites fail to implement the OAuth2 spec fully. return false } } if u, err := url.Parse(tokenURL); err == nil { for _, s := range brokenAuthHeaderDomains { if strings.HasSuffix(u.Host, s) { return false } } } // Assume the provider implements the spec properly // otherwise. We can add more exceptions as they're // discovered. We will _not_ be adding configurable hooks // to this package to let users select server bugs. return true } func RetrieveToken(ctx context.Context, clientID, clientSecret, tokenURL string, v url.Values) (*Token, error) { bustedAuth := !providerAuthHeaderWorks(tokenURL) if bustedAuth { if clientID != "" { v.Set("client_id", clientID) } if clientSecret != "" { v.Set("client_secret", clientSecret) } } req, err := http.NewRequest("POST", tokenURL, strings.NewReader(v.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if !bustedAuth { req.SetBasicAuth(url.QueryEscape(clientID), url.QueryEscape(clientSecret)) } r, err := ctxhttp.Do(ctx, ContextClient(ctx), req) if err != nil { return nil, err } defer r.Body.Close() body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) } if code := r.StatusCode; code < 200 || code > 299 { return nil, &RetrieveError{ Response: r, Body: body, } } var token *Token content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) switch content { case "application/x-www-form-urlencoded", "text/plain": vals, err := url.ParseQuery(string(body)) if err != nil { return nil, err } token = &Token{ AccessToken: vals.Get("access_token"), TokenType: vals.Get("token_type"), RefreshToken: vals.Get("refresh_token"), Raw: vals, } e := vals.Get("expires_in") if e == "" { // TODO(jbd): Facebook's OAuth2 implementation is broken and // returns expires_in field in expires. Remove the fallback to expires, // when Facebook fixes their implementation. e = vals.Get("expires") } expires, _ := strconv.Atoi(e) if expires != 0 { token.Expiry = time.Now().Add(time.Duration(expires) * time.Second) } default: var tj tokenJSON if err = json.Unmarshal(body, &tj); err != nil { return nil, err } token = &Token{ AccessToken: tj.AccessToken, TokenType: tj.TokenType, RefreshToken: tj.RefreshToken, Expiry: tj.expiry(), Raw: make(map[string]interface{}), } json.Unmarshal(body, &token.Raw) // no error checks for optional fields } // Don't overwrite `RefreshToken` with an empty value // if this was a token refreshing request. if token.RefreshToken == "" { token.RefreshToken = v.Get("refresh_token") } if token.AccessToken == "" { return token, errors.New("oauth2: server response missing access_token") } return token, nil } type RetrieveError struct { Response *http.Response Body []byte } func (r *RetrieveError) Error() string { return fmt.Sprintf("oauth2: cannot fetch token: %v\nResponse: %s", r.Response.Status, r.Body) }