forked from open-sauced/pizza-cli
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
389 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
} | ||
}() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"` | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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">✕</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" | ||
> Insights </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"> Insights </a> | ||
<span> in ${--seconds} second${seconds > 1 ? "s" : ""}</span>`; | ||
}, 1000); | ||
|
||
closeButton.addEventListener("click", () => { | ||
window.close(); | ||
}); | ||
</script> | ||
</body> | ||
</html> |
Oops, something went wrong.