// 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" "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: // // opts := &oauth2.Options{ // ClientID: "", // ClientSecret: "ad4364309eff", // RedirectURL: "https://homepage/oauth2callback", // Scopes: []string{"scope1", "scope2"}, // AccessType: "offline", // retrieves a refresh token // } // 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"` // AccessType is an OAuth extension that gets sent as the // "access_type" field in the URL from AuthCodeURL. // See https://developers.google.com/accounts/docs/OAuth2WebServer. // It may be "online" (the 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. AccessType string `json:"access_type,omitempty"` // ApprovalPrompt 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. ApprovalPrompt string `json:"-"` } // 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. func (c *Config) AuthCodeURL(state 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(c.opts.AccessType), "approval_prompt": condVal(c.opts.ApprovalPrompt), } 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() if r.StatusCode != 200 { // TODO(jbd): Add status code or error message return nil, errors.New("oauth2: can't retrieve a new token") } 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/") { // 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 }