token.go

  1// 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	"encoding/json"
  9	"errors"
 10	"fmt"
 11	"io"
 12	"io/ioutil"
 13	"mime"
 14	"net/http"
 15	"net/url"
 16	"strconv"
 17	"strings"
 18	"time"
 19
 20	"golang.org/x/net/context"
 21	"golang.org/x/net/context/ctxhttp"
 22)
 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//
 28// This type is a mirror of oauth2.Token and exists to break
 29// an otherwise-circular dependency. Other internal packages
 30// should convert this Token into an oauth2.Token before use.
 31type 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
 52	// Raw optionally contains extra metadata from the server
 53	// when updating a token.
 54	Raw interface{}
 55}
 56
 57// tokenJSON is the struct representing the HTTP response from OAuth2
 58// providers returning a token in JSON form.
 59type tokenJSON struct {
 60	AccessToken  string         `json:"access_token"`
 61	TokenType    string         `json:"token_type"`
 62	RefreshToken string         `json:"refresh_token"`
 63	ExpiresIn    expirationTime `json:"expires_in"` // at least PayPal returns string, while most return number
 64	Expires      expirationTime `json:"expires"`    // broken Facebook spelling of expires_in
 65}
 66
 67func (e *tokenJSON) expiry() (t time.Time) {
 68	if v := e.ExpiresIn; v != 0 {
 69		return time.Now().Add(time.Duration(v) * time.Second)
 70	}
 71	if v := e.Expires; v != 0 {
 72		return time.Now().Add(time.Duration(v) * time.Second)
 73	}
 74	return
 75}
 76
 77type expirationTime int32
 78
 79func (e *expirationTime) UnmarshalJSON(b []byte) error {
 80	var n json.Number
 81	err := json.Unmarshal(b, &n)
 82	if err != nil {
 83		return err
 84	}
 85	i, err := n.Int64()
 86	if err != nil {
 87		return err
 88	}
 89	*e = expirationTime(i)
 90	return nil
 91}
 92
 93var brokenAuthHeaderProviders = []string{
 94	"https://accounts.google.com/",
 95	"https://api.codeswholesale.com/oauth/token",
 96	"https://api.dropbox.com/",
 97	"https://api.dropboxapi.com/",
 98	"https://api.instagram.com/",
 99	"https://api.netatmo.net/",
100	"https://api.odnoklassniki.ru/",
101	"https://api.pushbullet.com/",
102	"https://api.soundcloud.com/",
103	"https://api.twitch.tv/",
104	"https://id.twitch.tv/",
105	"https://app.box.com/",
106	"https://api.box.com/",
107	"https://connect.stripe.com/",
108	"https://login.mailchimp.com/",
109	"https://login.microsoftonline.com/",
110	"https://login.salesforce.com/",
111	"https://login.windows.net",
112	"https://login.live.com/",
113	"https://oauth.sandbox.trainingpeaks.com/",
114	"https://oauth.trainingpeaks.com/",
115	"https://oauth.vk.com/",
116	"https://openapi.baidu.com/",
117	"https://slack.com/",
118	"https://test-sandbox.auth.corp.google.com",
119	"https://test.salesforce.com/",
120	"https://user.gini.net/",
121	"https://www.douban.com/",
122	"https://www.googleapis.com/",
123	"https://www.linkedin.com/",
124	"https://www.strava.com/oauth/",
125	"https://www.wunderlist.com/oauth/",
126	"https://api.patreon.com/",
127	"https://sandbox.codeswholesale.com/oauth/token",
128	"https://api.sipgate.com/v1/authorization/oauth",
129	"https://api.medium.com/v1/tokens",
130	"https://log.finalsurge.com/oauth/token",
131	"https://multisport.todaysplan.com.au/rest/oauth/access_token",
132	"https://whats.todaysplan.com.au/rest/oauth/access_token",
133	"https://stackoverflow.com/oauth/access_token",
134	"https://account.health.nokia.com",
135}
136
137// brokenAuthHeaderDomains lists broken providers that issue dynamic endpoints.
138var brokenAuthHeaderDomains = []string{
139	".auth0.com",
140	".force.com",
141	".myshopify.com",
142	".okta.com",
143	".oktapreview.com",
144}
145
146func RegisterBrokenAuthHeaderProvider(tokenURL string) {
147	brokenAuthHeaderProviders = append(brokenAuthHeaderProviders, tokenURL)
148}
149
150// providerAuthHeaderWorks reports whether the OAuth2 server identified by the tokenURL
151// implements the OAuth2 spec correctly
152// See https://code.google.com/p/goauth2/issues/detail?id=31 for background.
153// In summary:
154// - Reddit only accepts client secret in the Authorization header
155// - Dropbox accepts either it in URL param or Auth header, but not both.
156// - Google only accepts URL param (not spec compliant?), not Auth header
157// - Stripe only accepts client secret in Auth header with Bearer method, not Basic
158func providerAuthHeaderWorks(tokenURL string) bool {
159	for _, s := range brokenAuthHeaderProviders {
160		if strings.HasPrefix(tokenURL, s) {
161			// Some sites fail to implement the OAuth2 spec fully.
162			return false
163		}
164	}
165
166	if u, err := url.Parse(tokenURL); err == nil {
167		for _, s := range brokenAuthHeaderDomains {
168			if strings.HasSuffix(u.Host, s) {
169				return false
170			}
171		}
172	}
173
174	// Assume the provider implements the spec properly
175	// otherwise. We can add more exceptions as they're
176	// discovered. We will _not_ be adding configurable hooks
177	// to this package to let users select server bugs.
178	return true
179}
180
181func RetrieveToken(ctx context.Context, clientID, clientSecret, tokenURL string, v url.Values) (*Token, error) {
182	bustedAuth := !providerAuthHeaderWorks(tokenURL)
183	if bustedAuth {
184		if clientID != "" {
185			v.Set("client_id", clientID)
186		}
187		if clientSecret != "" {
188			v.Set("client_secret", clientSecret)
189		}
190	}
191	req, err := http.NewRequest("POST", tokenURL, strings.NewReader(v.Encode()))
192	if err != nil {
193		return nil, err
194	}
195	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
196	if !bustedAuth {
197		req.SetBasicAuth(url.QueryEscape(clientID), url.QueryEscape(clientSecret))
198	}
199	r, err := ctxhttp.Do(ctx, ContextClient(ctx), req)
200	if err != nil {
201		return nil, err
202	}
203	defer r.Body.Close()
204	body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1<<20))
205	if err != nil {
206		return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err)
207	}
208	if code := r.StatusCode; code < 200 || code > 299 {
209		return nil, &RetrieveError{
210			Response: r,
211			Body:     body,
212		}
213	}
214
215	var token *Token
216	content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
217	switch content {
218	case "application/x-www-form-urlencoded", "text/plain":
219		vals, err := url.ParseQuery(string(body))
220		if err != nil {
221			return nil, err
222		}
223		token = &Token{
224			AccessToken:  vals.Get("access_token"),
225			TokenType:    vals.Get("token_type"),
226			RefreshToken: vals.Get("refresh_token"),
227			Raw:          vals,
228		}
229		e := vals.Get("expires_in")
230		if e == "" {
231			// TODO(jbd): Facebook's OAuth2 implementation is broken and
232			// returns expires_in field in expires. Remove the fallback to expires,
233			// when Facebook fixes their implementation.
234			e = vals.Get("expires")
235		}
236		expires, _ := strconv.Atoi(e)
237		if expires != 0 {
238			token.Expiry = time.Now().Add(time.Duration(expires) * time.Second)
239		}
240	default:
241		var tj tokenJSON
242		if err = json.Unmarshal(body, &tj); err != nil {
243			return nil, err
244		}
245		token = &Token{
246			AccessToken:  tj.AccessToken,
247			TokenType:    tj.TokenType,
248			RefreshToken: tj.RefreshToken,
249			Expiry:       tj.expiry(),
250			Raw:          make(map[string]interface{}),
251		}
252		json.Unmarshal(body, &token.Raw) // no error checks for optional fields
253	}
254	// Don't overwrite `RefreshToken` with an empty value
255	// if this was a token refreshing request.
256	if token.RefreshToken == "" {
257		token.RefreshToken = v.Get("refresh_token")
258	}
259	if token.AccessToken == "" {
260		return token, errors.New("oauth2: server response missing access_token")
261	}
262	return token, nil
263}
264
265type RetrieveError struct {
266	Response *http.Response
267	Body     []byte
268}
269
270func (r *RetrieveError) Error() string {
271	return fmt.Sprintf("oauth2: cannot fetch token: %v\nResponse: %s", r.Response.Status, r.Body)
272}