From 34728fb62d01b746ffc8ede3c97a090b32b0b9f9 Mon Sep 17 00:00:00 2001 From: Anush Date: Wed, 16 Aug 2023 21:24:47 +0530 Subject: [PATCH] feat: cli auth (#21) --- README.md | 4 + cmd/auth/auth.go | 196 +++++++++++++++++++++++++++++++++++++ cmd/auth/constants.go | 8 ++ cmd/auth/schema.go | 44 +++++++++ cmd/auth/success.html | 123 +++++++++++++++++++++++ cmd/bake/bake.go | 5 +- cmd/root/root.go | 2 + go.mod | 1 + go.sum | 3 + pkg/constants/constants.go | 5 + 10 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 cmd/auth/auth.go create mode 100644 cmd/auth/constants.go create mode 100644 cmd/auth/schema.go create mode 100644 cmd/auth/success.html create mode 100644 pkg/constants/constants.go diff --git a/README.md b/README.md index 7d992a4..ef3e0f4 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ This CLI can be used for all things OpenSauced! ``` ❯ pizza +A command line utility for insights, metrics, and all things OpenSauced + Usage: pizza [flags] @@ -12,6 +14,8 @@ Available Commands: bake Use a pizza-oven to source git commits into OpenSauced completion Generate the autocompletion script for the specified shell help Help about any command + login Log into the CLI application via GitHub + repo-query Ask questions about a GitHub repository Flags: -h, --help help for pizza diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go new file mode 100644 index 0000000..7095b17 --- /dev/null +++ b/cmd/auth/auth.go @@ -0,0 +1,196 @@ +package auth + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + _ "embed" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/signal" + "path" + "time" + + "github.com/cli/browser" + "github.com/open-sauced/pizza-cli/pkg/constants" + "github.com/spf13/cobra" +) + +//go:embed success.html +var successHTML string + +const loginLongDesc string = `Log into OpenSauced. + +This command initiates the GitHub auth flow to log you into the OpenSauced application by launching your browser` + +func NewLoginCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "Log into the CLI application via GitHub", + Long: loginLongDesc, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return run() + }, + } + + return cmd +} + +func run() error { + codeVerifier, codeChallenge, err := pkce(codeChallengeLength) + if err != nil { + return fmt.Errorf("PKCE error: %v", err.Error()) + } + + supabaseAuthURL := fmt.Sprintf("https://%s.supabase.co/auth/v1/authorize", supabaseID) + queryParams := url.Values{ + "provider": {"github"}, + "code_challenge": {codeChallenge}, + "code_challenge_method": {"S256"}, + "redirect_to": {"http://" + authCallbackAddr + "/"}, + } + + authenticationURL := supabaseAuthURL + "?" + queryParams.Encode() + + server := &http.Server{Addr: authCallbackAddr} + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + defer shutdown(server) + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "'code' query param not found", http.StatusBadRequest) + return + } + + sessionData, err := getSession(code, codeVerifier) + if err != nil { + http.Error(w, "Access token exchange failed", http.StatusInternalServerError) + return + } + + homeDir, err := os.UserHomeDir() + if err != nil { + http.Error(w, "Couldn't get the Home directory", http.StatusInternalServerError) + return + } + + dirName := path.Join(homeDir, ".pizza") + if err := os.MkdirAll(dirName, os.ModePerm); err != nil { + http.Error(w, ".pizza directory couldn't be created", http.StatusInternalServerError) + return + } + + jsonData, err := json.Marshal(sessionData) + if err != nil { + http.Error(w, "Marshaling session data failed", http.StatusInternalServerError) + return + } + + filePath := path.Join(dirName, constants.SessionFileName) + if err := os.WriteFile(filePath, jsonData, 0o600); err != nil { + http.Error(w, "Error writing to file", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, err = w.Write([]byte(successHTML)) + if err != nil { + fmt.Println("Error writing response:", err.Error()) + } + + username := sessionData.User.UserMetadata["user_name"] + fmt.Println("🎉 Login successful 🎉") + fmt.Println("Welcome aboard", username, "🍕") + }) + + err = browser.OpenURL(authenticationURL) + if err != nil { + fmt.Println("Failed to open the browser 🤦‍♂️") + fmt.Println("Navigate to the following URL to begin authentication:") + fmt.Println(authenticationURL) + } + + errCh := make(chan error) + go func() { + errCh <- server.ListenAndServe() + }() + + interruptCh := make(chan os.Signal, 1) + signal.Notify(interruptCh, os.Interrupt) + + select { + case err := <-errCh: + if err != nil && err != http.ErrServerClosed { + return err + } + case <-time.After(60 * time.Second): + shutdown(server) + return errors.New("authentication timeout") + case <-interruptCh: + fmt.Println("\nAuthentication interrupted❗️") + shutdown(server) + os.Exit(0) + } + return nil +} + +func getSession(authCode, codeVerifier string) (*accessTokenResponse, error) { + url := fmt.Sprintf("https://%s.supabase.co/auth/v1/token?grant_type=pkce", supabaseID) + + payload := map[string]string{ + "auth_code": authCode, + "code_verifier": codeVerifier, + } + + jsonPayload, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) + req.Header.Set("Content-Type", "application/json;charset=UTF-8") + req.Header.Set("ApiKey", supabasePublicKey) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("couldn't make a request with the default client: %s", err.Error()) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %s", res.Status) + } + + var responseData accessTokenResponse + if err := json.NewDecoder(res.Body).Decode(&responseData); err != nil { + return nil, fmt.Errorf("could not decode JSON response: %s", err.Error()) + } + + return &responseData, nil +} + +func pkce(length int) (verifier, challenge string, err error) { + p := make([]byte, length) + if _, err := io.ReadFull(rand.Reader, p); err != nil { + return "", "", fmt.Errorf("failed to read random bytes: %s", err.Error()) + } + verifier = base64.RawURLEncoding.EncodeToString(p) + b := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(b[:]) + return verifier, challenge, nil +} + +func shutdown(server *http.Server) { + go func() { + err := server.Shutdown(context.Background()) + if err != nil { + panic(err.Error()) + } + }() +} diff --git a/cmd/auth/constants.go b/cmd/auth/constants.go new file mode 100644 index 0000000..cb355b8 --- /dev/null +++ b/cmd/auth/constants.go @@ -0,0 +1,8 @@ +package auth + +const ( + codeChallengeLength = 87 + supabaseID = "ibcwmlhcimymasokhgvn" + supabasePublicKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYyOTkzMDc3OCwiZXhwIjoxOTQ1NTA2Nzc4fQ.zcdbd7kDhk7iNSMo8SjsTaXi0wlLNNQcSZkzZ84NUDg" + authCallbackAddr = "localhost:3000" +) diff --git a/cmd/auth/schema.go b/cmd/auth/schema.go new file mode 100644 index 0000000..67af08f --- /dev/null +++ b/cmd/auth/schema.go @@ -0,0 +1,44 @@ +package auth + +type accessTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + ExpiresAt int `json:"expires_at"` + User userSchema `json:"user"` +} + +type userSchema struct { + ID string `json:"id"` + Aud string `json:"aud,omitempty"` + Role string `json:"role"` + Email string `json:"email"` + EmailConfirmedAt string `json:"email_confirmed_at"` + Phone string `json:"phone"` + PhoneConfirmedAt string `json:"phone_confirmed_at"` + ConfirmationSentAt string `json:"confirmation_sent_at"` + ConfirmedAt string `json:"confirmed_at"` + RecoverySentAt string `json:"recovery_sent_at"` + NewEmail string `json:"new_email"` + EmailChangeSentAt string `json:"email_change_sent_at"` + NewPhone string `json:"new_phone"` + PhoneChangeSentAt string `json:"phone_change_sent_at"` + ReauthenticationSentAt string `json:"reauthentication_sent_at"` + LastSignInAt string `json:"last_sign_in_at"` + AppMetadata map[string]interface{} `json:"app_metadata"` + UserMetadata map[string]interface{} `json:"user_metadata"` + Factors []mfaFactorSchema `json:"factors"` + Identities []interface{} `json:"identities"` + BannedUntil string `json:"banned_until"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt string `json:"deleted_at"` +} + +type mfaFactorSchema struct { + ID string `json:"id"` + Status string `json:"status"` + FriendlyName string `json:"friendly_name"` + FactorType string `json:"factor_type"` +} diff --git a/cmd/auth/success.html b/cmd/auth/success.html new file mode 100644 index 0000000..692c8a2 --- /dev/null +++ b/cmd/auth/success.html @@ -0,0 +1,123 @@ + + + + OpenSauced Pizza-CLI + + + +
+
+
🍕
+

Authentication Successful

+

+ You'll be redirected to +  Insights  + in 30 seconds +

+
+ + + + + + diff --git a/cmd/bake/bake.go b/cmd/bake/bake.go index 3ae50fe..05fac33 100644 --- a/cmd/bake/bake.go +++ b/cmd/bake/bake.go @@ -125,8 +125,9 @@ func bakeRepo(bodyPostReq bakePostRequest, endpoint string) error { return err } - responseBody := bytes.NewBuffer(bodyPostJSON) - resp, err := http.Post(fmt.Sprintf("%s/bake", endpoint), "application/json", responseBody) + requestBody := bytes.NewBuffer(bodyPostJSON) + resp, err := http.Post(fmt.Sprintf("%s/bake", endpoint), "application/json", requestBody) + if err != nil { return err } diff --git a/cmd/root/root.go b/cmd/root/root.go index dce212f..23fda04 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -4,6 +4,7 @@ package root import ( "github.com/spf13/cobra" + "github.com/open-sauced/pizza-cli/cmd/auth" "github.com/open-sauced/pizza-cli/cmd/bake" repoquery "github.com/open-sauced/pizza-cli/cmd/repo-query" ) @@ -19,6 +20,7 @@ func NewRootCommand() (*cobra.Command, error) { cmd.AddCommand(bake.NewBakeCommand()) cmd.AddCommand(repoquery.NewRepoQueryCommand()) + cmd.AddCommand(auth.NewLoginCommand()) return cmd, nil } diff --git a/go.mod b/go.mod index c85e868..1972ac4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/open-sauced/pizza-cli go 1.20 require ( + github.com/cli/browser v1.2.0 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 golang.org/x/term v0.9.0 diff --git a/go.sum b/go.sum index 3f97a21..a8adb90 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/cli/browser v1.2.0 h1:yvU7e9qf97kZqGFX6n2zJPHsmSObY9ske+iCvKelvXg= +github.com/cli/browser v1.2.0/go.mod h1:xFFnXLVcAyW9ni0cuo6NnrbCP75JxJ0RO7VtCBiH/oI= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -6,6 +8,7 @@ github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go new file mode 100644 index 0000000..3d7b92e --- /dev/null +++ b/pkg/constants/constants.go @@ -0,0 +1,5 @@ +package constants + +const ( + SessionFileName = "session.json" +)