Skip to content

Commit

Permalink
Add support for loopback 3LO flow. (#132)
Browse files Browse the repository at this point in the history
Uses a local server to capture auth code and state when redirect url is set to "localhost[:port]" inside the client ID json credentials - this is known as "loopback 3LO flow" and phases out the old "Out-of-band 3LO flow", which will stop being supported by Google auth backend in Oct 2022. Note that PKCE integration will be added in a separate PR.
  • Loading branch information
ulisesL authored Jun 28, 2022
1 parent 05472fe commit e4d8719
Show file tree
Hide file tree
Showing 15 changed files with 796 additions and 23 deletions.
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,15 @@ $ export GOOGLE_APPLICATION_CREDENTIALS="~/service_account.json"
$ oauth2l fetch --scope cloud-platform
```

When using an OAuth client ID file, the following applies:

If the first `redirect_uris` in the `--credentials client_id.json` is set to `urn:ietf:wg:oauth:2.0:oob`,
the 3LO out of band flow is activated. NOTE: 3LO out of band flow has been deprecated and will stop working entirely in Oct 2022.

If the first `redirect_uris` in the `--credentials client_id.json` is set to `http://localhost[:PORT]`,
the 3LO loopback flow is activated. When the port is omitted, an available port will be used to spin up the localhost.
When a port is provided, oauth2l will attempt to use such port. If the port cannot be used, oauth2l will stop.

### --type

The authentication type. The currently supported types are "oauth", "jwt", or
Expand Down Expand Up @@ -390,6 +399,28 @@ Impersonation [here](https://cloud.google.com/iam/docs/impersonating-service-acc
$ oauth2l fetch --credentials ~/client_credentials.json --scope cloud-platform,pubsub --impersonate-service-account 113258942105700140798
```

### --disableAutoOpenConsentPage

Disables the feature to automatically open the consent page in 3LO loopback flows.
When this option is used, the user will be provided with a URL to manually interact with the consent page.
This flag does not take any arguments. Simply add the option to disable this feature.

```bash
$ oauth2l fetch --credentials ~/client_credentials.json --disableAutoOpenConsentPage --consentPageInteractionTimeout 60 --consentPageInteractionTimeoutUnits seconds --scope cloud-platform
```

### --consentPageInteractionTimeout

Amount of time to wait for a user to interact with the consent page in 3LO loopback flows.
Once the time has lapsed, the localhost at the `redirect_uri` will no longer be available.
Its default value is 2. See `--consentPageInteractionTimeoutUnits` to change the units.

### --consentPageInteractionTimeoutUnits

Units of measurement to use when `--consentPageInteractionTimeout` is set.
Its default value is `minutes`. Valid inputs are `seconds` and `minutes`.
This option only affects 3LO loopback flows.

### fetch --output_format

Token's output format for "fetch" command. One of bare, header, json, json_compact, pretty, or refresh_token. Default is bare.
Expand Down
119 changes: 118 additions & 1 deletion integration/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"os/exec"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strings"
"testing"
Expand Down Expand Up @@ -217,6 +218,8 @@ func TestCLI(t *testing.T) {
runTestScenarios(t, tests)
}

// TODO: Remove this flow when the 3LO flow is deprecated. A replicated set of test is now part of Test3LOLoopbackFlow.
// tests in Test3LOLoopbackFlow have been updated to account for new outputs.
// Test OAuth 3LO flow with fake client secrets. Fake verification code is injected to stdin to advance the flow.
func Test3LOFlow(t *testing.T) {
tests := []testCase{
Expand Down Expand Up @@ -290,6 +293,100 @@ func Test3LOFlow(t *testing.T) {
runTestScenariosWithInput(t, tests, newFixture(t, "fake-verification-code.fixture").asFile())
}

// TODO: Enhance tests so that the entire loopback flow can be tested
// TODO: Once enhanced, uncomment and fix cache tests in this flow
// TODO: Remove Test3LOFlow once the 3LO flow is deprecated
// Test OAuth 3LO loopback flow with fake client secrets. Stops waiting for consent page interaction to advance the flow.
func Test3LOLoopbackFlow(t *testing.T) {
tests := []testCase{
{
"fetch; 3lo loopback",
[]string{"fetch", "--scope", "pubsub", "--credentials", "integration/fixtures/fake-client-secrets-3lo-loopback.json", "--cache", "",
"--disableAutoOpenConsentPage",
"--consentPageInteractionTimeout", "1", "--consentPageInteractionTimeoutUnits", "seconds"},
"fetch-3lo-loopback.golden",
false,
},
{
"fetch; 3lo loopback; old interface",
[]string{"fetch", "--json", "integration/fixtures/fake-client-secrets-3lo-loopback.json", "--cache", "", "pubsub",
"--disableAutoOpenConsentPage",
"--consentPageInteractionTimeout", "1", "--consentPageInteractionTimeoutUnits", "seconds"},
"fetch-3lo-loopback.golden",
false,
},
{
"fetch; 3lo loopback; userinfo scopes",
[]string{"fetch", "--scope", "userinfo.profile,userinfo.email", "--credentials", "integration/fixtures/fake-client-secrets-3lo-loopback.json", "--cache", "",
"--consentPageInteractionTimeout", "1", "--consentPageInteractionTimeoutUnits", "seconds",
"--disableAutoOpenConsentPage"},
"fetch-3lo-loopback-userinfo.golden",
false,
},
{
"header; 3lo loopback",
[]string{"header", "--scope", "pubsub", "--credentials", "integration/fixtures/fake-client-secrets-3lo-loopback.json", "--cache", "",
"--consentPageInteractionTimeout", "1", "--consentPageInteractionTimeoutUnits", "seconds",
"--disableAutoOpenConsentPage"},
"header-3lo-loopback.golden",
false,
},
{
"fetch; 3lo loopback; refresh token output format",
[]string{"fetch", "--output_format", "refresh_token", "--scope", "pubsub", "--credentials", "integration/fixtures/fake-client-secrets-3lo-loopback.json", "--cache", "",
"--consentPageInteractionTimeout", "1", "--consentPageInteractionTimeoutUnits", "seconds",
"--disableAutoOpenConsentPage"},
"fetch-3lo-loopback-refresh-token.golden",
false,
},
{
"curl; 3lo loopback",
[]string{"curl", "--scope", "pubsub", "--credentials", "integration/fixtures/fake-client-secrets-3lo-loopback.json", "--url", "http://localhost:8080/curl",
"--consentPageInteractionTimeout", "1", "--consentPageInteractionTimeoutUnits", "seconds",
"--disableAutoOpenConsentPage"},
"curl-3lo-loopback.golden",
false,
},
/*
{
"fetch; 3lo loopback cached",
[]string{"fetch", "--scope", "pubsub", "--credentials", "integration/fixtures/fake-client-secrets-3lo-loopback.json", "--consentPageInteractionTimeout", "1", "--consentPageInteractionTimeoutUnits", "seconds"},
"fetch-3lo-cached.golden",
false,
},
{
"fetch; 3lo loopback insert expired token into cache",
[]string{"fetch", "--scope", "pubsub", "--credentials", "integration/fixtures/fake-client-secrets-expired-token-3lo-loopback.json",
"--consentPageInteractionTimeout", "1", "--consentPageInteractionTimeoutUnits", "seconds"},
"fetch-3lo.golden",
false,
},
{
"fetch; 3lo loopback cached; token expired",
[]string{"fetch", "--scope", "pubsub", "--credentials", "integration/fixtures/fake-client-secrets-expired-token-3lo-loopback.json",
"--consentPageInteractionTimeout", "1", "--consentPageInteractionTimeoutUnits", "seconds"},
"fetch-3lo.golden",
false,
},
{
"fetch; 3lo loopback cached; refresh expired token",
[]string{"fetch", "--scope", "pubsub", "--credentials", "integration/fixtures/fake-client-secrets-expired-token-3lo-loopback.json", "--refresh",
"--consentPageInteractionTimeout", "1", "--consentPageInteractionTimeoutUnits", "seconds"},
"fetch-3lo-cached.golden",
false,
},*/
}

process3LOOutput := func(output string) string {
re := regexp.MustCompile("redirect_uri=http%3A%2F%2Flocalhost%3A\\d+")
match := re.FindString(output)
output = strings.Replace(output, match, "redirect_uri=http%3A%2F%2Flocalhost", 1)
return output
}

runTestScenariosWithInputAndProcessedOutput(t, tests, nil, process3LOOutput)
}

// Test OAuth 2LO Flow with fake service account.
func Test2LOFlow(t *testing.T) {
tests := []testCase{
Expand Down Expand Up @@ -408,6 +505,7 @@ func TestStsFlow(t *testing.T) {
// Test Service Account Impersonation Flow.
// This currently sends request to the real IAM endpoint, which will return 401 for having invalid user access token, which is expected.
func TestServiceAccountImpersonationFlow(t *testing.T) {

tests := []testCase{
{
"fetch; sso; impersonation",
Expand All @@ -416,7 +514,26 @@ func TestServiceAccountImpersonationFlow(t *testing.T) {
false,
},
}
runTestScenarios(t, tests)

processOutput := func(output string) string {

method := "\"method\": \"google.iam.credentials.v1.IAMCredentials.GenerateAccessToken\""
service := "\"service\": \"iamcredentials.googleapis.com\""

mPos := strings.Index(output, method)
sPos := strings.Index(output, service)

// If service appears later than method, revert order to match output
if sPos > mPos {
output = strings.Replace(output, method, "**MARKER-1**", 1)
output = strings.Replace(output, service, method, 1)
output = strings.Replace(output, "**MARKER-1**", service, 1)
}

return output
}

runTestScenariosWithInputAndProcessedOutput(t, tests, nil, processOutput)
}

func readFile(path string) string {
Expand Down
16 changes: 16 additions & 0 deletions integration/fixtures/fake-client-secrets-3lo-loopback.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"installed": {
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"client_email": "",
"client_id": "144169.apps.googleusercontent.com",
"project_id":"awesomeproject",
"client_secret": "awesomesecret",
"client_x509_cert_url": "",
"redirect_uris": [
"http://localhost",
"urn:ietf:wg:oauth:2.0:oob"
],
"token_uri": "http://localhost:8080/token"
}
}
4 changes: 4 additions & 0 deletions integration/golden/curl-3lo-loopback.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Go to the following link in your browser:

https://accounts.google.com/o/oauth2/auth?client_id=144169.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fpubsub&state=state
Authorization code not yet set.
4 changes: 4 additions & 0 deletions integration/golden/fetch-3lo-loopback-refresh-token.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Go to the following link in your browser:

https://accounts.google.com/o/oauth2/auth?client_id=144169.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fpubsub&state=state
Authorization code not yet set.
4 changes: 4 additions & 0 deletions integration/golden/fetch-3lo-loopback-userinfo.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Go to the following link in your browser:

https://accounts.google.com/o/oauth2/auth?client_id=144169.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&state=state
Authorization code not yet set.
4 changes: 4 additions & 0 deletions integration/golden/fetch-3lo-loopback.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Go to the following link in your browser:

https://accounts.google.com/o/oauth2/auth?client_id=144169.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fpubsub&state=state
Authorization code not yet set.
16 changes: 15 additions & 1 deletion integration/golden/fetch-impersonation.golden
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,21 @@
"error": {
"code": 401,
"message": "Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.",
"status": "UNAUTHENTICATED"
"status": "UNAUTHENTICATED",
"details": [
{
"@type": "type.googleapis.com/google.rpc.DebugInfo",
"detail": "Authentication error: 16; Error Details: Credential sent is invalid. Unknown token version 0 for token string: ya29.GltDB_y~"
},
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "ACCESS_TOKEN_TYPE_UNSUPPORTED",
"metadata": {
"service": "iamcredentials.googleapis.com",
"method": "google.iam.credentials.v1.IAMCredentials.GenerateAccessToken"
}
}
]
}
}

4 changes: 4 additions & 0 deletions integration/golden/header-3lo-loopback.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Go to the following link in your browser:

https://accounts.google.com/o/oauth2/auth?client_id=144169.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fpubsub&state=state
Authorization code not yet set.
76 changes: 56 additions & 20 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,10 @@ import (
"os"
"regexp"
"strings"
"time"

"github.com/google/oauth2l/util"
"github.com/jessevdk/go-flags"

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

const (
Expand Down Expand Up @@ -84,6 +83,11 @@ type commonFetchOptions struct {
// Refresh is used for 3LO flow. When used in conjunction with caching, the user can avoid re-authorizing.
Refresh bool `long:"refresh" description:"If the cached access token is expired, attempt to refresh it using refreshToken."`

// Consent page parameters.
DisableAutoOpenConsentPage bool `long:"disableAutoOpenConsentPage" description:"Disables the ability to open the consent page automatically."`
ConsentPageInteractionTimeout int `long:"consentPageInteractionTimeout" description:"Maximum wait time for user to interact with consent page." default:"2"`
ConsentPageInteractionTimeoutUnits string `long:"consentPageInteractionTimeoutUnits" choice:"seconds" choice:"minutes" description:"Consent page timeout units." default:"minutes"`

// Deprecated flags kept for backwards compatibility. Hidden from help page.
Json string `long:"json" description:"Deprecated. Same as --credentials." hidden:"true"`
Jwt bool `long:"jwt" description:"Deprecated. Same as --type jwt." hidden:"true"`
Expand Down Expand Up @@ -138,22 +142,6 @@ func readJSON(file string) (string, error) {
return "", nil
}

// Default 3LO authorization handler. Prints the authorization URL on stdout
// and reads the authorization code from stdin.
//
// Note that the "state" parameter is used to prevent CSRF attacks.
// For convenience, CmdAuthorizationHandler returns a pre-configured state
// instead of requiring the user to copy it from the browser.
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
}
}

// Append Google OAuth scope prefix if not provided and joins
// the slice into a whitespace-separated string.
func parseScopes(scopes []string) string {
Expand Down Expand Up @@ -193,6 +181,18 @@ func getCommonFetchOptions(cmdOpts commandOptions, cmd string) commonFetchOption
return commonOpts
}

// Generates a time duration
func getTimeDuration(quantity int, units string) (time.Duration, error) {
switch units {
case "seconds":
return time.Duration(quantity) * time.Second, nil
case "minutes":
return time.Duration(quantity) * time.Minute, nil
default:
return time.Duration(0), fmt.Errorf("Invalid units: %s", units)
}
}

// Get the authentication type, with backward compatibility.
func getAuthTypeWithFallback(commonOpts commonFetchOptions) string {
authType := commonOpts.AuthType
Expand Down Expand Up @@ -371,12 +371,48 @@ func main() {
return
}

var authCodeServer util.AuthorizationCodeServer = nil
var consentPageSettings util.ConsentPageSettings
redirectUri, err := util.GetFirstRedirectURI(json)
// 3LO Loopback case
if err == nil && strings.Contains(redirectUri, "localhost") {
interactionTimeout, err := getTimeDuration(commonOpts.ConsentPageInteractionTimeout, commonOpts.ConsentPageInteractionTimeoutUnits)
if err != nil {
fmt.Println("Failed to create time.Duration: " + err.Error())
return
}
consentPageSettings = util.ConsentPageSettings{
DisableAutoOpenConsentPage: commonOpts.DisableAutoOpenConsentPage,
InteractionTimeout: interactionTimeout,
}
authCodeServer = &util.AuthorizationCodeLocalhost{
ConsentPageSettings: consentPageSettings,
AuthCodeReqStatus: util.AuthorizationCodeStatus{
Status: util.WAITING, Details: "Authorization code not yet set."},
}

// Start localhost server
adr, err := authCodeServer.ListenAndServe(redirectUri)
if err != nil {
fmt.Println(err)
return
}
// Close localhost server's port on exit
defer authCodeServer.Close()

// If a different dynamic redirect uri was created, replace the redirect uri in file.
// this happens if the original redirect does not have a port for the localhost.
redirectUri = fmt.Sprintf("\"%s\"", redirectUri)
adr = fmt.Sprintf("\"%s\"", adr)
json = strings.Replace(json, redirectUri, adr, -1)
}

// 3LO or 2LO depending on the credential type.
// For 2LO flow AuthHandler and State are not needed.
// For 2LO flow AuthHandler, State and ConsentPageSettings are not needed.
settings = &util.Settings{
CredentialsJSON: json,
Scope: parseScopes(scopes),
AuthHandler: cmdAuthorizationHandler(defaultState),
AuthHandler: util.Get3LOAuthorizationHandler(defaultState, consentPageSettings, &authCodeServer),
State: defaultState,
Audience: audience,
QuotaProject: quotaProject,
Expand Down
Loading

0 comments on commit e4d8719

Please sign in to comment.