forked from Mirrors/oauth2
Initial commit
This commit is contained in:
commit
c32debaa6f
|
@ -0,0 +1,95 @@
|
||||||
|
// Package google provides support for making
|
||||||
|
// OAuth2 authorized and authenticated HTTP requests
|
||||||
|
// to Google APIs. It supports the following
|
||||||
|
// authorization and authentications flows:
|
||||||
|
// - Web Server
|
||||||
|
// - Client-side
|
||||||
|
// - Service Accounts
|
||||||
|
// - Auth from Google Compute Engine
|
||||||
|
// - Auth from Google App Engine
|
||||||
|
//
|
||||||
|
// For more information, please read
|
||||||
|
// https://developers.google.com/accounts/docs/OAuth2.
|
||||||
|
//
|
||||||
|
// Example usage:
|
||||||
|
// // Web server flow usage:
|
||||||
|
// // Specify your configuration.
|
||||||
|
// // Your credentials should be obtained from the Google
|
||||||
|
// // Developer Console (https://console.developers.google.com).
|
||||||
|
// var config = google.NewConfig(&oauth2.Opts{
|
||||||
|
// ClientID: YOUR_CLIENT_ID,
|
||||||
|
// ClientSecret: YOUR_CLIENT_SECRET,
|
||||||
|
// RedirectURL: "http://you.example.org/handler",
|
||||||
|
// Scopes: []string{ "scope1", "scope2" },
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// // A landing page redirects to Google to get the auth code.
|
||||||
|
// func landing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// http.Redirect(w, r, config.AuthCodeURL(""), http.StatusFound)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // The user will be redirected back to this handler, that takes the
|
||||||
|
// // "code" query parameter and Exchanges it for an access token.
|
||||||
|
// func handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// t, err := config.NewTransportWithCode(r.FormValue("code"))
|
||||||
|
// // The Transport now has a valid Token. Create an *http.Client
|
||||||
|
// // with which we can make authenticated API requests.
|
||||||
|
// c := t.Client()
|
||||||
|
// c.Post(...)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Service accounts usage:
|
||||||
|
// // Google Developer Console will provide a p12 file contains
|
||||||
|
// // a private key. You need to export it to the pem format.
|
||||||
|
// // Run the following command to generate a pem file that
|
||||||
|
// // contains your private key:
|
||||||
|
// // $ openssl pkcs12 -in /path/to/p12key.p12 -out key.pem -nodes
|
||||||
|
// // Then, specify your configuration.
|
||||||
|
// var config = google.NewServiceAccountConfig(&oauth2.JWTOpts{
|
||||||
|
// Email: "xxx@developer.gserviceaccount.com",
|
||||||
|
// PemFilename: "/path/to/key.pem",
|
||||||
|
// Scopes: []string{
|
||||||
|
// "https://www.googleapis.com/auth/drive.readonly"
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// // Create a transport.
|
||||||
|
// t, err := config.NewTransport()
|
||||||
|
// // Or, you can create a transport that impersonates
|
||||||
|
// // a Google user.
|
||||||
|
// t, err := config.NewTransportWithUser(googleUserEmail)
|
||||||
|
//
|
||||||
|
// // Create a client to make authorized requests.
|
||||||
|
// c := t.Client()
|
||||||
|
// c.Post(...)
|
||||||
|
//
|
||||||
|
package google
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/rakyll/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Google endpoints.
|
||||||
|
uriGoogleAuth = "https://accounts.google.com/o/oauth2/auth"
|
||||||
|
uriGoogleToken = "https://accounts.google.com/o/oauth2/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewConfig creates a new OAuth2 config that uses Google
|
||||||
|
// endpoints.
|
||||||
|
func NewConfig(opts *oauth2.Options) (oauth2.Config, error) {
|
||||||
|
return oauth2.NewConfig(opts, uriGoogleAuth, uriGoogleToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewComputeEngineConfig creates a new config that can fetch tokens
|
||||||
|
// from Google Compute Engine instance's metaserver.
|
||||||
|
func NewComputeEngineConfig() (oauth2.Config, error) {
|
||||||
|
// Should fetch an access token from the meta server.
|
||||||
|
panic("not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceAccountConfig creates a new JWT config that can
|
||||||
|
// fetch Bearer JWT tokens from Google endpoints.
|
||||||
|
func NewServiceAccountConfig(opts *oauth2.JWTOptions) (oauth2.JWTConfig, error) {
|
||||||
|
return oauth2.NewJWTConfig(opts, uriGoogleToken)
|
||||||
|
}
|
|
@ -0,0 +1,189 @@
|
||||||
|
// Package jws provides encoding and decoding utilities for
|
||||||
|
// signed JWS messages.
|
||||||
|
package jws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The JWT claim set contains information about the JWT including the
|
||||||
|
// permissions being requested (scopes), the target of the token, the issuer,
|
||||||
|
// the time the token was issued, and the lifetime of the token.
|
||||||
|
type ClaimSet struct {
|
||||||
|
Iss string `json:"iss"` // email address of the client_id of the application making the access token request
|
||||||
|
Scope string `json:"scope,omitempty"` // space-delimited list of the permissions the application requests
|
||||||
|
Aud string `json:"aud"` // descriptor of the intended target of the assertion (Optional).
|
||||||
|
Exp int64 `json:"exp"` // the expiration time of the assertion
|
||||||
|
Iat int64 `json:"iat"` // the time the assertion was issued.
|
||||||
|
Typ string `json:"typ,omitempty"` // token type (Optional).
|
||||||
|
|
||||||
|
// Email for which the application is requesting delegated access (Optional).
|
||||||
|
Sub string `json:"sub,omitempty"`
|
||||||
|
|
||||||
|
// The old name of Sub. Client keeps setting Prn to be
|
||||||
|
// complaint with legacy OAuth 2.0 providers. (Optional)
|
||||||
|
Prn string `json:"prn,omitempty"`
|
||||||
|
|
||||||
|
// See http://tools.ietf.org/html/draft-jones-json-web-token-10#section-4.3
|
||||||
|
// This array is marshalled using custom code (see (c *ClaimSet) encode()).
|
||||||
|
PrivateClaims map[string]interface{} `json:"-"`
|
||||||
|
|
||||||
|
exp time.Time
|
||||||
|
iat time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaimSet) encode() (string, error) {
|
||||||
|
if c.exp.IsZero() || c.iat.IsZero() {
|
||||||
|
// Reverting time back for machines whose time is not perfectly in sync.
|
||||||
|
// If client machine's time is in the future according
|
||||||
|
// to Google servers, an access token will not be issued.
|
||||||
|
now := time.Now().Add(-10 * time.Second)
|
||||||
|
c.iat = now
|
||||||
|
c.exp = now.Add(time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Exp = c.exp.Unix()
|
||||||
|
c.Iat = c.iat.Unix()
|
||||||
|
|
||||||
|
b, err := json.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.PrivateClaims) == 0 {
|
||||||
|
return base64Encode(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal private claim set and then append it to b.
|
||||||
|
prv, err := json.Marshal(c.PrivateClaims)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Invalid map of private claims %v", c.PrivateClaims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate public and private claim JSON objects.
|
||||||
|
if !bytes.HasSuffix(b, []byte{'}'}) {
|
||||||
|
return "", fmt.Errorf("Invalid JSON %s", b)
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(prv, []byte{'{'}) {
|
||||||
|
return "", fmt.Errorf("Invalid JSON %s", prv)
|
||||||
|
}
|
||||||
|
b[len(b)-1] = ',' // Replace closing curly brace with a comma.
|
||||||
|
b = append(b, prv[1:]...) // Append private claims.
|
||||||
|
return base64Encode(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header represents the header for the signed JWS payloads.
|
||||||
|
type Header struct {
|
||||||
|
// The algorithm used for signature.
|
||||||
|
Algorithm string `json:"alg"`
|
||||||
|
|
||||||
|
// Represents the token type.
|
||||||
|
Typ string `json:"typ"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Header) encode() (string, error) {
|
||||||
|
b, err := json.Marshal(h)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64Encode(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode decodes a claim set from a JWS payload.
|
||||||
|
func Decode(payload string) (c *ClaimSet, err error) {
|
||||||
|
// decode returned id token to get expiry
|
||||||
|
s := strings.Split(payload, ".")
|
||||||
|
if len(s) < 2 {
|
||||||
|
// TODO(jbd): Provide more context about the error.
|
||||||
|
return nil, errors.New("invalid token received")
|
||||||
|
}
|
||||||
|
decoded, err := base64Decode(s[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c = &ClaimSet{}
|
||||||
|
err = json.NewDecoder(bytes.NewBuffer(decoded)).Decode(c)
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode encodes a signed JWS with provided header and claim set.
|
||||||
|
func Encode(header *Header, c *ClaimSet, signature []byte) (payload string, err error) {
|
||||||
|
var encodedHeader, encodedClaimSet string
|
||||||
|
encodedHeader, err = header.encode()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encodedClaimSet, err = c.encode()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ss := fmt.Sprintf("%s.%s", encodedHeader, encodedClaimSet)
|
||||||
|
parsed, err := parsePrivateKey(signature)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(ss))
|
||||||
|
b, err := rsa.SignPKCS1v15(rand.Reader, parsed, crypto.SHA256, h.Sum(nil))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := base64Encode(b)
|
||||||
|
return fmt.Sprintf("%s.%s", ss, sig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64Encode returns and Base64url encoded version of the input string with any
|
||||||
|
// trailing "=" stripped.
|
||||||
|
func base64Encode(b []byte) string {
|
||||||
|
return strings.TrimRight(base64.URLEncoding.EncodeToString(b), "=")
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64Decode decodes the Base64url encoded string
|
||||||
|
func base64Decode(s string) ([]byte, error) {
|
||||||
|
// add back missing padding
|
||||||
|
switch len(s) % 4 {
|
||||||
|
case 2:
|
||||||
|
s += "=="
|
||||||
|
case 3:
|
||||||
|
s += "="
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.DecodeString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePrivateKey parses the key to extract the private key.
|
||||||
|
// It returns an error if private key is not provided or the
|
||||||
|
// provided key is invalid.
|
||||||
|
func parsePrivateKey(key []byte) (*rsa.PrivateKey, error) {
|
||||||
|
invalidPrivateKeyErr := errors.New("Private key is invalid.")
|
||||||
|
block, _ := pem.Decode(key)
|
||||||
|
if block == nil {
|
||||||
|
return nil, invalidPrivateKeyErr
|
||||||
|
}
|
||||||
|
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
parsedKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed, ok := parsedKey.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, invalidPrivateKeyErr
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
package oauth2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rakyll/oauth2/jws"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
||||||
|
defaultHeader = &jws.Header{Algorithm: "RS256", Typ: "JWT"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWTOptions represents a OAuth2 client's crendentials to retrieve a
|
||||||
|
// Bearer JWT token.
|
||||||
|
type JWTOptions struct {
|
||||||
|
// ClientID is the OAuth client identifier used when communicating with
|
||||||
|
// the configured OAuth provider.
|
||||||
|
Email string `json:"email"`
|
||||||
|
|
||||||
|
// The path to the pem file. If you have a p12 file instead, you
|
||||||
|
// can use `openssl` to export the private key into a pem file.
|
||||||
|
// $ openssl pkcs12 -in key.p12 -out key.pem -nodes
|
||||||
|
// Pem file should contain your private key.
|
||||||
|
PemFilename string `json:"pemfilename"`
|
||||||
|
|
||||||
|
// Scopes identify the level of access being requested.
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(jbd): Add p12 support.
|
||||||
|
|
||||||
|
// NewJWTConfig creates a new configuration with the specified options
|
||||||
|
// and OAuth2 provider endpoint.
|
||||||
|
func NewJWTConfig(opts *JWTOptions, aud string) (conf JWTConfig, err error) {
|
||||||
|
contents, err := ioutil.ReadFile(opts.PemFilename)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conf = &jwtConfig{opts: opts, aud: aud, signature: contents}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type jwtConfig struct {
|
||||||
|
opts *JWTOptions
|
||||||
|
aud string
|
||||||
|
signature []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTransport creates a transport that is authorize with the
|
||||||
|
// parent JWT configuration.
|
||||||
|
func (c *jwtConfig) NewTransport() (Transport, error) {
|
||||||
|
return &authorizedTransport{fetcher: c, token: &Token{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTransportWithUser creates a transport that is authorized by
|
||||||
|
// the client and impersonates the specified user.
|
||||||
|
func (c *jwtConfig) NewTransportWithUser(user string) (Transport, error) {
|
||||||
|
return &authorizedTransport{fetcher: c, token: &Token{Subject: user}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchToken retrieves a new access token and updates the existing token
|
||||||
|
// with the newly fetched credentials.
|
||||||
|
func (c *jwtConfig) fetchToken(existing *Token) (token *Token, err error) {
|
||||||
|
|
||||||
|
if existing == nil {
|
||||||
|
existing = &Token{}
|
||||||
|
}
|
||||||
|
|
||||||
|
claimSet := &jws.ClaimSet{
|
||||||
|
Iss: c.opts.Email,
|
||||||
|
Scope: strings.Join(c.opts.Scopes, " "),
|
||||||
|
Aud: c.aud,
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing.Subject != "" {
|
||||||
|
claimSet.Sub = existing.Subject
|
||||||
|
// prn is the old name of sub. Keep setting it
|
||||||
|
// to be compatible with legacy OAuth 2.0 providers.
|
||||||
|
claimSet.Prn = existing.Subject
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := jws.Encode(defaultHeader, claimSet, c.signature)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := url.Values{}
|
||||||
|
v.Set("grant_type", defaultGrantType)
|
||||||
|
v.Set("assertion", payload)
|
||||||
|
|
||||||
|
// Make a request with assertion to get a new token.
|
||||||
|
client := http.Client{Transport: DefaultTransport}
|
||||||
|
resp, err := client.PostForm(c.aud, v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
// TODO(jbd): Provide more context about the response.
|
||||||
|
return nil, errors.New("Cannot fetch token, response: " + resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := &tokenRespBody{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
token = &Token{
|
||||||
|
AccessToken: b.AccessToken,
|
||||||
|
TokenType: b.TokenType,
|
||||||
|
Subject: existing.Subject,
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.IdToken != "" {
|
||||||
|
// decode returned id token to get expiry
|
||||||
|
claimSet := &jws.ClaimSet{}
|
||||||
|
claimSet, err = jws.Decode(b.IdToken)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token.Expiry = time.Unix(claimSet.Exp, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token.Expiry = time.Now().Add(time.Duration(b.ExpiresIn) * time.Second)
|
||||||
|
return
|
||||||
|
}
|
|
@ -0,0 +1,327 @@
|
||||||
|
// Package oauth2 provides support for making
|
||||||
|
// OAuth2 authorized and authenticated HTTP requests.
|
||||||
|
// It can additionally grant authorization with Bearer JWT.
|
||||||
|
//
|
||||||
|
// Example usage:
|
||||||
|
//
|
||||||
|
// // Specify your configuration. (typically as a global variable)
|
||||||
|
// var config = oauth2.NewConfig(&oauth2.Options{
|
||||||
|
// ClientID: YOUR_CLIENT_ID,
|
||||||
|
// ClientSecret: YOUR_CLIENT_SECRET,
|
||||||
|
// RedirectURL: "http://you.example.org/handler",
|
||||||
|
// Scopes: []string{ "scope1", "scope2" },
|
||||||
|
// }, OAUTH2_PROVIDER_AUTH_URL, OAUTH2_PROVIDER_TOKEN_URL)
|
||||||
|
//
|
||||||
|
// // A landing page redirects to the OAuth provider to get the auth code.
|
||||||
|
// func landing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// http.Redirect(w, r, config.AuthCodeURL("foo"), http.StatusFound)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // The user will be redirected back to this handler, that takes the
|
||||||
|
// // "code" query parameter and Exchanges it for an access token.
|
||||||
|
// func handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// t, err := config.NewTransportWithCode(r.FormValue("code"))
|
||||||
|
// // The Transport now has a valid Token. Create an *http.Client
|
||||||
|
// // with which we can make authenticated API requests.
|
||||||
|
// c := t.Client()
|
||||||
|
// c.Post(...)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
package oauth2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io/ioutil"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The default transport implementation to be used while
|
||||||
|
// making the authorized requests.
|
||||||
|
var DefaultTransport = http.DefaultTransport
|
||||||
|
|
||||||
|
type tokenRespBody struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn time.Duration `json:"expires_in"`
|
||||||
|
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.
|
||||||
|
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: "<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"`
|
||||||
|
|
||||||
|
// Optional, identifies the level of access being requested.
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
|
||||||
|
// Optional, "online" (default) or "offline", no refresh token if "online"
|
||||||
|
AccessType string `json:"omit"`
|
||||||
|
|
||||||
|
// 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:"omit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config represents an OAuth 2.0 provider and client options to
|
||||||
|
// provide authorized transports.
|
||||||
|
type Config interface {
|
||||||
|
// NewTransport creates a transport which is configured to be
|
||||||
|
// authorized with the config provided.
|
||||||
|
NewTransport() (Transport, error)
|
||||||
|
|
||||||
|
// NewTransportWithCode creates a transport after going through
|
||||||
|
// the OAuth 2.0 exchange flow to retrieve a valid token from
|
||||||
|
// the exchange server.
|
||||||
|
NewTransportWithCode(exchangeCode string) (Transport, error)
|
||||||
|
|
||||||
|
// AuthCodeURL generates a URL to the consent page.
|
||||||
|
AuthCodeURL(state string) (string, error)
|
||||||
|
|
||||||
|
// Exchange ecxhanges the code with the provider to retrieve
|
||||||
|
// a new access token.
|
||||||
|
Exchange(exchangeCode string) (*Token, error)
|
||||||
|
|
||||||
|
// TODO(jbd): Token fetcher strategy should be settable
|
||||||
|
// from external packages.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config represents an OAuth 2.0 provider and client options to
|
||||||
|
// provide authorized transports with a Bearer JWT token.
|
||||||
|
type JWTConfig interface {
|
||||||
|
// NewTransport creates a transport which is configured to
|
||||||
|
// be authorized with OAuth 2.0 JWT Bearer flow.
|
||||||
|
NewTransport() (Transport, error)
|
||||||
|
|
||||||
|
// NewTransportWithUser creates a transport which is configured
|
||||||
|
// to be authorized with OAuth 2.0 JWT Bearer flow and
|
||||||
|
// impersonates the provided user.
|
||||||
|
NewTransportWithUser(user string) (Transport, error)
|
||||||
|
|
||||||
|
// TODO(jbd): Token fetcher strategy should be settable
|
||||||
|
// from external packages.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
conf := &config{
|
||||||
|
opts: opts,
|
||||||
|
authURL: authURL,
|
||||||
|
tokenURL: tokenURL,
|
||||||
|
}
|
||||||
|
if err := conf.validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return conf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// config represent the configuration of an OAuth 2.0 consumer client.
|
||||||
|
type config struct {
|
||||||
|
opts *Options
|
||||||
|
// AuthURL is the URL the user will be directed to
|
||||||
|
// in order to grant access.
|
||||||
|
authURL string
|
||||||
|
// TokenURL is the URL used to retrieve OAuth tokens.
|
||||||
|
tokenURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, err error) {
|
||||||
|
u, err := url.Parse(c.authURL)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := url.Values{
|
||||||
|
"response_type": {"code"},
|
||||||
|
"client_id": {c.opts.ClientID},
|
||||||
|
"redirect_uri": {c.opts.RedirectURL},
|
||||||
|
"scope": {strings.Join(c.opts.Scopes, " ")},
|
||||||
|
"state": {state},
|
||||||
|
"access_type": {c.opts.AccessType},
|
||||||
|
"approval_prompt": {c.opts.ApprovalPrompt},
|
||||||
|
}.Encode()
|
||||||
|
if u.RawQuery == "" {
|
||||||
|
u.RawQuery = q
|
||||||
|
} else {
|
||||||
|
u.RawQuery += "&" + q
|
||||||
|
}
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// t, _ := c.NewTransport()
|
||||||
|
// t.SetToken(validToken)
|
||||||
|
//
|
||||||
|
func (c *config) NewTransport() (Transport, error) {
|
||||||
|
return &authorizedTransport{fetcher: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTransportWithCode exchanges the OAuth 2.0 exchange 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(exchangeCode string) (Transport, error) {
|
||||||
|
token, err := c.Exchange(exchangeCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &authorizedTransport{fetcher: c, token: token}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange exchanges the exchange code with the OAuth 2.0 provider
|
||||||
|
// to retrieve a new access token.
|
||||||
|
func (c *config) Exchange(exchangeCode string) (*Token, error) {
|
||||||
|
token := &Token{}
|
||||||
|
err := c.updateToken(token, url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"redirect_uri": {c.opts.RedirectURL},
|
||||||
|
"scope": {strings.Join(c.opts.Scopes, " ")},
|
||||||
|
"code": {exchangeCode},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return 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("cannot fetch access token without refresh token.")
|
||||||
|
}
|
||||||
|
err := c.updateToken(existing, url.Values{
|
||||||
|
"grant_type": {"refresh_token"},
|
||||||
|
"refresh_token": {existing.RefreshToken},
|
||||||
|
})
|
||||||
|
return existing, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks if all required configuration fields have non-zero values.
|
||||||
|
func (c *config) validate() error {
|
||||||
|
if c.opts.ClientID == "" {
|
||||||
|
return errors.New("A client ID should be provided.")
|
||||||
|
}
|
||||||
|
if c.opts.ClientSecret == "" {
|
||||||
|
return errors.New("A client secret should be provided.")
|
||||||
|
}
|
||||||
|
// TODO(jbd): Are redirect URIs allowed to be a
|
||||||
|
// non-value string in the spec?
|
||||||
|
if c.opts.RedirectURL == "" {
|
||||||
|
return errors.New("A redirect URL should be provided.")
|
||||||
|
}
|
||||||
|
// TODO(jbd): Validate the URLs. Maybe convert them to URL
|
||||||
|
// objects on construction.
|
||||||
|
if c.authURL == "" {
|
||||||
|
return errors.New("An auth URL should be provided.")
|
||||||
|
}
|
||||||
|
if c.tokenURL == "" {
|
||||||
|
return errors.New("A token URL should be provided.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *config) updateToken(tok *Token, v url.Values) error {
|
||||||
|
v.Set("client_id", c.opts.ClientID)
|
||||||
|
v.Set("client_secret", c.opts.ClientSecret)
|
||||||
|
r, err := (&http.Client{Transport: DefaultTransport}).PostForm(c.tokenURL, v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
// TODO(jbd): Add status code or error message
|
||||||
|
return errors.New("Error during updating 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 err
|
||||||
|
}
|
||||||
|
vals, err := url.ParseQuery(string(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.AccessToken = vals.Get("access_token")
|
||||||
|
resp.TokenType = vals.Get("token_type")
|
||||||
|
resp.RefreshToken = vals.Get("refresh_token")
|
||||||
|
resp.ExpiresIn, _ = time.ParseDuration(vals.Get("expires_in") + "s")
|
||||||
|
resp.IdToken = vals.Get("id_token")
|
||||||
|
default:
|
||||||
|
if err = json.NewDecoder(r.Body).Decode(&resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The JSON parser treats the unitless ExpiresIn like 'ns' instead of 's' as above,
|
||||||
|
// so compensate here.
|
||||||
|
resp.ExpiresIn *= time.Second
|
||||||
|
}
|
||||||
|
tok.AccessToken = resp.AccessToken
|
||||||
|
tok.TokenType = resp.TokenType
|
||||||
|
// Don't overwrite `RefreshToken` with an empty value
|
||||||
|
if resp.RefreshToken == "" {
|
||||||
|
tok.RefreshToken = resp.RefreshToken
|
||||||
|
}
|
||||||
|
if resp.ExpiresIn == 0 {
|
||||||
|
tok.Expiry = time.Time{}
|
||||||
|
} else {
|
||||||
|
tok.Expiry = time.Now().Add(resp.ExpiresIn)
|
||||||
|
}
|
||||||
|
if resp.IdToken != "" {
|
||||||
|
if tok.Extra == nil {
|
||||||
|
tok.Extra = make(map[string]string)
|
||||||
|
}
|
||||||
|
tok.Extra["id_token"] = resp.IdToken
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
package oauth2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockTransport struct {
|
||||||
|
rt func(req *http.Request) (resp *http.Response, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *mockTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
|
||||||
|
return t.rt(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestConf() Config {
|
||||||
|
conf, _ := NewConfig(&Options{
|
||||||
|
ClientID: "CLIENT_ID",
|
||||||
|
ClientSecret: "CLIENT_SECRET",
|
||||||
|
RedirectURL: "REDIRECT_URL",
|
||||||
|
Scopes: []string{
|
||||||
|
"scope1",
|
||||||
|
"scope2",
|
||||||
|
},
|
||||||
|
AccessType: "offline",
|
||||||
|
ApprovalPrompt: "force",
|
||||||
|
}, "auth-url", "token-url")
|
||||||
|
return conf
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCodeURL(t *testing.T) {
|
||||||
|
DefaultTransport = http.DefaultTransport
|
||||||
|
|
||||||
|
conf := newTestConf()
|
||||||
|
url, err := conf.AuthCodeURL("foo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected to generate an auth URL, failed with %v.", err)
|
||||||
|
}
|
||||||
|
if url != "auth-url?access_type=offline&approval_prompt=force&client_id=CLIENT_ID&redirect_uri=REDIRECT_URL&response_type=code&scope=scope1+scope2&state=foo" {
|
||||||
|
t.Fatalf("Generated auth URL is not the expected. Found %v.", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangePayload(t *testing.T) {
|
||||||
|
conf := newTestConf()
|
||||||
|
DefaultTransport = &mockTransport{
|
||||||
|
rt: func(req *http.Request) (resp *http.Response, err error) {
|
||||||
|
headerContentType := req.Header.Get("Content-Type")
|
||||||
|
if headerContentType != "application/x-www-form-urlencoded" {
|
||||||
|
t.Fatalf("Content-Type header is expected to be application/x-www-form-urlencoded, %v found.", headerContentType)
|
||||||
|
}
|
||||||
|
body, _ := ioutil.ReadAll(req.Body)
|
||||||
|
if string(body) != "client_id=CLIENT_ID&client_secret=CLIENT_SECRET&code=exchange-code&grant_type=authorization_code&redirect_uri=REDIRECT_URL&scope=scope1+scope2" {
|
||||||
|
t.Fatalf("Exchange payload is found to be %v", string(body))
|
||||||
|
}
|
||||||
|
return nil, errors.New("no response")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
conf.Exchange("exchange-code")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangingTransport(t *testing.T) {
|
||||||
|
conf := newTestConf()
|
||||||
|
DefaultTransport = &mockTransport{
|
||||||
|
rt: func(req *http.Request) (resp *http.Response, err error) {
|
||||||
|
if req.URL.RequestURI() != "token-url" {
|
||||||
|
t.Fatalf("NewTransportWithCode should have exchanged the code, but it didn't.")
|
||||||
|
}
|
||||||
|
return nil, errors.New("no response")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
conf.NewTransportWithCode("exchange-code")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchWithNoRedirect(t *testing.T) {
|
||||||
|
DefaultTransport = http.DefaultTransport
|
||||||
|
|
||||||
|
fetcher := newTestConf().(tokenFetcher)
|
||||||
|
_, err := fetcher.fetchToken(&Token{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Fetch should return an error if no refresh token is set")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,166 @@
|
||||||
|
package oauth2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultTokenType = "Bearer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Token represents the crendentials used to authorize
|
||||||
|
// the requests to access protected resources on the OAuth 2.0
|
||||||
|
// provider's backend.
|
||||||
|
type Token struct {
|
||||||
|
// A token that authorizes and authenticates the requests.
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
|
||||||
|
// Identifies the type of token returned.
|
||||||
|
TokenType string `json:"token_type,omitempty"`
|
||||||
|
|
||||||
|
// A token that may be used to obtain a new access token.
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
|
||||||
|
// The remaining lifetime of the access token.
|
||||||
|
Expiry time.Time `json:"expiry,omitempty"`
|
||||||
|
|
||||||
|
Extra map[string]string `json:"extra,omitempty"`
|
||||||
|
|
||||||
|
// JWT related fields
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired returns true if there is no access token or the
|
||||||
|
// access token is expired.
|
||||||
|
func (t *Token) Expired() bool {
|
||||||
|
if t.AccessToken == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if t.Expiry.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return t.Expiry.Before(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transport represents an authorized transport.
|
||||||
|
// Provides currently in-use user token and allows to set a token to
|
||||||
|
// be used. If token expires, it tries to fetch a new token,
|
||||||
|
// if possible. Token fetching is thread-safe. If two or more
|
||||||
|
// concurrent requests are being made with the same expired token,
|
||||||
|
// one of the requests will wait for the other to refresh
|
||||||
|
// the existing token.
|
||||||
|
type Transport interface {
|
||||||
|
// Authenticates the request with the existing token. If token is
|
||||||
|
// expired, tries to refresh/fetch a new token.
|
||||||
|
// Makes the request by delegating it to the default transport.
|
||||||
|
RoundTrip(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
// Returns the token authenticates the transport.
|
||||||
|
// This operation is thread-safe.
|
||||||
|
Token() *Token
|
||||||
|
|
||||||
|
// Sets a new token to authenticate the transport.
|
||||||
|
// This operation is thread-safe.
|
||||||
|
SetToken(token *Token)
|
||||||
|
|
||||||
|
// Refreshes the token if refresh is possible (such as in the
|
||||||
|
// presense of a refresh token). Returns an error if refresh is
|
||||||
|
// not possible. Refresh is thread-safe.
|
||||||
|
RefreshToken() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type authorizedTransport struct {
|
||||||
|
fetcher tokenFetcher
|
||||||
|
token *Token
|
||||||
|
|
||||||
|
// Mutex to protect token during auto refreshments.
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoundTrip authorizes the request with the existing token.
|
||||||
|
// If token is expired, tries to refresh/fetch a new token.
|
||||||
|
func (t *authorizedTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
|
||||||
|
token := t.Token()
|
||||||
|
if token == nil || token.Expired() {
|
||||||
|
// Check if the token is refreshable.
|
||||||
|
// If token is refreshable, don't return an error,
|
||||||
|
// rather refresh.
|
||||||
|
|
||||||
|
if err := t.RefreshToken(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
token = t.Token()
|
||||||
|
}
|
||||||
|
|
||||||
|
// To set the Authorization header, we must make a copy of the Request
|
||||||
|
// so that we don't modify the Request we were given.
|
||||||
|
// This is required by the specification of http.RoundTripper.
|
||||||
|
req = cloneRequest(req)
|
||||||
|
typ := token.TokenType
|
||||||
|
if typ == "" {
|
||||||
|
typ = defaultTokenType
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", typ+" "+token.AccessToken)
|
||||||
|
|
||||||
|
// Make the HTTP request.
|
||||||
|
return DefaultTransport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token returns the existing token that authorizes the Transport.
|
||||||
|
func (t *authorizedTransport) Token() *Token {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
if t.token == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
token := &Token{
|
||||||
|
AccessToken: t.token.AccessToken,
|
||||||
|
TokenType: t.token.TokenType,
|
||||||
|
RefreshToken: t.token.RefreshToken,
|
||||||
|
Expiry: t.token.Expiry,
|
||||||
|
Extra: t.token.Extra,
|
||||||
|
Subject: t.token.Subject,
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetToken sets a token to the transport in a thread-safe way.
|
||||||
|
func (t *authorizedTransport) SetToken(token *Token) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
t.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken retrieves a new token, if a refreshing/fetching
|
||||||
|
// method is known and required credentials are presented
|
||||||
|
// (such as a refresh token).
|
||||||
|
func (t *authorizedTransport) RefreshToken() error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
token, err := t.fetcher.fetchToken(t.token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
t.token = token
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneRequest returns a clone of the provided *http.Request.
|
||||||
|
// The clone is a shallow copy of the struct and its Header map.
|
||||||
|
func cloneRequest(r *http.Request) *http.Request {
|
||||||
|
// shallow copy of the struct
|
||||||
|
r2 := new(http.Request)
|
||||||
|
*r2 = *r
|
||||||
|
// deep copy of the Header
|
||||||
|
r2.Header = make(http.Header)
|
||||||
|
for k, s := range r.Header {
|
||||||
|
r2.Header[k] = s
|
||||||
|
}
|
||||||
|
return r2
|
||||||
|
}
|
Loading…
Reference in New Issue