authhandler: Add support for 3-legged-OAuth
Added authhandler.go, which implements a TokenSource to support "three-legged OAuth 2.0" via a custom AuthorizationHandler.
Added example_test.go with a sample command line implementation for AuthorizationHandler.
This patch adds support for 3-legged-OAuth flow using an OAuth Client ID file downloaded from Google Cloud Console.
Change-Id: Iefe54494d6f3ee326a6b1b2a81a7d5d1a7ba3331
GitHub-Last-Rev: 48fc0367c2092baf97b8e09f03a94e7fe1ecd890
GitHub-Pull-Request: golang/oauth2#419
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/232238
Reviewed-by: Tyler Bui-Palsulich <tbp@google.com>
Reviewed-by: Shin Fan <shinfan@google.com>
Reviewed-by: Cody Oss <codyoss@google.com>
Trust: Shin Fan <shinfan@google.com>
Trust: Cody Oss <codyoss@google.com>
diff --git a/authhandler/authhandler.go b/authhandler/authhandler.go
new file mode 100644
index 0000000..69967cf
--- /dev/null
+++ b/authhandler/authhandler.go
@@ -0,0 +1,56 @@
+// Copyright 2021 The Go 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 authhandler implements a TokenSource to support
+// "three-legged OAuth 2.0" via a custom AuthorizationHandler.
+package authhandler
+
+import (
+ "context"
+ "errors"
+
+ "golang.org/x/oauth2"
+)
+
+// AuthorizationHandler is a 3-legged-OAuth helper that prompts
+// the user for OAuth consent at the specified auth code URL
+// and returns an auth code and state upon approval.
+type AuthorizationHandler func(authCodeURL string) (code string, state string, err error)
+
+// TokenSource returns an oauth2.TokenSource that fetches access tokens
+// using 3-legged-OAuth flow.
+//
+// The provided context.Context is used for oauth2 Exchange operation.
+//
+// The provided oauth2.Config should be a full configuration containing AuthURL,
+// TokenURL, and Scope.
+//
+// An environment-specific AuthorizationHandler is used to obtain user consent.
+//
+// Per the OAuth protocol, a unique "state" string should be specified here.
+// This token source will verify that the "state" is identical in the request
+// and response before exchanging the auth code for OAuth token to prevent CSRF
+// attacks.
+func TokenSource(ctx context.Context, config *oauth2.Config, state string, authHandler AuthorizationHandler) oauth2.TokenSource {
+ return oauth2.ReuseTokenSource(nil, authHandlerSource{config: config, ctx: ctx, authHandler: authHandler, state: state})
+}
+
+type authHandlerSource struct {
+ ctx context.Context
+ config *oauth2.Config
+ authHandler AuthorizationHandler
+ state string
+}
+
+func (source authHandlerSource) Token() (*oauth2.Token, error) {
+ url := source.config.AuthCodeURL(source.state)
+ code, state, err := source.authHandler(url)
+ if err != nil {
+ return nil, err
+ }
+ if state != source.state {
+ return nil, errors.New("state mismatch in 3-legged-OAuth flow")
+ }
+ return source.config.Exchange(source.ctx, code)
+}
diff --git a/authhandler/authhandler_test.go b/authhandler/authhandler_test.go
new file mode 100644
index 0000000..084198f
--- /dev/null
+++ b/authhandler/authhandler_test.go
@@ -0,0 +1,99 @@
+// Copyright 2021 The Go 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 authhandler
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "golang.org/x/oauth2"
+)
+
+func TestTokenExchange_Success(t *testing.T) {
+ authhandler := func(authCodeURL string) (string, string, error) {
+ if authCodeURL == "testAuthCodeURL?client_id=testClientID&response_type=code&scope=pubsub&state=testState" {
+ return "testCode", "testState", nil
+ }
+ return "", "", fmt.Errorf("invalid authCodeURL: %q", authCodeURL)
+ }
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.ParseForm()
+ if r.Form.Get("code") == "testCode" {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{
+ "access_token": "90d64460d14870c08c81352a05dedd3465940a7c",
+ "scope": "pubsub",
+ "token_type": "bearer",
+ "expires_in": 3600
+ }`))
+ }
+ }))
+ defer ts.Close()
+
+ conf := &oauth2.Config{
+ ClientID: "testClientID",
+ Scopes: []string{"pubsub"},
+ Endpoint: oauth2.Endpoint{
+ AuthURL: "testAuthCodeURL",
+ TokenURL: ts.URL,
+ },
+ }
+
+ tok, err := TokenSource(context.Background(), conf, "testState", authhandler).Token()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !tok.Valid() {
+ t.Errorf("got invalid token: %v", tok)
+ }
+ if got, want := tok.AccessToken, "90d64460d14870c08c81352a05dedd3465940a7c"; got != want {
+ t.Errorf("access token = %q; want %q", got, want)
+ }
+ if got, want := tok.TokenType, "bearer"; got != want {
+ t.Errorf("token type = %q; want %q", got, want)
+ }
+ if got := tok.Expiry.IsZero(); got {
+ t.Errorf("token expiry is zero = %v, want false", got)
+ }
+ scope := tok.Extra("scope")
+ if got, want := scope, "pubsub"; got != want {
+ t.Errorf("scope = %q; want %q", got, want)
+ }
+}
+
+func TestTokenExchange_StateMismatch(t *testing.T) {
+ authhandler := func(authCodeURL string) (string, string, error) {
+ return "testCode", "testStateMismatch", nil
+ }
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{
+ "access_token": "90d64460d14870c08c81352a05dedd3465940a7c",
+ "scope": "pubsub",
+ "token_type": "bearer",
+ "expires_in": 3600
+ }`))
+ }))
+ defer ts.Close()
+
+ conf := &oauth2.Config{
+ ClientID: "testClientID",
+ Scopes: []string{"pubsub"},
+ Endpoint: oauth2.Endpoint{
+ AuthURL: "testAuthCodeURL",
+ TokenURL: ts.URL,
+ },
+ }
+
+ _, err := TokenSource(context.Background(), conf, "testState", authhandler).Token()
+ if want_err := "state mismatch in 3-legged-OAuth flow"; err == nil || err.Error() != want_err {
+ t.Errorf("err = %q; want %q", err, want_err)
+ }
+}
diff --git a/authhandler/example_test.go b/authhandler/example_test.go
new file mode 100644
index 0000000..a62b4e1
--- /dev/null
+++ b/authhandler/example_test.go
@@ -0,0 +1,79 @@
+// Copyright 2021 The Go 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 authhandler_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+
+ "golang.org/x/oauth2"
+ "golang.org/x/oauth2/authhandler"
+)
+
+// CmdAuthorizationHandler returns a command line auth handler that prints
+// the auth URL to the console and prompts the user to authorize in the
+// browser and paste the auth code back via stdin.
+//
+// Per the OAuth protocol, a unique "state" string should be specified here.
+// The authhandler token source will verify that the "state" is identical in
+// the request and response before exchanging the auth code for OAuth token to
+// prevent CSRF attacks.
+//
+// For convenience, this handler returns a pre-configured state instead of
+// asking the user to additionally paste the state from the auth response.
+// In order for this to work, the state configured here must match the state
+// used in authCodeURL.
+func CmdAuthorizationHandler(state string) authhandler.AuthorizationHandler {
+ return func(authCodeURL string) (string, string, error) {
+ fmt.Printf("Go to the following link in your browser:\n\n %s\n\n", authCodeURL)
+ fmt.Println("Enter authorization code:")
+ var code string
+ fmt.Scanln(&code)
+ return code, state, nil
+ }
+}
+
+func Example() {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.ParseForm()
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{
+ "access_token": "90d64460d14870c08c81352a05dedd3465940a7c",
+ "scope": "pubsub",
+ "token_type": "bearer",
+ "expires_in": 3600
+ }`))
+ }))
+ defer ts.Close()
+
+ ctx := context.Background()
+ conf := &oauth2.Config{
+ ClientID: "testClientID",
+ Scopes: []string{"pubsub"},
+ Endpoint: oauth2.Endpoint{
+ AuthURL: "testAuthCodeURL",
+ TokenURL: ts.URL,
+ },
+ }
+ state := "unique_state"
+
+ token, err := authhandler.TokenSource(ctx, conf, state, CmdAuthorizationHandler(state)).Token()
+
+ if err != nil {
+ fmt.Println(err)
+ }
+
+ fmt.Printf("AccessToken: %s", token.AccessToken)
+
+ // Output:
+ // Go to the following link in your browser:
+ //
+ // testAuthCodeURL?client_id=testClientID&response_type=code&scope=pubsub&state=unique_state
+ //
+ // Enter authorization code:
+ // AccessToken: 90d64460d14870c08c81352a05dedd3465940a7c
+}