| // Copyright 2016 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. |
| |
| /* |
| Package auth implements the logic required to authenticate the user and |
| generate access tokens for use with GCR. |
| */ |
| package auth |
| |
| import ( |
| "crypto/rand" |
| "crypto/sha256" |
| "encoding/base64" |
| "fmt" |
| "io" |
| "io/ioutil" |
| "net" |
| "net/http" |
| "net/http/httputil" |
| "os" |
| "strings" |
| |
| "github.com/GoogleCloudPlatform/docker-credential-gcr/v2/config" |
| "github.com/toqueteos/webbrowser" |
| "golang.org/x/oauth2" |
| ) |
| |
| const redirectURIAuthCodeInTitleBar = "urn:ietf:wg:oauth:2.0:oob" |
| |
| // GCRLoginAgent implements the OAuth2 login dance, generating an Oauth2 access_token |
| // for the user. If AllowBrowser is set to true, the agent will attempt to |
| // obtain an authorization_code automatically by executing OpenBrowser and |
| // reading the redirect performed after a successful login. Otherwise, it will |
| // attempt to use In and Out to direct the user to the login portal and receive |
| // the authorization_code in response. |
| type GCRLoginAgent struct { |
| // Read input from here; if nil, uses os.Stdin. |
| In io.Reader |
| |
| // Write output to here; if nil, uses os.Stdout. |
| Out io.Writer |
| |
| // Open the browser for the given url. If nil, uses webbrowser.Open. |
| OpenBrowser func(url string) error |
| } |
| |
| // populate missing fields as described in the struct definition comments |
| func (a *GCRLoginAgent) init() { |
| if a.In == nil { |
| a.In = os.Stdin |
| } |
| if a.Out == nil { |
| a.Out = os.Stdout |
| } |
| if a.OpenBrowser == nil { |
| a.OpenBrowser = webbrowser.Open |
| } |
| } |
| |
| // PerformLogin performs the auth dance necessary to obtain an |
| // authorization_code from the user and exchange it for an Oauth2 access_token. |
| func (a *GCRLoginAgent) PerformLogin() (*oauth2.Token, error) { |
| a.init() |
| conf := &oauth2.Config{ |
| ClientID: config.GCRCredHelperClientID, |
| ClientSecret: config.GCRCredHelperClientNotSoSecret, |
| Scopes: config.GCRScopes, |
| Endpoint: config.GCROAuth2Endpoint, |
| } |
| |
| verifier, challenge, method, err := codeChallengeParams() |
| state, err := makeRandString(16) |
| if err != nil { |
| return nil, fmt.Errorf("Unable to build random string: %v", err) |
| } |
| authCodeOpts := []oauth2.AuthCodeOption{ |
| oauth2.AccessTypeOffline, |
| oauth2.SetAuthURLParam("code_challenge", challenge), |
| oauth2.SetAuthURLParam("code_challenge_method", method), |
| } |
| |
| // Browser based auth is the only mechanism supported now. |
| // Attempt to receive the authorization code via redirect URL |
| ln, port, err := getListener() |
| if err != nil { |
| return nil, fmt.Errorf("Unable to open local listener: %v", err) |
| } |
| defer ln.Close() |
| |
| // open a web browser and listen on the redirect URL port |
| conf.RedirectURL = fmt.Sprintf("http://localhost:%d", port) |
| url := conf.AuthCodeURL(state, authCodeOpts...) |
| err = a.OpenBrowser(url) |
| if err != nil { |
| return nil, fmt.Errorf("Unable to open browser: %v", err) |
| } |
| |
| code, err := handleCodeResponse(ln, state) |
| if err != nil { |
| return nil, fmt.Errorf("Response was invalid: %v", err) |
| } |
| |
| return conf.Exchange( |
| config.OAuthHTTPContext, |
| code, |
| oauth2.SetAuthURLParam("code_verifier", verifier)) |
| } |
| |
| func (a *GCRLoginAgent) codeViaPrompt(conf *oauth2.Config, authCodeOpts []oauth2.AuthCodeOption) (string, error) { |
| // Direct the user to our login portal |
| conf.RedirectURL = redirectURIAuthCodeInTitleBar |
| url := conf.AuthCodeURL("state", authCodeOpts...) |
| fmt.Fprintln(a.Out, "Please visit the following URL and complete the authorization dialog:") |
| fmt.Fprintf(a.Out, "%v\n", url) |
| |
| // Receive the authorization_code in response |
| fmt.Fprintln(a.Out, "Authorization code:") |
| var code string |
| if _, err := fmt.Fscan(a.In, &code); err != nil { |
| return "", err |
| } |
| |
| return code, nil |
| } |
| |
| func getListener() (net.Listener, int, error) { |
| laddr := net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} // port: 0 == find free port |
| ln, err := net.ListenTCP("tcp4", &laddr) |
| if err != nil { |
| return nil, 0, err |
| } |
| return ln, ln.Addr().(*net.TCPAddr).Port, nil |
| } |
| |
| func handleCodeResponse(ln net.Listener, stateCheck string) (string, error) { |
| conn, err := ln.Accept() |
| if err != nil { |
| return "", err |
| } |
| |
| srvConn := httputil.NewServerConn(conn, nil) |
| defer srvConn.Close() |
| |
| req, err := srvConn.Read() |
| if err != nil { |
| return "", err |
| } |
| |
| code := req.URL.Query().Get("code") |
| state := req.URL.Query().Get("state") |
| |
| resp := &http.Response{ |
| StatusCode: 200, |
| Proto: "HTTP/1.1", |
| ProtoMajor: 1, |
| ProtoMinor: 1, |
| Close: true, |
| ContentLength: -1, // designates unknown length |
| } |
| defer srvConn.Write(req, resp) |
| |
| // If the code couldn't be obtained, inform the user via the browser and |
| // return an error. |
| // TODO i18n? |
| if code == "" { |
| err := fmt.Errorf("Code not present in response: %s", req.URL.String()) |
| resp.Body = getResponseBody("ERROR: Authentication code not present in response.") |
| return "", err |
| } |
| |
| if state != stateCheck { |
| err := fmt.Errorf("Invalid State") |
| resp.StatusCode = 400 |
| resp.Body = getResponseBody("ERROR: State parameter is invalid.") |
| return "", err |
| } |
| |
| resp.Body = getResponseBody("Success! You may now close your browser.") |
| return code, nil |
| } |
| |
| // turn a string into an io.ReadCloser as required by an http.Response |
| func getResponseBody(body string) io.ReadCloser { |
| reader := strings.NewReader(body) |
| return ioutil.NopCloser(reader) |
| } |
| |
| // generates the values used in "Proof Key for Code Exchange by OAuth Public Clients" |
| // https://tools.ietf.org/html/rfc7636 |
| // https://developers.google.com/identity/protocols/OAuth2InstalledApp#step1-code-verifier |
| func codeChallengeParams() (verifier, challenge, method string, err error) { |
| // A `code_verifier` is a high-entropy cryptographic random string using the unreserved characters |
| // [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" |
| // with a minimum length of 43 characters and a maximum length of 128 characters. |
| verifier, err = makeRandString(32) |
| if err != nil { |
| return "", "", "", err |
| } |
| |
| // https://tools.ietf.org/html/rfc7636#section-4.2 |
| // If the client is capable of using "S256", it MUST use "S256": |
| // code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) |
| sha := sha256.Sum256([]byte(verifier)) |
| challenge = base64.RawURLEncoding.EncodeToString(sha[:]) |
| |
| return verifier, challenge, "S256", nil |
| } |
| |
| func makeRandString(length int) (string, error) { |
| b := make([]byte, length) |
| _, err := rand.Read(b) |
| if err != nil { |
| return "", err |
| } |
| return base64.RawURLEncoding.EncodeToString(b), nil |
| } |