mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-05-01 07:14:13 +00:00
- Replace CLI-based rootwallet calls with agent-based communication - Update production provisioner to support sudo-based service management - Add API key-to-wallet resolution for gateway operator handlers
295 lines
8.6 KiB
Go
295 lines
8.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/DeBrosOfficial/network/pkg/rwagent"
|
|
"github.com/DeBrosOfficial/network/pkg/tlsutil"
|
|
)
|
|
|
|
// IsRootWalletInstalled checks if the rootwallet agent is reachable.
|
|
func IsRootWalletInstalled() bool {
|
|
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
return client.IsRunning(ctx)
|
|
}
|
|
|
|
// getRootWalletAddress gets the EVM address from the rootwallet agent.
|
|
func getRootWalletAddress() (string, error) {
|
|
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
data, err := client.GetAddress(ctx, "evm")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get address from rootwallet agent: %w", err)
|
|
}
|
|
if data.Address == "" {
|
|
return "", fmt.Errorf("rootwallet agent returned empty address")
|
|
}
|
|
return data.Address, nil
|
|
}
|
|
|
|
// signWithRootWallet signs a message using the rootwallet agent's EVM key.
|
|
// The desktop app may prompt the user for approval.
|
|
func signWithRootWallet(message string) (string, error) {
|
|
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
data, err := client.Sign(ctx, message, "evm")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to sign with rootwallet agent: %w", err)
|
|
}
|
|
if data.Signature == "" {
|
|
return "", fmt.Errorf("rootwallet agent returned empty signature")
|
|
}
|
|
return data.Signature, nil
|
|
}
|
|
|
|
// PerformRootWalletAuthentication performs a challenge-response authentication flow
|
|
// using the RootWallet CLI to sign a gateway-issued nonce
|
|
func PerformRootWalletAuthentication(gatewayURL, namespace string) (*Credentials, error) {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
|
|
fmt.Println("\n🔐 RootWallet Authentication")
|
|
fmt.Println("=============================")
|
|
|
|
// 1. Get wallet address from RootWallet
|
|
fmt.Println("⏳ Reading wallet address from RootWallet...")
|
|
wallet, err := getRootWalletAddress()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get wallet address: %w", err)
|
|
}
|
|
|
|
if !ValidateWalletAddress(wallet) {
|
|
return nil, fmt.Errorf("invalid wallet address from rw: %s", wallet)
|
|
}
|
|
|
|
fmt.Printf("✅ Wallet: %s\n", wallet)
|
|
|
|
// 2. Prompt for namespace if not provided
|
|
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. Please enter a namespace.")
|
|
}
|
|
}
|
|
fmt.Printf("✅ Namespace: %s\n", namespace)
|
|
|
|
// 3. Request challenge nonce from gateway
|
|
fmt.Println("⏳ Requesting authentication challenge...")
|
|
domain := extractDomainFromURL(gatewayURL)
|
|
client := tlsutil.NewHTTPClientForDomain(30*time.Second, domain)
|
|
|
|
nonce, err := requestChallenge(client, gatewayURL, wallet, namespace)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get challenge: %w", err)
|
|
}
|
|
|
|
// 4. Sign the nonce with RootWallet
|
|
fmt.Println("⏳ Signing challenge with RootWallet...")
|
|
signature, err := signWithRootWallet(nonce)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to sign challenge: %w", err)
|
|
}
|
|
fmt.Println("✅ Challenge signed")
|
|
|
|
// 5. Verify signature with gateway
|
|
fmt.Println("⏳ Verifying signature with gateway...")
|
|
creds, err := verifySignature(client, gatewayURL, wallet, nonce, signature, namespace)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to verify signature: %w", err)
|
|
}
|
|
|
|
// If namespace cluster is being provisioned, poll until ready
|
|
if creds.ProvisioningPollURL != "" {
|
|
fmt.Println("⏳ Provisioning namespace cluster...")
|
|
pollErr := pollNamespaceProvisioning(client, gatewayURL, creds.ProvisioningPollURL)
|
|
if pollErr != nil {
|
|
fmt.Printf("⚠️ Provisioning poll failed: %v\n", pollErr)
|
|
fmt.Println(" Credentials are saved. Cluster may still be provisioning in background.")
|
|
} else {
|
|
fmt.Println("✅ Namespace cluster ready!")
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\n🎉 Authentication successful!\n")
|
|
fmt.Printf("🏢 Namespace: %s\n", creds.Namespace)
|
|
|
|
return creds, nil
|
|
}
|
|
|
|
// requestChallenge sends POST /v1/auth/challenge and returns the nonce
|
|
func requestChallenge(client *http.Client, gatewayURL, wallet, namespace string) (string, error) {
|
|
reqBody := map[string]string{
|
|
"wallet": wallet,
|
|
"namespace": namespace,
|
|
}
|
|
|
|
payload, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
resp, err := client.Post(gatewayURL+"/v1/auth/challenge", "application/json", bytes.NewReader(payload))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to call gateway: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("gateway returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
Nonce string `json:"nonce"`
|
|
Wallet string `json:"wallet"`
|
|
Namespace string `json:"namespace"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
if result.Nonce == "" {
|
|
return "", fmt.Errorf("no nonce in challenge response")
|
|
}
|
|
|
|
return result.Nonce, nil
|
|
}
|
|
|
|
// verifySignature sends POST /v1/auth/verify and returns credentials
|
|
func verifySignature(client *http.Client, gatewayURL, wallet, nonce, signature, namespace string) (*Credentials, error) {
|
|
reqBody := map[string]string{
|
|
"wallet": wallet,
|
|
"nonce": nonce,
|
|
"signature": signature,
|
|
"namespace": namespace,
|
|
"chain_type": "ETH",
|
|
}
|
|
|
|
payload, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
resp, err := client.Post(gatewayURL+"/v1/auth/verify", "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 && resp.StatusCode != http.StatusAccepted {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("gateway returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Subject string `json:"subject"`
|
|
Namespace string `json:"namespace"`
|
|
APIKey string `json:"api_key"`
|
|
// Provisioning fields (202 Accepted)
|
|
Status string `json:"status"`
|
|
PollURL string `json:"poll_url"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
if result.APIKey == "" {
|
|
return nil, fmt.Errorf("no api_key in verify response")
|
|
}
|
|
|
|
// Build namespace gateway URL
|
|
namespaceURL := ""
|
|
if d := extractDomainFromURL(gatewayURL); d != "" {
|
|
if namespace == "default" {
|
|
namespaceURL = fmt.Sprintf("https://%s", d)
|
|
} else {
|
|
namespaceURL = fmt.Sprintf("https://ns-%s.%s", namespace, d)
|
|
}
|
|
}
|
|
|
|
creds := &Credentials{
|
|
APIKey: result.APIKey,
|
|
RefreshToken: result.RefreshToken,
|
|
Namespace: result.Namespace,
|
|
UserID: result.Subject,
|
|
Wallet: result.Subject,
|
|
IssuedAt: time.Now(),
|
|
NamespaceURL: namespaceURL,
|
|
}
|
|
|
|
// If 202, namespace cluster is being provisioned — set poll URL
|
|
if resp.StatusCode == http.StatusAccepted && result.PollURL != "" {
|
|
creds.ProvisioningPollURL = result.PollURL
|
|
}
|
|
|
|
// Note: result.ExpiresIn is the JWT access token lifetime (15min),
|
|
// NOT the API key lifetime. Don't set ExpiresAt — the API key is permanent.
|
|
|
|
return creds, nil
|
|
}
|
|
|
|
// pollNamespaceProvisioning polls the namespace status endpoint until the cluster is ready.
|
|
func pollNamespaceProvisioning(client *http.Client, gatewayURL, pollPath string) error {
|
|
pollURL := gatewayURL + pollPath
|
|
timeout := time.After(120 * time.Second)
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-timeout:
|
|
return fmt.Errorf("timed out after 120s waiting for namespace cluster")
|
|
case <-ticker.C:
|
|
resp, err := client.Get(pollURL)
|
|
if err != nil {
|
|
continue // Retry on network error
|
|
}
|
|
|
|
var status struct {
|
|
Status string `json:"status"`
|
|
}
|
|
decErr := json.NewDecoder(resp.Body).Decode(&status)
|
|
resp.Body.Close()
|
|
if decErr != nil {
|
|
continue
|
|
}
|
|
|
|
switch status.Status {
|
|
case "ready":
|
|
return nil
|
|
case "failed", "error":
|
|
return fmt.Errorf("namespace provisioning failed")
|
|
}
|
|
// "provisioning" or other — keep polling
|
|
fmt.Print(".")
|
|
}
|
|
}
|
|
}
|