Skip to content

Commit

Permalink
Support user identity tokens and browser login flow
Browse files Browse the repository at this point in the history
  • Loading branch information
gartnera committed Jul 4, 2022
1 parent 622f60c commit 28b20f2
Show file tree
Hide file tree
Showing 6 changed files with 264 additions and 61 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ go install github.com/gartnera/gcloud@latest
- `gcloud auth application-default login` (code flow)
- `gcloud auth application-default print-access-token`
- `gcloud auth print-access-token`
- `gcloud auth print-identity-token`
- `gcloud auth configure-docker`
- `gcloud auth docker-helper`

Expand Down
250 changes: 244 additions & 6 deletions auth/application_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,31 @@ package auth

import (
"context"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"runtime"
"strings"

"github.com/kirsle/configdir"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/jwt"
)

const defaultClientId = "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com"
const defaultClientSecret = "d-FL95Q19q7MQmFpd7hHD0Ty"

// ApplicationCredentials is the a struct representing the application_default_credentials.json format.
// We add access_token and access_token_expiry for easy token caching and refresh.
type ApplicationCredentials struct {
Expand Down Expand Up @@ -106,7 +119,37 @@ func (a *ApplicationCredentials) SetContext(ctx context.Context) {
a.ctx = ctx
}

func discoverAcPath() string {
func DefaultApplicationCredentialManager() *ApplicationCredentialManager {
return &ApplicationCredentialManager{
ClientID: defaultClientId,
ClientSecret: defaultClientSecret,
}
}

func EnvApplicationCredentialManager() *ApplicationCredentialManager {
m := DefaultApplicationCredentialManager()
clientId, ok := os.LookupEnv("GOOGLE_APPLICATION_CLIENT_ID")
if ok {
m.ClientID = clientId
}
clientSecret, ok := os.LookupEnv("GOOGLE_APPLICATION_CLIENT_SECRET")
if ok {
m.ClientSecret = clientSecret
}
return m
}

type ApplicationCredentialManager struct {
ClientID string
ClientSecret string
}

func (m *ApplicationCredentialManager) discoverAcPath() string {
if m.ClientID != "" && m.ClientID != defaultClientId {
credDir := configdir.LocalCache("gcloud-gartnera-credentials")
_ = os.MkdirAll(credDir, 0755)
return path.Join(credDir, m.ClientID+".json")
}
acPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
if acPath == "" {
defaultConfigDir := os.Getenv("CLOUDSDK_CONFIG")
Expand All @@ -122,8 +165,8 @@ func discoverAcPath() string {
}

// WriteApplicationCredentials idempotently writes out the application credentials to disk
func WriteApplicationCredentials(ac *ApplicationCredentials) error {
acPath := discoverAcPath()
func (m *ApplicationCredentialManager) WriteApplicationCredentials(ac *ApplicationCredentials) error {
acPath := m.discoverAcPath()
tmpPath := acPath + ".tmp"
acFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
if err != nil {
Expand All @@ -142,11 +185,11 @@ func WriteApplicationCredentials(ac *ApplicationCredentials) error {
return nil
}

func ReadApplicationCredentials() (*ApplicationCredentials, error) {
acPath := discoverAcPath()
func (m *ApplicationCredentialManager) ReadApplicationCredentials() (*ApplicationCredentials, error) {
acPath := m.discoverAcPath()
acBytes, err := ioutil.ReadFile(acPath)
if err != nil {
return nil, fmt.Errorf("unable to read ac file: %w", err)
return nil, fmt.Errorf("unable to read ac file, maybe you need to login: %w", err)
}
ac := &ApplicationCredentials{}
err = json.Unmarshal(acBytes, ac)
Expand All @@ -158,3 +201,198 @@ func ReadApplicationCredentials() (*ApplicationCredentials, error) {
ac.hash = hex.EncodeToString(hashBytes[:])
return ac, nil
}

func (m *ApplicationCredentialManager) CodeFlowLogin(ctx context.Context, quotaProject string) error {
conf := &oauth2.Config{
ClientID: m.ClientID,
ClientSecret: m.ClientSecret,
Scopes: []string{
"openid",
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/accounts.reauth",
},
Endpoint: google.Endpoint,
}

stateBytes := make([]byte, 25)
_, err := rand.Read(stateBytes)
if err != nil {
return fmt.Errorf("unable to read state bytes: %w", err)
}
stateString := base64.RawStdEncoding.EncodeToString(stateBytes)

redirectParam := oauth2.SetAuthURLParam("redirect_uri", "urn:ietf:wg:oauth:2.0:oob")
url := conf.AuthCodeURL(stateString, oauth2.AccessTypeOffline, redirectParam)
fmt.Fprintf(os.Stderr, "Go to the following link in your browser:\n\n%s\n\n", url)

fmt.Fprint(os.Stderr, "Enter verification code: ")
var code string
if _, err := fmt.Scan(&code); err != nil {
return fmt.Errorf("unable to read code: %w", err)
}
tok, err := conf.Exchange(ctx, code, redirectParam)
if err != nil {
return fmt.Errorf("unable to exchange code: %w", err)
}

adc := &ApplicationCredentials{
ClientID: conf.ClientID,
ClientSecret: conf.ClientSecret,
QuotaProjectId: quotaProject,
RefreshToken: tok.RefreshToken,
AuthUri: conf.Endpoint.AuthURL,
TokenUri: conf.Endpoint.TokenURL,
Type: "authorized_user",
}
err = m.WriteApplicationCredentials(adc)
if err != nil {
return fmt.Errorf("unable to save application credentials: %w", err)
}
fmt.Fprintln(os.Stderr, "Login complete!")
return nil
}

func (m *ApplicationCredentialManager) BrowserFlowLogin(ctx context.Context, quotaProject string) error {
conf := &oauth2.Config{
ClientID: m.ClientID,
ClientSecret: m.ClientSecret,
Scopes: []string{
"openid",
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/accounts.reauth",
},
Endpoint: google.Endpoint,
}

stateBytes := make([]byte, 25)
_, err := rand.Read(stateBytes)
if err != nil {
return fmt.Errorf("unable to read state bytes: %w", err)
}
stateString := base64.RawStdEncoding.EncodeToString(stateBytes)
cb := &googleAccountLoginCallback{
state: stateString,
values: make(chan url.Values),
}

redirectURL, server, err := loginServer(stateString, cb)
if err != nil {
return err
}
defer func(ctx context.Context, server *http.Server) {
ctxCancel, cancel := context.WithCancel(ctx)
cancel()
_ = server.Shutdown(ctxCancel)
}(ctx, server)

conf.RedirectURL = redirectURL
codeURL := conf.AuthCodeURL(stateString, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
fmt.Fprintf(os.Stderr, "Opening URL to login: %s\n", codeURL)
openURL(codeURL)

var code string
code, err = cb.wait()
if err != nil {
return fmt.Errorf("unable to login: %w", err)
}
var tok *oauth2.Token
tok, err = conf.Exchange(ctx, code)
if err != nil {
return fmt.Errorf("unable to exchange token: %w", err)
}

adc := &ApplicationCredentials{
ClientID: conf.ClientID,
ClientSecret: conf.ClientSecret,
QuotaProjectId: quotaProject,
RefreshToken: tok.RefreshToken,
AuthUri: conf.Endpoint.AuthURL,
TokenUri: conf.Endpoint.TokenURL,
Type: "authorized_user",
}
err = m.WriteApplicationCredentials(adc)
if err != nil {
return fmt.Errorf("unable to save application credentials: %w", err)
}
fmt.Fprintln(os.Stderr, "Login complete!")
return nil
}

func (m *ApplicationCredentialManager) AutoDetectLogin(ctx context.Context, quotaProject string) error {
if detectBrowserAvailable() {
fmt.Println("browser avaliable)")
return m.BrowserFlowLogin(ctx, quotaProject)
}

return m.CodeFlowLogin(ctx, quotaProject)
}

func detectBrowserAvailable() bool {
_, isX11 := os.LookupEnv("DISPLAY")
_, isWayland := os.LookupEnv("WAYLAND_DISPLAY")
if isX11 || isWayland {
return true
}
return false
}

func loginServer(state string, handler http.Handler) (string, *http.Server, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", nil, err
}
port := strings.Split(listener.Addr().String(), ":")[1]
redirectURL := fmt.Sprintf("http://localhost:%s", port)

s := &http.Server{
Handler: handler,
}
go s.Serve(listener) //nolint:errcheck
return redirectURL, s, nil
}

func openURL(url string) {
var err error

switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
default:
err = fmt.Errorf("unsupported platform")
}
if err != nil {
panic(fmt.Errorf("failed to open browser to login with okta: %v", err))
}
}

type googleAccountLoginCallback struct {
state string
values chan url.Values
}

func (cb *googleAccountLoginCallback) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}

cb.values <- r.URL.Query()
_, _ = w.Write([]byte("Login complete, you can close this page now"))
}

func (cb *googleAccountLoginCallback) wait() (string, error) {
query := <-cb.values
state := query.Get("state")
if state != cb.state {
return "", fmt.Errorf("invalid state in callback %s != %s", state, cb.state)
}
return query.Get("code"), nil
}
56 changes: 2 additions & 54 deletions auth/application_default_cmd.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
package auth

import (
"crypto/rand"
"encoding/base64"
"fmt"

"github.com/spf13/cobra"
"golang.org/x/oauth2"
)

var applicationDefaultCmd = &cobra.Command{
Expand All @@ -24,57 +21,8 @@ var applicationDefaultLoginCmd = &cobra.Command{
return fmt.Errorf("unable to read quota project: %w", err)
}

conf := &oauth2.Config{
ClientID: "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
ClientSecret: "d-FL95Q19q7MQmFpd7hHD0Ty",
Scopes: []string{
"openid",
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/accounts.reauth",
},
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.google.com/o/oauth2/auth",
TokenURL: "https://oauth2.googleapis.com/token",
},
}

stateBytes := make([]byte, 25)
_, err := rand.Read(stateBytes)
if err != nil {
return fmt.Errorf("unable to read state bytes: %w", err)
}
stateString := base64.RawStdEncoding.EncodeToString(stateBytes)

redirectParam := oauth2.SetAuthURLParam("redirect_uri", "urn:ietf:wg:oauth:2.0:oob")
url := conf.AuthCodeURL(stateString, oauth2.AccessTypeOffline, redirectParam)
fmt.Printf("Go to the following link in your browser:\n\n%s\n\n", url)

fmt.Print("Enter verification code: ")
var code string
if _, err := fmt.Scan(&code); err != nil {
return fmt.Errorf("unable to read code: %w", err)
}
tok, err := conf.Exchange(ctx, code, redirectParam)
if err != nil {
return fmt.Errorf("unable to exchange code: %w", err)
}

adc := &ApplicationCredentials{
ClientID: conf.ClientID,
ClientSecret: conf.ClientSecret,
QuotaProjectId: quotaProject,
RefreshToken: tok.RefreshToken,
AuthUri: conf.Endpoint.AuthURL,
TokenUri: conf.Endpoint.TokenURL,
Type: "authorized_user",
}
err = WriteApplicationCredentials(adc)
if err != nil {
return fmt.Errorf("unable to save application default credentials: %w", err)
}
fmt.Println("Login complete!")
return nil
m := DefaultApplicationCredentialManager()
return m.AutoDetectLogin(ctx, quotaProject)
},
}

Expand Down
15 changes: 15 additions & 0 deletions auth/login_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package auth

import (
"github.com/spf13/cobra"
)

var loginCmd = &cobra.Command{
Use: "login",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

m := EnvApplicationCredentialManager()
return m.AutoDetectLogin(ctx, "")
},
}
Loading

0 comments on commit 28b20f2

Please sign in to comment.