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
+}