Files
oauth2-old/oauth2.go
Arnaud Ysmal e6396819be Add Dropbox to the providers not supporting Auth headers.
Dropbox does not support mixing URL param and Auth header for authentication but the retrieveToken method forces the client_id to be sent in the URL param.
2014-09-07 19:05:03 +02:00

304 lines
9.7 KiB
Go

// Copyright 2014 The oauth2 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 oauth2 provides support for making
// OAuth2 authorized and authenticated HTTP requests.
// It can additionally grant authorization with Bearer JWT.
package oauth2
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type tokenRespBody struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"` // in seconds
IdToken string `json:"id_token"`
}
// TokenFetcher refreshes or fetches a new access token from the
// provider. It should return an error if it's not capable of
// retrieving a token.
type TokenFetcher interface {
// FetchToken retrieves a new access token for the provider.
// If the implementation doesn't know how to retrieve a new token,
// it returns an error. The existing token may be nil.
FetchToken(existing *Token) (*Token, error)
}
// Options represents options to provide OAuth 2.0 client credentials
// and access level. A sample configuration:
type Options struct {
// ClientID is the OAuth client identifier used when communicating with
// the configured OAuth provider.
ClientID string `json:"client_id"`
// ClientSecret is the OAuth client secret used when communicating with
// the configured OAuth provider.
ClientSecret string `json:"client_secret"`
// RedirectURL is the URL to which the user will be returned after
// granting (or denying) access.
RedirectURL string `json:"redirect_url"`
// Scopes optionally specifies a list of requested permission scopes.
Scopes []string `json:"scopes,omitempty"`
}
// NewConfig creates a generic OAuth 2.0 configuration that talks
// to an OAuth 2.0 provider specified with authURL and tokenURL.
func NewConfig(opts *Options, authURL, tokenURL string) (*Config, error) {
aURL, err := url.Parse(authURL)
if err != nil {
return nil, err
}
tURL, err := url.Parse(tokenURL)
if err != nil {
return nil, err
}
if opts.ClientID == "" {
return nil, errors.New("oauth2: missing client ID")
}
return &Config{
opts: opts,
authURL: aURL,
tokenURL: tURL,
}, nil
}
// Config represents the configuration of an OAuth 2.0 consumer client.
type Config struct {
// Client is the HTTP client to be used to retrieve
// tokens from the OAuth 2.0 provider.
Client *http.Client
// Transport is the http.RoundTripper to be used
// to construct new oauth2.Transport instances from
// this configuration.
Transport http.RoundTripper
opts *Options
// AuthURL is the URL the user will be directed to
// in order to grant access.
authURL *url.URL
// TokenURL is the URL used to retrieve OAuth tokens.
tokenURL *url.URL
}
// AuthCodeURL returns a URL to OAuth 2.0 provider's consent page
// that asks for permissions for the required scopes explicitly.
//
// State is a token to protect the user from CSRF attacks. You must
// always provide a non-zero string and validate that it matches the
// the state query parameter on your redirect callback.
// See http://tools.ietf.org/html/rfc6749#section-10.12 for more info.
//
// Access type is an OAuth extension that gets sent as the
// "access_type" field in the URL from AuthCodeURL.
// It may be "online" (default) or "offline".
// If your application needs to refresh access tokens when the
// user is not present at the browser, then use offline. This
// will result in your application obtaining a refresh token
// the first time your application exchanges an authorization
// code for a user.
//
// Approval prompt indicates whether the user should be
// re-prompted for consent. If set to "auto" (default) the
// user will be prompted only if they haven't previously
// granted consent and the code can only be exchanged for an
// access token. If set to "force" the user will always be prompted,
// and the code can be exchanged for a refresh token.
func (c *Config) AuthCodeURL(state, accessType, prompt string) (authURL string) {
u := *c.authURL
v := url.Values{
"response_type": {"code"},
"client_id": {c.opts.ClientID},
"redirect_uri": condVal(c.opts.RedirectURL),
"scope": condVal(strings.Join(c.opts.Scopes, " ")),
"state": condVal(state),
"access_type": condVal(accessType),
"approval_prompt": condVal(prompt),
}
q := v.Encode()
if u.RawQuery == "" {
u.RawQuery = q
} else {
u.RawQuery += "&" + q
}
return u.String()
}
// NewTransport creates a new authorizable transport. It doesn't
// initialize the new transport with a token, so after creation,
// you need to set a valid token (or an expired token with a valid
// refresh token) in order to be able to do authorized requests.
func (c *Config) NewTransport() *Transport {
return NewTransport(c.transport(), c, nil)
}
// NewTransportWithCode exchanges the OAuth 2.0 authorization code with
// the provider to fetch a new access token (and refresh token). Once
// it succesffully retrieves a new token, creates a new transport
// authorized with it.
func (c *Config) NewTransportWithCode(code string) (*Transport, error) {
token, err := c.Exchange(code)
if err != nil {
return nil, err
}
return NewTransport(c.transport(), c, token), nil
}
// FetchToken retrieves a new access token and updates the existing token
// with the newly fetched credentials. If existing token doesn't
// contain a refresh token, it returns an error.
func (c *Config) FetchToken(existing *Token) (*Token, error) {
if existing == nil || existing.RefreshToken == "" {
return nil, errors.New("oauth2: cannot fetch access token without refresh token")
}
return c.retrieveToken(url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {existing.RefreshToken},
})
}
// Exchange exchanges the authorization code with the OAuth 2.0 provider
// to retrieve a new access token.
func (c *Config) Exchange(code string) (*Token, error) {
return c.retrieveToken(url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": condVal(c.opts.RedirectURL),
"scope": condVal(strings.Join(c.opts.Scopes, " ")),
})
}
func (c *Config) retrieveToken(v url.Values) (*Token, error) {
v.Set("client_id", c.opts.ClientID)
bustedAuth := !providerAuthHeaderWorks(c.tokenURL.String())
if bustedAuth && c.opts.ClientSecret != "" {
v.Set("client_secret", c.opts.ClientSecret)
}
req, err := http.NewRequest("POST", c.tokenURL.String(), strings.NewReader(v.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if !bustedAuth && c.opts.ClientSecret != "" {
req.SetBasicAuth(c.opts.ClientID, c.opts.ClientSecret)
}
r, err := c.client().Do(req)
if err != nil {
return nil, err
}
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err)
}
if c := r.StatusCode; c < 200 || c > 299 {
return nil, fmt.Errorf("oauth2: cannot fetch token: %v\nResponse: %s", r.Status, body)
}
resp := &tokenRespBody{}
content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
switch content {
case "application/x-www-form-urlencoded", "text/plain":
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return nil, err
}
vals, err := url.ParseQuery(string(body))
if err != nil {
return nil, err
}
resp.AccessToken = vals.Get("access_token")
resp.TokenType = vals.Get("token_type")
resp.RefreshToken = vals.Get("refresh_token")
resp.ExpiresIn, _ = strconv.ParseInt(vals.Get("expires_in"), 10, 64)
resp.IdToken = vals.Get("id_token")
default:
if err = json.NewDecoder(r.Body).Decode(&resp); err != nil {
return nil, err
}
}
token := &Token{
AccessToken: resp.AccessToken,
TokenType: resp.TokenType,
RefreshToken: resp.RefreshToken,
}
// Don't overwrite `RefreshToken` with an empty value
// if this was a token refreshing request.
if resp.RefreshToken == "" {
token.RefreshToken = v.Get("refresh_token")
}
if resp.ExpiresIn == 0 {
token.Expiry = time.Time{}
} else {
token.Expiry = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second)
}
if resp.IdToken != "" {
if token.Extra == nil {
token.Extra = make(map[string]string)
}
token.Extra["id_token"] = resp.IdToken
}
return token, nil
}
func (c *Config) transport() http.RoundTripper {
if c.Transport != nil {
return c.Transport
}
return http.DefaultTransport
}
func (c *Config) client() *http.Client {
if c.Client != nil {
return c.Client
}
return http.DefaultClient
}
func condVal(v string) []string {
if v == "" {
return nil
}
return []string{v}
}
// 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
func providerAuthHeaderWorks(tokenURL string) bool {
if strings.HasPrefix(tokenURL, "https://accounts.google.com/") ||
strings.HasPrefix(tokenURL, "https://github.com/") ||
strings.HasPrefix(tokenURL, "https://api.instagram.com/") ||
strings.HasPrefix(tokenURL, "https://www.douban.com/") ||
strings.HasPrefix(tokenURL, "https://api.dropbox.com/") {
// Some sites fail to implement the OAuth2 spec fully.
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
}