Skip to content

Commit

Permalink
Support two-legged OAuth2 flows ("client credentials")
Browse files Browse the repository at this point in the history
By setting a token URL but not an auth URL, the credentials flow is
assumed to have a bearer token instead of needing an intermediate app
to authorize with.

Tested with a partial / PoC implementation of the Twitter API.
  • Loading branch information
mholt committed Feb 8, 2019
1 parent 31eb103 commit 210323b
Show file tree
Hide file tree
Showing 7 changed files with 92 additions and 52 deletions.
2 changes: 1 addition & 1 deletion cmd/timeliner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func loadConfig() error {
}

// TODO: Should this be passed into timeliner.Open() instead?
timeliner.OAuth2TokenSource = func(providerID string, scopes []string) (oauth2client.App, error) {
timeliner.OAuth2AppSource = func(providerID string, scopes []string) (oauth2client.App, error) {
cfg, ok := oauth2Configs[providerID]
if !ok {
return nil, fmt.Errorf("unsupported provider: %s", providerID)
Expand Down
2 changes: 1 addition & 1 deletion datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func (ds DataSource) authFunc() AuthenticateFn {
return ds.Authenticate
} else if ds.OAuth2.ProviderID != "" {
return func(userID string) ([]byte, error) {
return authorizeWithOAuth2(ds.OAuth2.ProviderID, ds.OAuth2.Scopes)
return authorizeWithOAuth2(ds.OAuth2)
}
}
return nil
Expand Down
1 change: 0 additions & 1 deletion datasources/googlephotos/googlephotos.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,6 @@ type checkpointInfo struct {
// save records the checkpoint. It is NOT thread-safe,
// so calls to this must be protected by a mutex.
func (ch *checkpointInfo) save(ctx context.Context) {
log.Printf("CHECKPOINTING: %+v", ch)
gobBytes, err := timeliner.MarshalGob(ch)
if err != nil {
log.Printf("[ERROR][%s] Encoding checkpoint: %v", DataSourceID, err)
Expand Down
20 changes: 10 additions & 10 deletions oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import (
"golang.org/x/oauth2"
)

// OAuth2TokenSource returns a TokenSource for the OAuth2 provider
// OAuth2AppSource returns an oauth2client.App for the OAuth2 provider
// with the given ID. Programs using data sources that authenticate
// with OAuth2 MUST set this field, or the program will panic.
var OAuth2TokenSource func(providerID string, scopes []string) (oauth2client.App, error)
// with OAuth2 MUST set this variable, or the program will panic.
var OAuth2AppSource func(providerID string, scopes []string) (oauth2client.App, error)

// NewOAuth2HTTPClient returns a new HTTP client which performs
// HTTP requests that are authenticated with an oauth2.Token
Expand All @@ -31,14 +31,14 @@ func (acc Account) NewOAuth2HTTPClient() (*http.Client, error) {
// load the service's "oauth app", which can provide both tokens and
// oauth configs -- in this case, we need the oauth config; we should
// already have a token
oapp, err := OAuth2TokenSource(acc.ds.OAuth2.ProviderID, acc.ds.OAuth2.Scopes)
oapp, err := OAuth2AppSource(acc.ds.OAuth2.ProviderID, acc.ds.OAuth2.Scopes)
if err != nil {
return nil, fmt.Errorf("getting token source for %s: %v", acc.DataSourceID, err)
}

// obtain a token source from the oauth's config so that it can keep
// the token refreshed if it expires
src := oapp.Config().TokenSource(context.Background(), tkn)
src := oapp.TokenSource(context.Background(), tkn)

// finally, create an HTTP client that authenticates using the token,
// but wrapping the underlying token source so we can persist any
Expand All @@ -51,14 +51,14 @@ func (acc Account) NewOAuth2HTTPClient() (*http.Client, error) {
}), nil
}

// authorizeWithOAuth2 gets an initial OAuth2 token from the user. It
// panics if OAuth2TokenSource
func authorizeWithOAuth2(providerID string, scopes []string) ([]byte, error) {
src, err := OAuth2TokenSource(providerID, scopes)
// authorizeWithOAuth2 gets an initial OAuth2 token from the user.
// It requires OAuth2AppSource to be set or it will panic.
func authorizeWithOAuth2(oc OAuth2) ([]byte, error) {
src, err := OAuth2AppSource(oc.ProviderID, oc.Scopes)
if err != nil {
return nil, fmt.Errorf("getting token source: %v", err)
}
tkn, err := src.Token()
tkn, err := src.InitialToken()
if err != nil {
return nil, fmt.Errorf("getting token from source: %v", err)
}
Expand Down
61 changes: 48 additions & 13 deletions oauth2client/localapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
)

// LocalAppSource implements oauth2.TokenSource for
Expand All @@ -13,35 +14,44 @@ import (
// locally. The OAuth2 provider is accessed directly
// using the OAuth2Config field value.
//
// LocalAppSource values can be ephemeral.
// If the OAuth2Config.Endpoint's TokenURL is set
// but the AuthURL is empty, then it is assumed
// that this is a two-legged ("client credentials")
// OAuth2 configuration; i.e. bearer token.
//
// LocalAppSource instances can be ephemeral.
type LocalAppSource struct {
// OAuth2Config is the OAuth2 configuration.
OAuth2Config *oauth2.Config

// AuthCodeGetter is how the auth code
// is obtained. If not set, a default
// oauth2code.Browser is used.
// oauth2client.Browser is used.
AuthCodeGetter Getter
}

// Config returns an OAuth2 config.
func (s LocalAppSource) Config() *oauth2.Config {
return s.OAuth2Config
}

// Token obtains a token using s.OAuth2Config.
func (s LocalAppSource) Token() (*oauth2.Token, error) {
// InitialToken obtains a token using s.OAuth2Config
// and s.AuthCodeGetter (unless the configuration
// is for a client credentials / "two-legged" flow).
func (s LocalAppSource) InitialToken() (*oauth2.Token, error) {
if s.OAuth2Config == nil {
return nil, fmt.Errorf("missing OAuth2Config")
}

// if this is a two-legged config ("client credentials" flow,
// where the client bears the actual token, like a password,
// without an intermediate app) configuration, then we can
// just return that bearer token immediately
if tlc := s.twoLeggedConfig(); tlc != nil {
return tlc.Token(context.Background())
}

if s.AuthCodeGetter == nil {
s.AuthCodeGetter = Browser{}
}

cfg := s.Config()

stateVal := State()
authURL := cfg.AuthCodeURL(stateVal, oauth2.AccessTypeOffline)
authURL := s.OAuth2Config.AuthCodeURL(stateVal, oauth2.AccessTypeOffline)

code, err := s.AuthCodeGetter.Get(stateVal, authURL)
if err != nil {
Expand All @@ -51,7 +61,32 @@ func (s LocalAppSource) Token() (*oauth2.Token, error) {
ctx := context.WithValue(context.Background(),
oauth2.HTTPClient, httpClient)

return cfg.Exchange(ctx, code)
return s.OAuth2Config.Exchange(ctx, code)
}

// TokenSource returns a token source for s.
func (s LocalAppSource) TokenSource(ctx context.Context, tkn *oauth2.Token) oauth2.TokenSource {
if tlc := s.twoLeggedConfig(); tlc != nil {
return tlc.TokenSource(ctx)
}
return s.OAuth2Config.TokenSource(ctx, tkn)
}

// twoLeggedConfig returns a clientcredentials configuration if
// this app source appears to be configured as one (i.e. with
// bearer credentials, with a token URL but without an auth URL,
// because the client credentials is the actual authentication).
func (s LocalAppSource) twoLeggedConfig() *clientcredentials.Config {
if s.OAuth2Config.Endpoint.TokenURL != "" &&
s.OAuth2Config.Endpoint.AuthURL == "" {
return &clientcredentials.Config{
ClientID: s.OAuth2Config.ClientID,
ClientSecret: s.OAuth2Config.ClientSecret,
TokenURL: s.OAuth2Config.Endpoint.TokenURL,
Scopes: s.OAuth2Config.Scopes,
}
}
return nil
}

var _ App = LocalAppSource{}
9 changes: 5 additions & 4 deletions oauth2client/oauth2.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package oauth2client

import (
"context"
mathrand "math/rand"
"net/http"
"time"
Expand Down Expand Up @@ -41,11 +42,11 @@ type (
AuthCodeURL string
}

// App provides methods for obtaining an
// OAuth2 config and an initial token.
// App provides a way to get an initial OAuth2 token
// as well as a continuing token source.
App interface {
oauth2.TokenSource
Config() *oauth2.Config
InitialToken() (*oauth2.Token, error)
TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource
}
)

Expand Down
49 changes: 27 additions & 22 deletions oauth2client/remoteapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,35 +46,16 @@ type RemoteAppSource struct {
AuthCodeGetter Getter
}

// Config returns an OAuth2 config.
func (s RemoteAppSource) Config() *oauth2.Config {
redirURL := s.RedirectURL
if redirURL == "" {
redirURL = DefaultRedirectURL
}

return &oauth2.Config{
ClientID: "placeholder",
ClientSecret: "placeholder",
RedirectURL: redirURL,
Scopes: s.Scopes,
Endpoint: oauth2.Endpoint{
AuthURL: s.ProxyURL + "/proxy/" + s.ProviderID + "/auth",
TokenURL: s.ProxyURL + "/proxy/" + s.ProviderID + "/token",
},
}
}

// Token obtains a token.
func (s RemoteAppSource) Token() (*oauth2.Token, error) {
// InitialToken obtains an initial token using s.AuthCodeGetter.
func (s RemoteAppSource) InitialToken() (*oauth2.Token, error) {
if s.AuthCodeGetter == nil {
s.AuthCodeGetter = Browser{}
}
if s.AuthURLMode == "" {
s.AuthURLMode = DirectAuthURLMode
}

cfg := s.Config()
cfg := s.config()

// obtain a state value and auth URL
var stateVal, authURL string
Expand Down Expand Up @@ -147,6 +128,30 @@ func (s RemoteAppSource) getProxiedAuthURL(cfg *oauth2.Config) (state string, au
return
}

// config builds an OAuth2 config from s.
func (s RemoteAppSource) config() *oauth2.Config {
redirURL := s.RedirectURL
if redirURL == "" {
redirURL = DefaultRedirectURL
}

return &oauth2.Config{
ClientID: "placeholder",
ClientSecret: "placeholder",
RedirectURL: redirURL,
Scopes: s.Scopes,
Endpoint: oauth2.Endpoint{
AuthURL: s.ProxyURL + "/proxy/" + s.ProviderID + "/auth",
TokenURL: s.ProxyURL + "/proxy/" + s.ProviderID + "/token",
},
}
}

// TokenSource returns a token source for s.
func (s RemoteAppSource) TokenSource(ctx context.Context, tkn *oauth2.Token) oauth2.TokenSource {
return s.config().TokenSource(ctx, tkn)
}

// AuthURLMode describes what kind of auth URL a
// RemoteAppSource should obtain.
type AuthURLMode string
Expand Down

0 comments on commit 210323b

Please sign in to comment.