From e4d871995ac34c357be4235f247a5d2f1f21da8c Mon Sep 17 00:00:00 2001 From: ulisesL Date: Tue, 28 Jun 2022 15:41:50 -0700 Subject: [PATCH] Add support for loopback 3LO flow. (#132) 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. --- README.md | 31 ++ integration/cli_test.go | 119 ++++++- .../fake-client-secrets-3lo-loopback.json | 16 + integration/golden/curl-3lo-loopback.golden | 4 + .../fetch-3lo-loopback-refresh-token.golden | 4 + .../golden/fetch-3lo-loopback-userinfo.golden | 4 + integration/golden/fetch-3lo-loopback.golden | 4 + integration/golden/fetch-impersonation.golden | 16 +- integration/golden/header-3lo-loopback.golden | 4 + main.go | 76 ++-- util/auth-handlers.go | 102 ++++++ util/browser.go | 48 +++ util/cache.go | 11 +- util/client-id-file.go | 54 +++ util/loopback.go | 326 ++++++++++++++++++ 15 files changed, 796 insertions(+), 23 deletions(-) create mode 100644 integration/fixtures/fake-client-secrets-3lo-loopback.json create mode 100644 integration/golden/curl-3lo-loopback.golden create mode 100644 integration/golden/fetch-3lo-loopback-refresh-token.golden create mode 100644 integration/golden/fetch-3lo-loopback-userinfo.golden create mode 100644 integration/golden/fetch-3lo-loopback.golden create mode 100644 integration/golden/header-3lo-loopback.golden create mode 100644 util/auth-handlers.go create mode 100644 util/browser.go create mode 100644 util/client-id-file.go create mode 100644 util/loopback.go diff --git a/README.md b/README.md index 5f3c914..2bff387 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/integration/cli_test.go b/integration/cli_test.go index 57e4876..5970689 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -25,6 +25,7 @@ import ( "os/exec" "path/filepath" "reflect" + "regexp" "runtime" "strings" "testing" @@ -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{ @@ -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{ @@ -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", @@ -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 { diff --git a/integration/fixtures/fake-client-secrets-3lo-loopback.json b/integration/fixtures/fake-client-secrets-3lo-loopback.json new file mode 100644 index 0000000..7b103b7 --- /dev/null +++ b/integration/fixtures/fake-client-secrets-3lo-loopback.json @@ -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" + } +} diff --git a/integration/golden/curl-3lo-loopback.golden b/integration/golden/curl-3lo-loopback.golden new file mode 100644 index 0000000..0b184ac --- /dev/null +++ b/integration/golden/curl-3lo-loopback.golden @@ -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. diff --git a/integration/golden/fetch-3lo-loopback-refresh-token.golden b/integration/golden/fetch-3lo-loopback-refresh-token.golden new file mode 100644 index 0000000..0b184ac --- /dev/null +++ b/integration/golden/fetch-3lo-loopback-refresh-token.golden @@ -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. diff --git a/integration/golden/fetch-3lo-loopback-userinfo.golden b/integration/golden/fetch-3lo-loopback-userinfo.golden new file mode 100644 index 0000000..2b49b6b --- /dev/null +++ b/integration/golden/fetch-3lo-loopback-userinfo.golden @@ -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. diff --git a/integration/golden/fetch-3lo-loopback.golden b/integration/golden/fetch-3lo-loopback.golden new file mode 100644 index 0000000..0b184ac --- /dev/null +++ b/integration/golden/fetch-3lo-loopback.golden @@ -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. diff --git a/integration/golden/fetch-impersonation.golden b/integration/golden/fetch-impersonation.golden index 6d3f9c3..d76c1df 100644 --- a/integration/golden/fetch-impersonation.golden +++ b/integration/golden/fetch-impersonation.golden @@ -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" + } + } + ] } } diff --git a/integration/golden/header-3lo-loopback.golden b/integration/golden/header-3lo-loopback.golden new file mode 100644 index 0000000..0b184ac --- /dev/null +++ b/integration/golden/header-3lo-loopback.golden @@ -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. diff --git a/main.go b/main.go index 877fc8b..1042e8c 100644 --- a/main.go +++ b/main.go @@ -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 ( @@ -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"` @@ -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 { @@ -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 @@ -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, diff --git a/util/auth-handlers.go b/util/auth-handlers.go new file mode 100644 index 0000000..7f078f2 --- /dev/null +++ b/util/auth-handlers.go @@ -0,0 +1,102 @@ +// +// Copyright 2020 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Contains authorization handler functions. +package util + +import ( + "fmt" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2/authhandler" +) + +// 3LO authorization handler. Determines what algorithm to use +// to get the authorization code. +// +// Note that the "state" parameter is used to prevent CSRF attacks. +func Get3LOAuthorizationHandler(state string, consentSettings ConsentPageSettings, + authCodeServer *AuthorizationCodeServer) authhandler.AuthorizationHandler { + return func(authCodeURL string) (string, string, error) { + decodedValue, _ := url.ParseQuery(authCodeURL) + redirectURL := decodedValue.Get("redirect_uri") + + if strings.Contains(redirectURL, "localhost") { + return authorization3LOLoopback(authCodeURL, consentSettings, authCodeServer) + } + + return authorization3LOOutOfBand(state, authCodeURL) + } +} + +// authorization3LOOutOfBand 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, authorization3LOOutOfBand returns a pre-configured state +// instead of requiring the user to copy it from the browser. +func authorization3LOOutOfBand(state string, 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 +} + +// authorization3LOLoopback prints the authorization URL on stdout +// and redirects the user to the authCodeURL in a new browser's tab. +// if `DisableAutoOpenConsentPage` is set, then the user is instructed +// to manually open the authCodeURL in a new browser's tab. +// +// The code and state output parameters in this function are the same +// as the ones generated after the user grants permission on the consent page. +// When the user interacts with the consent page, an error or a code-state-tuple +// is expected to be returned to the Auth Code Localhost Server endpoint +// (see loopback.go for more info). +func authorization3LOLoopback(authCodeURL string, consentSettings ConsentPageSettings, + authCodeServer *AuthorizationCodeServer) (string, string, error) { + const ( + // Max wait time for the server to start listening and serving + maxWaitForListenAndServe time.Duration = 10 * time.Second + ) + + // (Step 1) Start local Auth Code Server + if started, _ := (*authCodeServer).WaitForListeningAndServing(maxWaitForListenAndServe); started { + // (Step 2) Provide access to the consent page + if consentSettings.DisableAutoOpenConsentPage { // Auto open consent disabled + fmt.Println("Go to the following link in your browser:") + fmt.Println("\n", authCodeURL) + } else { // Auto open consent + b := Browser{} + if be := b.OpenURL(authCodeURL); be != nil { + fmt.Println("Your browser could not be opened to visit:") + fmt.Println("\n", authCodeURL) + } else { + fmt.Println("Your browser has been opened to visit:") + fmt.Println("\n", authCodeURL) + } + } + + // (Step 3) Wait for user to interact with consent page + (*authCodeServer).WaitForConsentPageToReturnControl() + } + + // (Step 4) Attempt to get Authorization code. If one was not received + // default string values are returned. + code, err := (*authCodeServer).GetAuthenticationCode() + return code.Code, code.State, err +} diff --git a/util/browser.go b/util/browser.go new file mode 100644 index 0000000..0af719f --- /dev/null +++ b/util/browser.go @@ -0,0 +1,48 @@ +// +// Copyright 2020 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// browser implements helper functions to interact with the OS's default +// internet browser. MacOs, Windows and Linux are the only supported OS. +package util + +import ( + "fmt" + "os/exec" + "runtime" +) + +// Browser represents an internet browser. +type Browser struct{} + +// Opens URL in a new broser tab. +func (b *Browser) OpenURL(url string) error { + var err error + rt := runtime.GOOS + switch rt { + case "darwin": + err = exec.Command("open", url).Start() + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + default: + err = fmt.Errorf("Unsupported runtime") + } + + if err != nil { + return fmt.Errorf("Unable to open browser window for runtime: %s: %v", rt, err) + } + return nil +} diff --git a/util/cache.go b/util/cache.go index b15e1e6..c8ecaf4 100644 --- a/util/cache.go +++ b/util/cache.go @@ -21,6 +21,8 @@ import ( "os" "os/user" "path/filepath" + "regexp" + "strings" "golang.org/x/oauth2" ) @@ -127,8 +129,15 @@ func loadCache() (map[string][]byte, error) { } func createKey(settings *Settings) CacheKey { + // Removing redirect_uri from credentials file. This allows for dynamic + // localhost ports created during 3LO loopback. + var credentialsJSON string = settings.CredentialsJSON + re := regexp.MustCompile("\"redirect_uris\":\\[(.*?)\\]") + match := re.FindString(credentialsJSON) + credentialsJSON = strings.Replace(credentialsJSON, match, "\"redirect_uris\":[]", 1) + return CacheKey{ - CredentialsJSON: settings.CredentialsJSON, + CredentialsJSON: credentialsJSON, Scope: settings.Scope, Audience: settings.Audience, Email: settings.Email, diff --git a/util/client-id-file.go b/util/client-id-file.go new file mode 100644 index 0000000..ddba438 --- /dev/null +++ b/util/client-id-file.go @@ -0,0 +1,54 @@ +// +// Copyright 2020 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// clientIdFile implements several helper functions (wrapping around google package) +// to manipulate the OAuth Client ID file. +package util + +import ( + "golang.org/x/oauth2/google" +) + +// IsValidOauthClientIdFile determines if a valid OAuth Client ID file can be created +// from a credentials json file. +// +// credentialsJSON represents the credentials json file. +// +// Returns isValidCredFile: true if it can be recreated, false otherwise. +func IsValidOauthClientIdFile(credentialsJSON string) (isValidCredFile bool) { + if credentialsJSON == "" { + return false + } + + data := []byte(credentialsJSON) + _, err := google.ConfigFromJSON(data) + return err == nil +} + +// getFirstRedirectURI returns the the first URI in "redirect_uris" +// +// credentialsJSON represents the credentials json file. +// +// Returns firstRedirectURI: is the address of the first URI in "redirect_uris". +// Returns err: if unable to process the credentialsJSON file. +func GetFirstRedirectURI(credentialsJSON string) (firstRedirectURI string, err error) { + data := []byte(credentialsJSON) + credentials, err := google.ConfigFromJSON(data) + if err != nil { + return "", err + } + + return credentials.RedirectURL, nil +} diff --git a/util/loopback.go b/util/loopback.go new file mode 100644 index 0000000..0f7687f --- /dev/null +++ b/util/loopback.go @@ -0,0 +1,326 @@ +// +// Copyright 2020 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// loopback implements an authorization code localhost server that +// handles 3LO loopback flows. (see AuthorizationCodeServer interface) +package util + +import ( + "fmt" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +type AuthorizationCodeRequestStatus int + +// Phases of the authorization code +const ( + // Waiting for authorization code + // (waiting for authorization code request to start, + // or for authorization code request to complete) + WAITING AuthorizationCodeRequestStatus = iota + // Athorization code successfully granted. + GRANTED + // Failed to grant authorization code + FAILED +) + +// AuthorizationCodeServer represents a localhost server +// that handles the Loopback 3LO authorization +type AuthorizationCodeServer interface { + // Starts listening and serving on the provided address. + // If no port is specified in the address, an available port is assigned. + // + // Input address: represents a localhost address. Its format is http://localhost[:port] + // + // Returns serverAddress: is the address of the listener. Its format is http://localhost[:port] + // Returns err: if server fails to listen or serve. + ListenAndServe(address string) (serverAddress string, err error) + + // Stops listening and serving. + Close() + + // IsListeningAndServing determines if the server is listening and serving. + // + // Returns isLisAndServ: true if this is listening and serving, false otherwise. + IsListeningAndServing() (isLisAndServ bool) + + // WaitForListeningAndServing waits until the server is listening and serving, + // or until a timeout occurs. + // + // Input maxWaitTime: is the maximum time to wait for the server to start + // listening and serving. + // + // Returns isLisAndServ: true if the server is listening and serving. + // false if the server fails to listen and server before + // Returns err: if isLisAndServ is false. + WaitForListeningAndServing(maxWaitTime time.Duration) (isLisAndServ bool, err error) + + // Returns the AuthorizationCode. + // + // Returns authCode: represents the authorization code. + // if not yet granted its value is an empty string. + // Returns err: is not nil if the code has not been granted. + GetAuthenticationCode() (authCode AuthorizationCode, err error) + + // WaitForConsentPageToReturnControl waits until the consent page returns control. + // + // Returns err: if the consent page fails to return control + // within the maxWaitTime. + WaitForConsentPageToReturnControl() (err error) +} + +// AuthorizationCode represents the authorization code +type AuthorizationCode struct { + Code string + State string +} + +// AuthorizationCodeStatus represents the state +// of the authorization code +type AuthorizationCodeStatus struct { + Status AuthorizationCodeRequestStatus + Details string +} + +// ConsentPageSettings is a 3-legged-OAuth helper that +// contains the settings for the interaction with the consent page +type ConsentPageSettings struct { + // DisableAutoOpenConsentPage controls the feature to automatically + // open the browser to vist the consent page + DisableAutoOpenConsentPage bool + // InteractionTimeout is the maximum time to wait for the user + // to interact with the consent page + InteractionTimeout time.Duration +} + +// AuthorizationCodeLocalhost implements AuthorizationCodeServer. +// See interface for description +type AuthorizationCodeLocalhost struct { + AuthCodeReqStatus AuthorizationCodeStatus + ConsentPageSettings ConsentPageSettings + addr string + authCode AuthorizationCode + server *http.Server +} + +func (lh *AuthorizationCodeLocalhost) ListenAndServe(address string) (serverAddress string, err error) { + listener, serverAddress, err := getListener(address) + if err != nil { + return "", fmt.Errorf("Unable to Listen: %v", err) + } + + lh.addr = serverAddress + + // Setup local host in given address + mux := http.NewServeMux() + lh.server = &http.Server{Addr: strings.Replace(lh.addr, "http://", "", 1), Handler: mux} + mux.HandleFunc("/", lh.redirectUriHandler) + mux.HandleFunc("/status/get", lh.statusGetHandler) + + go func() { + // Start Listed and Serve + if err := lh.server.Serve(*listener); err != nil && err != http.ErrServerClosed { + fmt.Printf("Could not listen on address: %v. Error: %v\n", lh.addr, err) + } + }() + + return serverAddress, nil +} + +func (lh *AuthorizationCodeLocalhost) Close() { + if lh.server == nil { + return + } + + // Stoping server + lh.server.Close() + lh.server = nil + lh.addr = "" +} + +func (lh *AuthorizationCodeLocalhost) IsListeningAndServing() (isLisAndServ bool) { + if lh.server == nil { + return false + } + + _, err := http.Get(lh.addr + "/status/get") + return err == nil +} + +func (lh *AuthorizationCodeLocalhost) WaitForListeningAndServing(maxWaitTime time.Duration) (isLisAndServ bool, err error) { + if lh.server == nil { + return false, fmt.Errorf("Server has not been set.") + } + + timeout := false + timer := time.AfterFunc(maxWaitTime, func() { + timeout = true + }) + + defer timer.Stop() + + for !timeout && !lh.IsListeningAndServing() { + // Loop until: + // - maxWaitTime is reached + // - server is listening and serving + } + + if !lh.IsListeningAndServing() { + return false, fmt.Errorf("Timed out.") + } + return true, nil +} + +func (lh *AuthorizationCodeLocalhost) GetAuthenticationCode() (authCode AuthorizationCode, err error) { + if lh.AuthCodeReqStatus.Status != GRANTED { + return lh.authCode, fmt.Errorf(lh.AuthCodeReqStatus.Details) + } + return lh.authCode, nil +} + +func (lh *AuthorizationCodeLocalhost) WaitForConsentPageToReturnControl() (err error) { + if lh.server == nil { + return fmt.Errorf("Server has not been set.") + } + + timeout := false + timer := time.AfterFunc(lh.ConsentPageSettings.InteractionTimeout, func() { + timeout = true + }) + + defer timer.Stop() + + for !timeout && lh.AuthCodeReqStatus.Status == WAITING { + // Loop until: + // - maxWaitTime is reached + // - authorization code status is not waiting + } + + if lh.AuthCodeReqStatus.Status == WAITING { + return fmt.Errorf("Timed out.") + } + return nil +} + +// redirectUriHandler handles the redirect logic when aquiring the authorization code. +func (lh *AuthorizationCodeLocalhost) redirectUriHandler(w http.ResponseWriter, r *http.Request) { + const ( + closeTab string = ". Please close this tab." + ) + + rq := r.URL.RawQuery + urlValues, err := url.ParseQuery(rq) + if err != nil { + err := fmt.Sprintf("Unable to parse query: %v", err) + + lh.AuthCodeReqStatus = AuthorizationCodeStatus{Status: FAILED, Details: err} + lh.authCode = AuthorizationCode{} + w.WriteHeader(http.StatusOK) + w.Write([]byte(lh.AuthCodeReqStatus.Details + closeTab)) + return + } + + // Authentication Code Error from consent page + if urlValues.Has("error") { + err := fmt.Sprintf("An error occurred when getting authorization code: %s", + urlValues.Get("error")) + + lh.AuthCodeReqStatus = AuthorizationCodeStatus{Status: FAILED, Details: err} + lh.authCode = AuthorizationCode{} + w.WriteHeader(http.StatusOK) + w.Write([]byte(lh.AuthCodeReqStatus.Details + closeTab)) + return + } + + // No Code, Status, or Error is treated as unknown error + if !urlValues.Has("code") && !urlValues.Has("state") { + err := "Unknown error when getting athorization code" + lh.AuthCodeReqStatus = AuthorizationCodeStatus{Status: FAILED, Details: err} + + lh.authCode = AuthorizationCode{} + w.WriteHeader(http.StatusOK) + w.Write([]byte(lh.AuthCodeReqStatus.Details + closeTab)) + return + } + + // Authorization code returned + if urlValues.Has("code") && urlValues.Has("state") { + + lh.authCode = AuthorizationCode{ + Code: urlValues.Get("code"), + State: urlValues.Get("state"), + } + + lh.AuthCodeReqStatus = AuthorizationCodeStatus{ + Status: GRANTED, Details: "Authorization code granted"} + + w.WriteHeader(http.StatusOK) + w.Write([]byte(lh.AuthCodeReqStatus.Details + closeTab)) + return + } + + err = fmt.Errorf("Athorization code missing code or state.") + lh.AuthCodeReqStatus = AuthorizationCodeStatus{Status: FAILED, Details: err.Error()} + + lh.authCode = AuthorizationCode{} + w.WriteHeader(http.StatusOK) + w.Write([]byte(lh.AuthCodeReqStatus.Details + closeTab)) + return +} + +// statusGetHandler handles request to get the localhost status +func (lh *AuthorizationCodeLocalhost) statusGetHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Status OK")) + return +} + +// getListener gets a listener on the port specified in the address. +// If no port is specified in the address, an available port is assigned. +// +// Input address: represents a localhost address. Its format is http://localhost[:port] +// +// Returns listener +// Returns serverAddress: is the address of the listener. Its format is http://localhost[:port] +// Returns err: if not nil an error occurred when creating the listener. +func getListener(address string) (listener *net.Listener, serverAddress string, err error) { + var l net.Listener = nil + + re := regexp.MustCompile("localhost:\\d+") + match := re.FindString(address) + + if match == "" { // Case: No given port provided for localhost + // Creating a listener on the next available port + l, err = net.Listen("tcp", ":0") + } else { // Case: Port provided for localhost + // Creating a listener on the provided port + l, err = net.Listen("tcp", strings.Replace(match, "localhost", "", 1)) + } + + if err != nil { + return nil, "", fmt.Errorf("Unable to open port: %v", err) + } + + tcpPort := (l).Addr().(*net.TCPAddr).Port + // Updating redirect uri to reflect port to use. + localhostAddr := "http://localhost:" + strconv.Itoa(tcpPort) + return &l, localhostAddr, nil +}