orama/pkg/auth/phantom.go

206 lines
5.8 KiB
Go

package auth
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/DeBrosOfficial/network/pkg/tlsutil"
qrterminal "github.com/mdp/qrterminal/v3"
)
// Hardcoded Phantom auth React app URL (deployed on Orama devnet)
const phantomAuthURL = "https://phantom-auth-y0w9aa.orama-devnet.network"
// PhantomSession represents a phantom auth session from the gateway.
type PhantomSession struct {
SessionID string `json:"session_id"`
ExpiresAt string `json:"expires_at"`
}
// PhantomSessionStatus represents the polled status of a phantom auth session.
type PhantomSessionStatus struct {
SessionID string `json:"session_id"`
Status string `json:"status"`
Wallet string `json:"wallet"`
APIKey string `json:"api_key"`
Namespace string `json:"namespace"`
Error string `json:"error"`
}
// PerformPhantomAuthentication runs the Phantom Solana auth flow:
// 1. Prompt for namespace
// 2. Create session via gateway
// 3. Display QR code in terminal
// 4. Poll for completion
// 5. Return credentials
func PerformPhantomAuthentication(gatewayURL, namespace string) (*Credentials, error) {
reader := bufio.NewReader(os.Stdin)
fmt.Println("\n🟣 Phantom Wallet Authentication (Solana)")
fmt.Println("==========================================")
fmt.Println("Requires an NFT from the authorized collection.")
// Prompt for namespace if empty
if namespace == "" {
for {
fmt.Print("Enter namespace (required): ")
nsInput, err := reader.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("failed to read namespace: %w", err)
}
namespace = strings.TrimSpace(nsInput)
if namespace != "" {
break
}
fmt.Println("Namespace cannot be empty.")
}
}
domain := extractDomainFromURL(gatewayURL)
client := tlsutil.NewHTTPClientForDomain(30*time.Second, domain)
// 1. Create phantom session
fmt.Println("\nCreating authentication session...")
session, err := createPhantomSession(client, gatewayURL, namespace)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
// 2. Build auth URL and display QR code
authURL := fmt.Sprintf("%s/?session=%s&gateway=%s&namespace=%s",
phantomAuthURL, session.SessionID, url.QueryEscape(gatewayURL), url.QueryEscape(namespace))
fmt.Println("\nScan this QR code with your phone to authenticate:")
fmt.Println()
qrterminal.GenerateWithConfig(authURL, qrterminal.Config{
Level: qrterminal.M,
Writer: os.Stdout,
BlackChar: qrterminal.BLACK,
WhiteChar: qrterminal.WHITE,
QuietZone: 1,
})
fmt.Println()
fmt.Printf("Or open this URL on your phone:\n%s\n\n", authURL)
fmt.Println("Waiting for authentication... (timeout: 5 minutes)")
// 3. Poll for completion
creds, err := pollPhantomSession(client, gatewayURL, session.SessionID)
if err != nil {
return nil, err
}
// Set namespace and build namespace URL
creds.Namespace = namespace
if domain := extractDomainFromURL(gatewayURL); domain != "" {
if namespace == "default" {
creds.NamespaceURL = fmt.Sprintf("https://%s", domain)
} else {
creds.NamespaceURL = fmt.Sprintf("https://ns-%s.%s", namespace, domain)
}
}
fmt.Printf("\n🎉 Authentication successful!\n")
truncatedKey := creds.APIKey
if len(truncatedKey) > 8 {
truncatedKey = truncatedKey[:8] + "..."
}
fmt.Printf("📝 API Key: %s\n", truncatedKey)
return creds, nil
}
// createPhantomSession creates a new phantom auth session via the gateway.
func createPhantomSession(client *http.Client, gatewayURL, namespace string) (*PhantomSession, error) {
reqBody := map[string]string{
"namespace": namespace,
}
payload, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
resp, err := client.Post(gatewayURL+"/v1/auth/phantom/session", "application/json", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("failed to call gateway: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("gateway returned status %d: %s", resp.StatusCode, string(body))
}
var session PhantomSession
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &session, nil
}
// pollPhantomSession polls the gateway for session completion.
func pollPhantomSession(client *http.Client, gatewayURL, sessionID string) (*Credentials, error) {
pollInterval := 2 * time.Second
maxDuration := 5 * time.Minute
deadline := time.Now().Add(maxDuration)
spinnerChars := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
spinnerIdx := 0
for time.Now().Before(deadline) {
resp, err := client.Get(gatewayURL + "/v1/auth/phantom/session/" + sessionID)
if err != nil {
time.Sleep(pollInterval)
continue
}
var status PhantomSessionStatus
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
resp.Body.Close()
time.Sleep(pollInterval)
continue
}
resp.Body.Close()
switch status.Status {
case "completed":
fmt.Printf("\r✅ Authenticated! \n")
return &Credentials{
APIKey: status.APIKey,
Wallet: status.Wallet,
UserID: status.Wallet,
IssuedAt: time.Now(),
}, nil
case "failed":
fmt.Printf("\r❌ Authentication failed \n")
errMsg := status.Error
if errMsg == "" {
errMsg = "unknown error"
}
return nil, fmt.Errorf("authentication failed: %s", errMsg)
case "expired":
fmt.Printf("\r⏰ Session expired \n")
return nil, fmt.Errorf("authentication session expired")
case "pending":
fmt.Printf("\r%s Waiting for phone authentication... ", spinnerChars[spinnerIdx%len(spinnerChars)])
spinnerIdx++
}
time.Sleep(pollInterval)
}
fmt.Printf("\r⏰ Timeout \n")
return nil, fmt.Errorf("authentication timed out after 5 minutes")
}