Skip to content

Commit

Permalink
feat: cli auth (open-sauced#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
Anush008 authored Aug 16, 2023
1 parent ad187a9 commit 34728fb
Show file tree
Hide file tree
Showing 10 changed files with 389 additions and 2 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ This CLI can be used for all things OpenSauced!
```
❯ pizza
A command line utility for insights, metrics, and all things OpenSauced
Usage:
pizza <command> <subcommand> [flags]
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
Expand Down
196 changes: 196 additions & 0 deletions cmd/auth/auth.go
Original file line number Diff line number Diff line change
@@ -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())
}
}()
}
8 changes: 8 additions & 0 deletions cmd/auth/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package auth

const (
codeChallengeLength = 87
supabaseID = "ibcwmlhcimymasokhgvn"
supabasePublicKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYyOTkzMDc3OCwiZXhwIjoxOTQ1NTA2Nzc4fQ.zcdbd7kDhk7iNSMo8SjsTaXi0wlLNNQcSZkzZ84NUDg"
authCallbackAddr = "localhost:3000"
)
44 changes: 44 additions & 0 deletions cmd/auth/schema.go
Original file line number Diff line number Diff line change
@@ -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"`
}
123 changes: 123 additions & 0 deletions cmd/auth/success.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html>
<head>
<title>OpenSauced Pizza-CLI</title>
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #ff9900, #ff5500);
font-family: Arial, sans-serif;
position: relative;
}
.container {
text-align: center;
padding: 20px;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2), 0 5px 15px rgba(0, 0, 0, 0.3);
transition: transform 0.3s;
position: relative;
}
.container:hover {
transform: scale(1.05);
}
.pizza-icon {
font-size: 80px;
margin-bottom: 20px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}
.title {
color: #ff5500;
font-size: 32px;
font-weight: bold;
margin: 0;
margin-bottom: 10px;
}
.message {
color: #555;
font-size: 20px;
display: flex;
justify-content: center;
align-items: center;
margin-top: 15px;
}
.message-link {
color: #ff5500;
text-decoration: none;
font-weight: bold;
}
.opensauced-logo {
display: flex;
align-items: center;
position: absolute;
top: 20px;
left: 20px;
color: #fff;
font-size: 28px;
font-weight: bold;
}
.logo-image {
width: 60px;
height: 60px;
}
.close-button {
position: absolute;
top: 10px;
right: 10px;
font-size: 24px;
color: #ff9900;
cursor: pointer;
}
.close-button:hover {
color: #ff5500;
}
</style>
</head>
<body>
<div class="container">
<div class="close-button" id="closeButton">&#10005;</div>
<div class="pizza-icon">🍕</div>
<h1 class="title">Authentication Successful</h1>
<p class="message" id="counter">
<span>You'll be redirected to </span>
<a href="https://insights.opensauced.pizza/" class="message-link"
>&nbspInsights&nbsp</a
>
<span> in 30 seconds</span>
</p>
</div>
<a href="https://opensauced.pizza" target="_blank" rel="noreferrer">
<div class="opensauced-logo">
<img
src="https://raw.githubusercontent.com/open-sauced/assets/main/logos/slice-White.png"
alt="OpenSauced Logo"
class="logo-image"
/>
<div class="logo-text">OpenSauced</div>
</div>
</a>
<script>
const counterElement = document.getElementById("counter");
const closeButton = document.getElementById("closeButton");

let seconds = 30;
let interval = setInterval(() => {
if (seconds <= 1) {
clearInterval(interval);
window.open("https://insights.opensauced.pizza", "_self");
}
counterElement.innerHTML = `<span>You'll be redirected to </span>
<a href="https://insights.opensauced.pizza/" class="message-link">&nbspInsights&nbsp</a>
<span> in ${--seconds} second${seconds > 1 ? "s" : ""}</span>`;
}, 1000);

closeButton.addEventListener("click", () => {
window.close();
});
</script>
</body>
</html>
Loading

0 comments on commit 34728fb

Please sign in to comment.