mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-17 09:36:56 +00:00
Fixed firewall problem with anyone rellay and added authentication with root wallet
This commit is contained in:
parent
1d186706f6
commit
5fed8a6c88
2
.gitignore
vendored
2
.gitignore
vendored
@ -108,3 +108,5 @@ cli
|
|||||||
./inspector
|
./inspector
|
||||||
|
|
||||||
results/
|
results/
|
||||||
|
|
||||||
|
phantom-auth/
|
||||||
@ -192,6 +192,17 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
|
|||||||
cfg.IPFSReplicationFactor = y.IPFSReplicationFactor
|
cfg.IPFSReplicationFactor = y.IPFSReplicationFactor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phantom Solana auth (from env vars)
|
||||||
|
if v := os.Getenv("PHANTOM_AUTH_URL"); v != "" {
|
||||||
|
cfg.PhantomAuthURL = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("SOLANA_RPC_URL"); v != "" {
|
||||||
|
cfg.SolanaRPCURL = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("NFT_COLLECTION_ADDRESS"); v != "" {
|
||||||
|
cfg.NFTCollectionAddress = v
|
||||||
|
}
|
||||||
|
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
if errs := cfg.ValidateConfig(); len(errs) > 0 {
|
if errs := cfg.ValidateConfig(); len(errs) > 0 {
|
||||||
fmt.Fprintf(os.Stderr, "\nGateway configuration errors (%d):\n", len(errs))
|
fmt.Fprintf(os.Stderr, "\nGateway configuration errors (%d):\n", len(errs))
|
||||||
|
|||||||
2
go.mod
2
go.mod
@ -176,6 +176,7 @@ require (
|
|||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1 // indirect
|
||||||
github.com/miekg/dns v1.1.70 // indirect
|
github.com/miekg/dns v1.1.70 // indirect
|
||||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect
|
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect
|
||||||
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect
|
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect
|
||||||
@ -315,6 +316,7 @@ require (
|
|||||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
||||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
|
rsc.io/qr v0.2.0 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
|
||||||
sigs.k8s.io/mcs-api v0.3.0 // indirect
|
sigs.k8s.io/mcs-api v0.3.0 // indirect
|
||||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@ -485,6 +485,8 @@ github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU
|
|||||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
|
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
|
||||||
github.com/miekg/dns v1.1.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
github.com/miekg/dns v1.1.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||||
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
|
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
|
||||||
@ -1149,6 +1151,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8
|
|||||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||||
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
|
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
|
||||||
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
|
||||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||||
sigs.k8s.io/mcs-api v0.3.0 h1:LjRvgzjMrvO1904GP6XBJSnIX221DJMyQlZOYt9LAnM=
|
sigs.k8s.io/mcs-api v0.3.0 h1:LjRvgzjMrvO1904GP6XBJSnIX221DJMyQlZOYt9LAnM=
|
||||||
|
|||||||
21
migrations/017_phantom_auth_sessions.sql
Normal file
21
migrations/017_phantom_auth_sessions.sql
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
-- Migration 017: Phantom auth sessions for QR code + deep link authentication
|
||||||
|
-- Stores session state for the CLI-to-phone relay pattern via the gateway
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS phantom_auth_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
wallet TEXT,
|
||||||
|
api_key TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_phantom_sessions_status ON phantom_auth_sessions(status);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO schema_migrations(version) VALUES (17);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
195
pkg/auth/phantom.go
Normal file
195
pkg/auth/phantom.go
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/tlsutil"
|
||||||
|
qrterminal "github.com/mdp/qrterminal/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PhantomSession represents a phantom auth session from the gateway.
|
||||||
|
type PhantomSession struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
AuthURL string `json:"auth_url"`
|
||||||
|
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. Display QR code
|
||||||
|
fmt.Println("\nScan this QR code with your phone to authenticate:")
|
||||||
|
fmt.Println()
|
||||||
|
qrterminal.GenerateWithConfig(session.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", session.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 != "" {
|
||||||
|
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")
|
||||||
|
}
|
||||||
229
pkg/auth/rootwallet.go
Normal file
229
pkg/auth/rootwallet.go
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/tlsutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsRootWalletInstalled checks if the `rw` CLI is available in PATH
|
||||||
|
func IsRootWalletInstalled() bool {
|
||||||
|
_, err := exec.LookPath("rw")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRootWalletAddress gets the EVM address from the RootWallet keystore
|
||||||
|
func getRootWalletAddress() (string, error) {
|
||||||
|
cmd := exec.Command("rw", "address", "--chain", "evm")
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get address from rw: %w", err)
|
||||||
|
}
|
||||||
|
addr := strings.TrimSpace(string(out))
|
||||||
|
if addr == "" {
|
||||||
|
return "", fmt.Errorf("rw returned empty address — run 'rw init' first")
|
||||||
|
}
|
||||||
|
return addr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// signWithRootWallet signs a message using RootWallet's EVM key.
|
||||||
|
// Stdin is passed through so the user can enter their password if the session is expired.
|
||||||
|
func signWithRootWallet(message string) (string, error) {
|
||||||
|
cmd := exec.Command("rw", "sign", message, "--chain", "evm")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to sign with rw: %w", err)
|
||||||
|
}
|
||||||
|
sig := strings.TrimSpace(string(out))
|
||||||
|
if sig == "" {
|
||||||
|
return "", fmt.Errorf("rw returned empty signature")
|
||||||
|
}
|
||||||
|
return sig, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
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 != "" {
|
||||||
|
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 result.ExpiresIn > 0 {
|
||||||
|
creds.ExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
return creds, nil
|
||||||
|
}
|
||||||
@ -15,8 +15,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// PerformSimpleAuthentication performs a simple authentication flow where the user
|
// PerformSimpleAuthentication performs a simple authentication flow where the user
|
||||||
// provides a wallet address and receives an API key without signature verification
|
// provides a wallet address and receives an API key without signature verification.
|
||||||
func PerformSimpleAuthentication(gatewayURL, wallet, namespace string) (*Credentials, error) {
|
// Requires an existing valid API key (convenience re-auth only).
|
||||||
|
func PerformSimpleAuthentication(gatewayURL, wallet, namespace, existingAPIKey string) (*Credentials, error) {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
fmt.Println("\n🔐 Simple Wallet Authentication")
|
fmt.Println("\n🔐 Simple Wallet Authentication")
|
||||||
@ -67,7 +68,7 @@ func PerformSimpleAuthentication(gatewayURL, wallet, namespace string) (*Credent
|
|||||||
fmt.Println("⏳ Requesting API key from gateway...")
|
fmt.Println("⏳ Requesting API key from gateway...")
|
||||||
|
|
||||||
// Request API key from gateway
|
// Request API key from gateway
|
||||||
apiKey, err := requestAPIKeyFromGateway(gatewayURL, wallet, namespace)
|
apiKey, err := requestAPIKeyFromGateway(gatewayURL, wallet, namespace, existingAPIKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to request API key: %w", err)
|
return nil, fmt.Errorf("failed to request API key: %w", err)
|
||||||
}
|
}
|
||||||
@ -89,14 +90,18 @@ func PerformSimpleAuthentication(gatewayURL, wallet, namespace string) (*Credent
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n🎉 Authentication successful!\n")
|
fmt.Printf("\n🎉 Authentication successful!\n")
|
||||||
fmt.Printf("📝 API Key: %s\n", creds.APIKey)
|
truncatedKey := creds.APIKey
|
||||||
|
if len(truncatedKey) > 8 {
|
||||||
|
truncatedKey = truncatedKey[:8] + "..."
|
||||||
|
}
|
||||||
|
fmt.Printf("📝 API Key: %s\n", truncatedKey)
|
||||||
|
|
||||||
return creds, nil
|
return creds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestAPIKeyFromGateway calls the gateway's simple-key endpoint to generate an API key
|
// requestAPIKeyFromGateway calls the gateway's simple-key endpoint to generate an API key
|
||||||
// For non-default namespaces, this may trigger cluster provisioning and require polling
|
// For non-default namespaces, this may trigger cluster provisioning and require polling
|
||||||
func requestAPIKeyFromGateway(gatewayURL, wallet, namespace string) (string, error) {
|
func requestAPIKeyFromGateway(gatewayURL, wallet, namespace, existingAPIKey string) (string, error) {
|
||||||
reqBody := map[string]string{
|
reqBody := map[string]string{
|
||||||
"wallet": wallet,
|
"wallet": wallet,
|
||||||
"namespace": namespace,
|
"namespace": namespace,
|
||||||
@ -114,7 +119,16 @@ func requestAPIKeyFromGateway(gatewayURL, wallet, namespace string) (string, err
|
|||||||
domain := extractDomainFromURL(gatewayURL)
|
domain := extractDomainFromURL(gatewayURL)
|
||||||
client := tlsutil.NewHTTPClientForDomain(30*time.Second, domain)
|
client := tlsutil.NewHTTPClientForDomain(30*time.Second, domain)
|
||||||
|
|
||||||
resp, err := client.Post(endpoint, "application/json", bytes.NewReader(payload))
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if existingAPIKey != "" {
|
||||||
|
req.Header.Set("X-API-Key", existingAPIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to call gateway: %w", err)
|
return "", fmt.Errorf("failed to call gateway: %w", err)
|
||||||
}
|
}
|
||||||
@ -122,7 +136,7 @@ func requestAPIKeyFromGateway(gatewayURL, wallet, namespace string) (string, err
|
|||||||
|
|
||||||
// Handle 202 Accepted - namespace cluster is being provisioned
|
// Handle 202 Accepted - namespace cluster is being provisioned
|
||||||
if resp.StatusCode == http.StatusAccepted {
|
if resp.StatusCode == http.StatusAccepted {
|
||||||
return handleProvisioningResponse(gatewayURL, client, resp, wallet, namespace)
|
return handleProvisioningResponse(gatewayURL, client, resp, wallet, namespace, existingAPIKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
@ -144,7 +158,7 @@ func requestAPIKeyFromGateway(gatewayURL, wallet, namespace string) (string, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleProvisioningResponse handles 202 Accepted responses when namespace cluster provisioning is needed
|
// handleProvisioningResponse handles 202 Accepted responses when namespace cluster provisioning is needed
|
||||||
func handleProvisioningResponse(gatewayURL string, client *http.Client, resp *http.Response, wallet, namespace string) (string, error) {
|
func handleProvisioningResponse(gatewayURL string, client *http.Client, resp *http.Response, wallet, namespace, existingAPIKey string) (string, error) {
|
||||||
var provResp map[string]interface{}
|
var provResp map[string]interface{}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&provResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&provResp); err != nil {
|
||||||
return "", fmt.Errorf("failed to decode provisioning response: %w", err)
|
return "", fmt.Errorf("failed to decode provisioning response: %w", err)
|
||||||
@ -177,7 +191,7 @@ func handleProvisioningResponse(gatewayURL string, client *http.Client, resp *ht
|
|||||||
fmt.Println("\n✅ Namespace cluster ready!")
|
fmt.Println("\n✅ Namespace cluster ready!")
|
||||||
fmt.Println("⏳ Retrieving API key...")
|
fmt.Println("⏳ Retrieving API key...")
|
||||||
|
|
||||||
return retryAPIKeyRequest(gatewayURL, client, wallet, namespace)
|
return retryAPIKeyRequest(gatewayURL, client, wallet, namespace, existingAPIKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pollProvisioningStatus polls the status endpoint until the cluster is ready
|
// pollProvisioningStatus polls the status endpoint until the cluster is ready
|
||||||
@ -185,6 +199,13 @@ func pollProvisioningStatus(gatewayURL string, client *http.Client, pollURL stri
|
|||||||
// Build full poll URL if it's a relative path
|
// Build full poll URL if it's a relative path
|
||||||
if strings.HasPrefix(pollURL, "/") {
|
if strings.HasPrefix(pollURL, "/") {
|
||||||
pollURL = gatewayURL + pollURL
|
pollURL = gatewayURL + pollURL
|
||||||
|
} else {
|
||||||
|
// Validate that absolute poll URLs point to the same gateway domain
|
||||||
|
gatewayDomain := extractDomainFromURL(gatewayURL)
|
||||||
|
pollDomain := extractDomainFromURL(pollURL)
|
||||||
|
if gatewayDomain != pollDomain {
|
||||||
|
return fmt.Errorf("poll URL domain mismatch: expected %s, got %s", gatewayDomain, pollDomain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maxAttempts := 120 // 10 minutes (5 seconds per poll)
|
maxAttempts := 120 // 10 minutes (5 seconds per poll)
|
||||||
@ -260,7 +281,7 @@ func pollProvisioningStatus(gatewayURL string, client *http.Client, pollURL stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// retryAPIKeyRequest retries the API key request after cluster provisioning
|
// retryAPIKeyRequest retries the API key request after cluster provisioning
|
||||||
func retryAPIKeyRequest(gatewayURL string, client *http.Client, wallet, namespace string) (string, error) {
|
func retryAPIKeyRequest(gatewayURL string, client *http.Client, wallet, namespace, existingAPIKey string) (string, error) {
|
||||||
reqBody := map[string]string{
|
reqBody := map[string]string{
|
||||||
"wallet": wallet,
|
"wallet": wallet,
|
||||||
"namespace": namespace,
|
"namespace": namespace,
|
||||||
@ -273,7 +294,16 @@ func retryAPIKeyRequest(gatewayURL string, client *http.Client, wallet, namespac
|
|||||||
|
|
||||||
endpoint := gatewayURL + "/v1/auth/simple-key"
|
endpoint := gatewayURL + "/v1/auth/simple-key"
|
||||||
|
|
||||||
resp, err := client.Post(endpoint, "application/json", bytes.NewReader(payload))
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if existingAPIKey != "" {
|
||||||
|
req.Header.Set("X-API-Key", existingAPIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to call gateway: %w", err)
|
return "", fmt.Errorf("failed to call gateway: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,11 +21,13 @@ func HandleAuthCommand(args []string) {
|
|||||||
switch subcommand {
|
switch subcommand {
|
||||||
case "login":
|
case "login":
|
||||||
var wallet, namespace string
|
var wallet, namespace string
|
||||||
|
var simple bool
|
||||||
fs := flag.NewFlagSet("auth login", flag.ExitOnError)
|
fs := flag.NewFlagSet("auth login", flag.ExitOnError)
|
||||||
fs.StringVar(&wallet, "wallet", "", "Wallet address (0x...)")
|
fs.StringVar(&wallet, "wallet", "", "Wallet address (implies --simple)")
|
||||||
fs.StringVar(&namespace, "namespace", "", "Namespace name")
|
fs.StringVar(&namespace, "namespace", "", "Namespace name")
|
||||||
|
fs.BoolVar(&simple, "simple", false, "Use simple auth without signature verification")
|
||||||
_ = fs.Parse(args[1:])
|
_ = fs.Parse(args[1:])
|
||||||
handleAuthLogin(wallet, namespace)
|
handleAuthLogin(wallet, namespace, simple)
|
||||||
case "logout":
|
case "logout":
|
||||||
handleAuthLogout()
|
handleAuthLogout()
|
||||||
case "whoami":
|
case "whoami":
|
||||||
@ -47,30 +49,37 @@ func showAuthHelp() {
|
|||||||
fmt.Printf("🔐 Authentication Commands\n\n")
|
fmt.Printf("🔐 Authentication Commands\n\n")
|
||||||
fmt.Printf("Usage: orama auth <subcommand>\n\n")
|
fmt.Printf("Usage: orama auth <subcommand>\n\n")
|
||||||
fmt.Printf("Subcommands:\n")
|
fmt.Printf("Subcommands:\n")
|
||||||
fmt.Printf(" login - Authenticate by providing your wallet address\n")
|
fmt.Printf(" login - Authenticate with RootWallet (default) or simple auth\n")
|
||||||
fmt.Printf(" logout - Clear stored credentials\n")
|
fmt.Printf(" logout - Clear stored credentials\n")
|
||||||
fmt.Printf(" whoami - Show current authentication status\n")
|
fmt.Printf(" whoami - Show current authentication status\n")
|
||||||
fmt.Printf(" status - Show detailed authentication info\n")
|
fmt.Printf(" status - Show detailed authentication info\n")
|
||||||
fmt.Printf(" list - List all stored credentials for current environment\n")
|
fmt.Printf(" list - List all stored credentials for current environment\n")
|
||||||
fmt.Printf(" switch - Switch between stored credentials\n\n")
|
fmt.Printf(" switch - Switch between stored credentials\n\n")
|
||||||
|
fmt.Printf("Login Flags:\n")
|
||||||
|
fmt.Printf(" --namespace <name> - Target namespace\n")
|
||||||
|
fmt.Printf(" --simple - Use simple auth (no signature, dev only)\n")
|
||||||
|
fmt.Printf(" --wallet <0x...> - Wallet address (implies --simple)\n\n")
|
||||||
fmt.Printf("Examples:\n")
|
fmt.Printf("Examples:\n")
|
||||||
fmt.Printf(" orama auth login # Enter wallet address interactively\n")
|
fmt.Printf(" orama auth login # Sign with RootWallet (default)\n")
|
||||||
fmt.Printf(" orama auth login --wallet 0x... --namespace myns # Non-interactive\n")
|
fmt.Printf(" orama auth login --namespace myns # Sign with RootWallet + namespace\n")
|
||||||
|
fmt.Printf(" orama auth login --simple # Simple auth (no signature)\n")
|
||||||
fmt.Printf(" orama auth whoami # Check who you're logged in as\n")
|
fmt.Printf(" orama auth whoami # Check who you're logged in as\n")
|
||||||
fmt.Printf(" orama auth status # View detailed authentication info\n")
|
|
||||||
fmt.Printf(" orama auth logout # Clear all stored credentials\n\n")
|
fmt.Printf(" orama auth logout # Clear all stored credentials\n\n")
|
||||||
fmt.Printf("Environment Variables:\n")
|
fmt.Printf("Environment Variables:\n")
|
||||||
fmt.Printf(" DEBROS_GATEWAY_URL - Gateway URL (overrides environment config)\n\n")
|
fmt.Printf(" DEBROS_GATEWAY_URL - Gateway URL (overrides environment config)\n\n")
|
||||||
fmt.Printf("Authentication Flow:\n")
|
fmt.Printf("Authentication Flow (RootWallet):\n")
|
||||||
fmt.Printf(" 1. Run 'orama auth login'\n")
|
fmt.Printf(" 1. Run 'orama auth login'\n")
|
||||||
fmt.Printf(" 2. Enter your wallet address when prompted\n")
|
fmt.Printf(" 2. Your wallet address is read from RootWallet automatically\n")
|
||||||
fmt.Printf(" 3. Enter your namespace (or press Enter for 'default')\n")
|
fmt.Printf(" 3. Enter your namespace when prompted\n")
|
||||||
fmt.Printf(" 4. An API key will be generated and saved to ~/.orama/credentials.json\n\n")
|
fmt.Printf(" 4. A challenge nonce is signed with your wallet key\n")
|
||||||
fmt.Printf("Note: Authentication uses the currently active environment.\n")
|
fmt.Printf(" 5. Credentials are saved to ~/.orama/credentials.json\n\n")
|
||||||
|
fmt.Printf("Note: Requires RootWallet CLI (rw) in PATH.\n")
|
||||||
|
fmt.Printf(" Install: cd rootwallet/cli && ./install.sh\n")
|
||||||
|
fmt.Printf(" Authentication uses the currently active environment.\n")
|
||||||
fmt.Printf(" Use 'orama env current' to see your active environment.\n")
|
fmt.Printf(" Use 'orama env current' to see your active environment.\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAuthLogin(wallet, namespace string) {
|
func handleAuthLogin(wallet, namespace string, simple bool) {
|
||||||
// Get gateway URL from active environment
|
// Get gateway URL from active environment
|
||||||
gatewayURL := getGatewayURL()
|
gatewayURL := getGatewayURL()
|
||||||
|
|
||||||
@ -129,8 +138,43 @@ func handleAuthLogin(wallet, namespace string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform simple authentication to add a new credential
|
// Choose authentication method
|
||||||
creds, err := auth.PerformSimpleAuthentication(gatewayURL, wallet, namespace)
|
var creds *auth.Credentials
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
|
if simple || wallet != "" {
|
||||||
|
// Explicit simple auth — requires existing credentials
|
||||||
|
existingCreds := store.GetDefaultCredential(gatewayURL)
|
||||||
|
if existingCreds == nil || !existingCreds.IsValid() {
|
||||||
|
fmt.Fprintf(os.Stderr, "❌ Simple auth requires existing credentials. Authenticate with RootWallet or Phantom first.\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
creds, err = auth.PerformSimpleAuthentication(gatewayURL, wallet, namespace, existingCreds.APIKey)
|
||||||
|
} else {
|
||||||
|
// Show auth method selection
|
||||||
|
fmt.Println("How would you like to authenticate?")
|
||||||
|
fmt.Println(" 1. RootWallet (EVM signature)")
|
||||||
|
fmt.Println(" 2. Phantom (Solana + NFT required)")
|
||||||
|
fmt.Print("\nSelect [1/2]: ")
|
||||||
|
|
||||||
|
choice, _ := reader.ReadString('\n')
|
||||||
|
choice = strings.TrimSpace(choice)
|
||||||
|
|
||||||
|
switch choice {
|
||||||
|
case "2":
|
||||||
|
creds, err = auth.PerformPhantomAuthentication(gatewayURL, namespace)
|
||||||
|
default:
|
||||||
|
// Default to RootWallet
|
||||||
|
if auth.IsRootWalletInstalled() {
|
||||||
|
creds, err = auth.PerformRootWalletAuthentication(gatewayURL, namespace)
|
||||||
|
} else {
|
||||||
|
fmt.Println("\n⚠️ RootWallet CLI (rw) not found in PATH.")
|
||||||
|
fmt.Println(" Install it: cd rootwallet/cli && ./install.sh")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "❌ Authentication failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "❌ Authentication failed: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@ -155,7 +199,6 @@ func handleAuthLogin(wallet, namespace string) {
|
|||||||
fmt.Printf("📁 Credentials saved to: %s\n", credsPath)
|
fmt.Printf("📁 Credentials saved to: %s\n", credsPath)
|
||||||
fmt.Printf("🎯 Wallet: %s\n", creds.Wallet)
|
fmt.Printf("🎯 Wallet: %s\n", creds.Wallet)
|
||||||
fmt.Printf("🏢 Namespace: %s\n", creds.Namespace)
|
fmt.Printf("🏢 Namespace: %s\n", creds.Namespace)
|
||||||
fmt.Printf("🔑 API Key: %s\n", creds.APIKey)
|
|
||||||
if creds.NamespaceURL != "" {
|
if creds.NamespaceURL != "" {
|
||||||
fmt.Printf("🌐 Namespace URL: %s\n", creds.NamespaceURL)
|
fmt.Printf("🌐 Namespace URL: %s\n", creds.NamespaceURL)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ func NewOrchestrator(flags *Flags) *Orchestrator {
|
|||||||
setup := production.NewProductionSetup(oramaHome, os.Stdout, flags.Force, branch, flags.NoPull, flags.SkipChecks, flags.PreBuilt)
|
setup := production.NewProductionSetup(oramaHome, os.Stdout, flags.Force, branch, flags.NoPull, flags.SkipChecks, flags.PreBuilt)
|
||||||
setup.SetNameserver(isNameserver)
|
setup.SetNameserver(isNameserver)
|
||||||
|
|
||||||
// Configure Anyone mode (flag > saved preference)
|
// Configure Anyone mode (flag > saved preference > auto-detect)
|
||||||
if flags.AnyoneRelay {
|
if flags.AnyoneRelay {
|
||||||
setup.SetAnyoneRelayConfig(&production.AnyoneRelayConfig{
|
setup.SetAnyoneRelayConfig(&production.AnyoneRelayConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -71,6 +71,20 @@ func NewOrchestrator(flags *Flags) *Orchestrator {
|
|||||||
Enabled: true,
|
Enabled: true,
|
||||||
ORPort: orPort,
|
ORPort: orPort,
|
||||||
})
|
})
|
||||||
|
} else if detectAnyoneRelay(oramaDir) {
|
||||||
|
// Auto-detect: relay is installed but preferences weren't saved.
|
||||||
|
// This happens when upgrading from older versions that didn't persist
|
||||||
|
// the anyone_relay preference, or when preferences.yaml was reset.
|
||||||
|
orPort := detectAnyoneORPort(oramaDir)
|
||||||
|
setup.SetAnyoneRelayConfig(&production.AnyoneRelayConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ORPort: orPort,
|
||||||
|
})
|
||||||
|
// Save the detected preference for future upgrades
|
||||||
|
prefs.AnyoneRelay = true
|
||||||
|
prefs.AnyoneORPort = orPort
|
||||||
|
_ = production.SavePreferences(oramaDir, prefs)
|
||||||
|
fmt.Printf(" Auto-detected Anyone relay (ORPort: %d), saved to preferences\n", orPort)
|
||||||
} else if flags.AnyoneClient || prefs.AnyoneClient {
|
} else if flags.AnyoneClient || prefs.AnyoneClient {
|
||||||
setup.SetAnyoneClient(true)
|
setup.SetAnyoneClient(true)
|
||||||
}
|
}
|
||||||
@ -648,17 +662,15 @@ func (o *Orchestrator) restartServices() error {
|
|||||||
// Get services to restart
|
// Get services to restart
|
||||||
services := utils.GetProductionServices()
|
services := utils.GetProductionServices()
|
||||||
|
|
||||||
// Re-enable namespace services BEFORE restarting debros-node.
|
// Re-enable all services BEFORE restarting them.
|
||||||
// orama prod stop disables them, and debros-node's PartOf= dependency
|
// orama prod stop disables services, and debros-node's PartOf= dependency
|
||||||
// won't propagate restart to disabled services. We must re-enable first
|
// won't propagate restart to disabled services. We must re-enable first
|
||||||
// so that namespace gateways restart with the updated binary.
|
// so that all services restart with the updated binary.
|
||||||
for _, svc := range services {
|
for _, svc := range services {
|
||||||
if strings.Contains(svc, "@") {
|
|
||||||
if err := exec.Command("systemctl", "enable", svc).Run(); err != nil {
|
if err := exec.Command("systemctl", "enable", svc).Run(); err != nil {
|
||||||
fmt.Printf(" ⚠️ Warning: Failed to re-enable %s: %v\n", svc, err)
|
fmt.Printf(" ⚠️ Warning: Failed to re-enable %s: %v\n", svc, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If this is a nameserver, also restart CoreDNS and Caddy
|
// If this is a nameserver, also restart CoreDNS and Caddy
|
||||||
if o.setup.IsNameserver() {
|
if o.setup.IsNameserver() {
|
||||||
@ -803,3 +815,46 @@ func (o *Orchestrator) waitForClusterHealth(timeout time.Duration) error {
|
|||||||
|
|
||||||
return fmt.Errorf("timeout waiting for cluster to become healthy")
|
return fmt.Errorf("timeout waiting for cluster to become healthy")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detectAnyoneRelay checks if an Anyone relay is installed on this node
|
||||||
|
// by looking for the systemd service file or the anonrc config file.
|
||||||
|
func detectAnyoneRelay(oramaDir string) bool {
|
||||||
|
// Check if systemd service file exists
|
||||||
|
if _, err := os.Stat("/etc/systemd/system/debros-anyone-relay.service"); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Check if anonrc config exists
|
||||||
|
if _, err := os.Stat(filepath.Join(oramaDir, "anyone", "anonrc")); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("/etc/anon/anonrc"); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectAnyoneORPort reads the configured ORPort from anonrc, defaulting to 9001.
|
||||||
|
func detectAnyoneORPort(oramaDir string) int {
|
||||||
|
for _, path := range []string{
|
||||||
|
filepath.Join(oramaDir, "anyone", "anonrc"),
|
||||||
|
"/etc/anon/anonrc",
|
||||||
|
} {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "ORPort ") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
port := 0
|
||||||
|
if _, err := fmt.Sscanf(parts[1], "%d", &port); err == nil && port > 0 {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 9001
|
||||||
|
}
|
||||||
|
|||||||
601
pkg/gateway/auth/solana_nft.go
Normal file
601
pkg/gateway/auth/solana_nft.go
Normal file
@ -0,0 +1,601 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Solana Token Program ID
|
||||||
|
tokenProgramID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
||||||
|
// Metaplex Token Metadata Program ID
|
||||||
|
metaplexProgramID = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SolanaNFTVerifier verifies NFT ownership on Solana via JSON-RPC.
|
||||||
|
type SolanaNFTVerifier struct {
|
||||||
|
rpcURL string
|
||||||
|
collectionAddress string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSolanaNFTVerifier creates a new verifier for the given collection.
|
||||||
|
func NewSolanaNFTVerifier(rpcURL, collectionAddress string) *SolanaNFTVerifier {
|
||||||
|
return &SolanaNFTVerifier{
|
||||||
|
rpcURL: rpcURL,
|
||||||
|
collectionAddress: collectionAddress,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyNFTOwnership checks if the wallet owns at least one NFT from the configured collection.
|
||||||
|
func (v *SolanaNFTVerifier) VerifyNFTOwnership(ctx context.Context, walletAddress string) (bool, error) {
|
||||||
|
// 1. Get all token accounts owned by the wallet
|
||||||
|
tokenAccounts, err := v.getTokenAccountsByOwner(ctx, walletAddress)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get token accounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Filter for NFT-like accounts (amount == 1, decimals == 0)
|
||||||
|
var mints []string
|
||||||
|
for _, ta := range tokenAccounts {
|
||||||
|
if ta.amount == "1" && ta.decimals == 0 {
|
||||||
|
mints = append(mints, ta.mint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mints) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap mints to prevent excessive RPC calls from wallets with many tokens
|
||||||
|
const maxMints = 500
|
||||||
|
if len(mints) > maxMints {
|
||||||
|
mints = mints[:maxMints]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Derive metadata PDA for each mint
|
||||||
|
metaplexProgram, err := base58Decode(metaplexProgramID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to decode metaplex program: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pdas []string
|
||||||
|
for _, mint := range mints {
|
||||||
|
mintBytes, err := base58Decode(mint)
|
||||||
|
if err != nil || len(mintBytes) != 32 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pda, err := findProgramAddress(
|
||||||
|
[][]byte{[]byte("metadata"), metaplexProgram, mintBytes},
|
||||||
|
metaplexProgram,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pdas = append(pdas, base58Encode(pda))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pdas) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Batch fetch metadata accounts (max 100 per call)
|
||||||
|
targetCollection, err := base58Decode(v.collectionAddress)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to decode collection address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(pdas); i += 100 {
|
||||||
|
end := i + 100
|
||||||
|
if end > len(pdas) {
|
||||||
|
end = len(pdas)
|
||||||
|
}
|
||||||
|
batch := pdas[i:end]
|
||||||
|
|
||||||
|
accounts, err := v.getMultipleAccounts(ctx, batch)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get metadata accounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, acct := range accounts {
|
||||||
|
if acct == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
collKey, verified := parseMetaplexCollection(acct)
|
||||||
|
if verified && bytes.Equal(collKey, targetCollection) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenAccountInfo holds parsed SPL token account data.
|
||||||
|
type tokenAccountInfo struct {
|
||||||
|
mint string
|
||||||
|
amount string
|
||||||
|
decimals int
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTokenAccountsByOwner fetches all SPL token accounts for a wallet.
|
||||||
|
func (v *SolanaNFTVerifier) getTokenAccountsByOwner(ctx context.Context, wallet string) ([]tokenAccountInfo, error) {
|
||||||
|
params := []interface{}{
|
||||||
|
wallet,
|
||||||
|
map[string]string{"programId": tokenProgramID},
|
||||||
|
map[string]string{"encoding": "jsonParsed"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := v.rpcCall(ctx, "getTokenAccountsByOwner", params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the result
|
||||||
|
resultMap, ok := result.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected result format")
|
||||||
|
}
|
||||||
|
|
||||||
|
valueArr, ok := resultMap["value"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var accounts []tokenAccountInfo
|
||||||
|
for _, item := range valueArr {
|
||||||
|
itemMap, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
account, ok := itemMap["account"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, ok := account["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed, ok := data["parsed"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, ok := parsed["info"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mint, _ := info["mint"].(string)
|
||||||
|
tokenAmount, ok := info["tokenAmount"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
amount, _ := tokenAmount["amount"].(string)
|
||||||
|
decimals, _ := tokenAmount["decimals"].(float64)
|
||||||
|
|
||||||
|
if mint != "" && amount != "" {
|
||||||
|
accounts = append(accounts, tokenAccountInfo{
|
||||||
|
mint: mint,
|
||||||
|
amount: amount,
|
||||||
|
decimals: int(decimals),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMultipleAccounts fetches multiple accounts by their addresses.
|
||||||
|
// Returns raw account data (base64-decoded) for each address, nil for missing accounts.
|
||||||
|
func (v *SolanaNFTVerifier) getMultipleAccounts(ctx context.Context, addresses []string) ([][]byte, error) {
|
||||||
|
params := []interface{}{
|
||||||
|
addresses,
|
||||||
|
map[string]string{"encoding": "base64"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := v.rpcCall(ctx, "getMultipleAccounts", params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMap, ok := result.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected result format")
|
||||||
|
}
|
||||||
|
|
||||||
|
valueArr, ok := resultMap["value"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts := make([][]byte, len(valueArr))
|
||||||
|
for i, item := range valueArr {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acct, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dataArr, ok := acct["data"].([]interface{})
|
||||||
|
if !ok || len(dataArr) < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dataStr, ok := dataArr[0].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(dataStr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
accounts[i] = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rpcCall executes a Solana JSON-RPC call.
|
||||||
|
func (v *SolanaNFTVerifier) rpcCall(ctx context.Context, method string, params []interface{}) (interface{}, error) {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": method,
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal RPC request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", v.rpcURL, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create RPC request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := v.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("RPC request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("RPC returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit response size to 10MB to prevent memory exhaustion
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read RPC response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rpcResp struct {
|
||||||
|
Result interface{} `json:"result"`
|
||||||
|
Error map[string]interface{} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &rpcResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse RPC response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rpcResp.Error != nil {
|
||||||
|
msg, _ := rpcResp.Error["message"].(string)
|
||||||
|
return nil, fmt.Errorf("RPC error: %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rpcResp.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMetaplexCollection extracts the collection key and verified flag from
|
||||||
|
// Borsh-encoded Metaplex metadata account data.
|
||||||
|
//
|
||||||
|
// Metaplex Token Metadata v1 layout (simplified):
|
||||||
|
// - [0]: key (1 byte, should be 4 for MetadataV1)
|
||||||
|
// - [1..33]: update_authority (32 bytes)
|
||||||
|
// - [33..65]: mint (32 bytes)
|
||||||
|
// - [65..]: name (4-byte len prefix + UTF-8, borsh string)
|
||||||
|
// - then: symbol (borsh string)
|
||||||
|
// - then: uri (borsh string)
|
||||||
|
// - then: seller_fee_basis_points (u16, 2 bytes)
|
||||||
|
// - then: creators (Option<Vec<Creator>>)
|
||||||
|
// - then: primary_sale_happened (bool, 1 byte)
|
||||||
|
// - then: is_mutable (bool, 1 byte)
|
||||||
|
// - then: edition_nonce (Option<u8>)
|
||||||
|
// - then: token_standard (Option<u8>)
|
||||||
|
// - then: collection (Option<Collection>)
|
||||||
|
// - Collection: { verified: bool(1), key: Pubkey(32) }
|
||||||
|
func parseMetaplexCollection(data []byte) (collectionKey []byte, verified bool) {
|
||||||
|
if len(data) < 66 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate metadata key byte (must be 4 = MetadataV1)
|
||||||
|
if data[0] != 4 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip: key(1) + update_authority(32) + mint(32)
|
||||||
|
offset := 65
|
||||||
|
|
||||||
|
// Skip name (borsh string: 4-byte LE length + bytes)
|
||||||
|
offset, _ = skipBorshString(data, offset)
|
||||||
|
if offset < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip symbol
|
||||||
|
offset, _ = skipBorshString(data, offset)
|
||||||
|
if offset < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip uri
|
||||||
|
offset, _ = skipBorshString(data, offset)
|
||||||
|
if offset < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip seller_fee_basis_points (u16)
|
||||||
|
offset += 2
|
||||||
|
if offset > len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip creators (Option<Vec<Creator>>)
|
||||||
|
// Option: 1 byte (0 = None, 1 = Some)
|
||||||
|
if offset >= len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if data[offset] == 1 {
|
||||||
|
offset++ // skip option byte
|
||||||
|
if offset+4 > len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
numCreators := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||||
|
offset += 4
|
||||||
|
// Solana limits creators to 5, but be generous with 20
|
||||||
|
if numCreators > 20 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
// Each Creator: pubkey(32) + verified(1) + share(1) = 34 bytes
|
||||||
|
creatorBytes := numCreators * 34
|
||||||
|
if offset+creatorBytes > len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
offset += creatorBytes
|
||||||
|
} else {
|
||||||
|
offset++ // skip None byte
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset >= len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip primary_sale_happened (bool)
|
||||||
|
offset++
|
||||||
|
if offset >= len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip is_mutable (bool)
|
||||||
|
offset++
|
||||||
|
if offset >= len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip edition_nonce (Option<u8>)
|
||||||
|
if offset >= len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if data[offset] == 1 {
|
||||||
|
offset += 2 // option byte + u8
|
||||||
|
} else {
|
||||||
|
offset++ // None
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip token_standard (Option<u8>)
|
||||||
|
if offset >= len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if data[offset] == 1 {
|
||||||
|
offset += 2
|
||||||
|
} else {
|
||||||
|
offset++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection (Option<Collection>)
|
||||||
|
if offset >= len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if data[offset] == 0 {
|
||||||
|
// No collection
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
offset++ // skip option byte
|
||||||
|
|
||||||
|
// Collection: verified(1 byte bool) + key(32 bytes)
|
||||||
|
if offset+33 > len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
verified = data[offset] == 1
|
||||||
|
offset++
|
||||||
|
collectionKey = data[offset : offset+32]
|
||||||
|
|
||||||
|
return collectionKey, verified
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipBorshString skips a Borsh-encoded string (4-byte LE length + bytes) at the given offset.
|
||||||
|
// Returns the new offset, or -1 if the data is too short.
|
||||||
|
func skipBorshString(data []byte, offset int) (int, string) {
|
||||||
|
if offset+4 > len(data) {
|
||||||
|
return -1, ""
|
||||||
|
}
|
||||||
|
strLen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||||
|
offset += 4
|
||||||
|
if offset+strLen > len(data) {
|
||||||
|
return -1, ""
|
||||||
|
}
|
||||||
|
s := string(data[offset : offset+strLen])
|
||||||
|
return offset + strLen, s
|
||||||
|
}
|
||||||
|
|
||||||
|
// findProgramAddress derives a Solana Program Derived Address (PDA).
|
||||||
|
// It finds the first valid PDA by trying bump seeds from 255 down to 0.
|
||||||
|
func findProgramAddress(seeds [][]byte, programID []byte) ([]byte, error) {
|
||||||
|
for bump := byte(255); ; bump-- {
|
||||||
|
candidate := derivePDA(seeds, bump, programID)
|
||||||
|
if !isOnCurve(candidate) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
if bump == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("could not find valid PDA")
|
||||||
|
}
|
||||||
|
|
||||||
|
// derivePDA computes SHA256(seeds || bump || programID || "ProgramDerivedAddress").
|
||||||
|
func derivePDA(seeds [][]byte, bump byte, programID []byte) []byte {
|
||||||
|
h := sha256.New()
|
||||||
|
for _, seed := range seeds {
|
||||||
|
h.Write(seed)
|
||||||
|
}
|
||||||
|
h.Write([]byte{bump})
|
||||||
|
h.Write(programID)
|
||||||
|
h.Write([]byte("ProgramDerivedAddress"))
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOnCurve checks if a 32-byte key is on the Ed25519 curve.
|
||||||
|
// PDAs must NOT be on the curve (they have no private key).
|
||||||
|
// This uses a simplified check based on the Ed25519 point decompression.
|
||||||
|
func isOnCurve(key []byte) bool {
|
||||||
|
if len(key) != 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ed25519 field prime: p = 2^255 - 19
|
||||||
|
p := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 255), big.NewInt(19))
|
||||||
|
|
||||||
|
// Extract y coordinate (little-endian, clear top bit)
|
||||||
|
yBytes := make([]byte, 32)
|
||||||
|
copy(yBytes, key)
|
||||||
|
yBytes[31] &= 0x7f
|
||||||
|
|
||||||
|
// Reverse for big-endian
|
||||||
|
for i, j := 0, len(yBytes)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
yBytes[i], yBytes[j] = yBytes[j], yBytes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
y := new(big.Int).SetBytes(yBytes)
|
||||||
|
if y.Cmp(p) >= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute u = y^2 - 1
|
||||||
|
y2 := new(big.Int).Mul(y, y)
|
||||||
|
y2.Mod(y2, p)
|
||||||
|
u := new(big.Int).Sub(y2, big.NewInt(1))
|
||||||
|
u.Mod(u, p)
|
||||||
|
if u.Sign() < 0 {
|
||||||
|
u.Add(u, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// d = -121665/121666 mod p
|
||||||
|
d := new(big.Int).SetInt64(121666)
|
||||||
|
d.ModInverse(d, p)
|
||||||
|
d.Mul(d, big.NewInt(-121665))
|
||||||
|
d.Mod(d, p)
|
||||||
|
if d.Sign() < 0 {
|
||||||
|
d.Add(d, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute v = d*y^2 + 1
|
||||||
|
v := new(big.Int).Mul(d, y2)
|
||||||
|
v.Mod(v, p)
|
||||||
|
v.Add(v, big.NewInt(1))
|
||||||
|
v.Mod(v, p)
|
||||||
|
|
||||||
|
// Check if u/v is a quadratic residue mod p
|
||||||
|
// x^2 = u * v^{-1} mod p
|
||||||
|
vInv := new(big.Int).ModInverse(v, p)
|
||||||
|
if vInv == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
x2 := new(big.Int).Mul(u, vInv)
|
||||||
|
x2.Mod(x2, p)
|
||||||
|
|
||||||
|
// Euler criterion: x2^((p-1)/2) mod p == 1 means QR
|
||||||
|
exp := new(big.Int).Sub(p, big.NewInt(1))
|
||||||
|
exp.Rsh(exp, 1)
|
||||||
|
result := new(big.Int).Exp(x2, exp, p)
|
||||||
|
|
||||||
|
return result.Cmp(big.NewInt(1)) == 0 || x2.Sign() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// base58Decode decodes a base58-encoded string (same as Service.Base58Decode but standalone).
|
||||||
|
func base58Decode(input string) ([]byte, error) {
|
||||||
|
const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||||
|
answer := big.NewInt(0)
|
||||||
|
j := big.NewInt(1)
|
||||||
|
for i := len(input) - 1; i >= 0; i-- {
|
||||||
|
tmp := strings.IndexByte(alphabet, input[i])
|
||||||
|
if tmp == -1 {
|
||||||
|
return nil, fmt.Errorf("invalid base58 character")
|
||||||
|
}
|
||||||
|
idx := big.NewInt(int64(tmp))
|
||||||
|
tmp1 := new(big.Int).Mul(idx, j)
|
||||||
|
answer.Add(answer, tmp1)
|
||||||
|
j.Mul(j, big.NewInt(58))
|
||||||
|
}
|
||||||
|
res := answer.Bytes()
|
||||||
|
for i := 0; i < len(input) && input[i] == alphabet[0]; i++ {
|
||||||
|
res = append([]byte{0}, res...)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// base58Encode encodes bytes to base58.
|
||||||
|
func base58Encode(input []byte) string {
|
||||||
|
const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||||
|
|
||||||
|
x := new(big.Int).SetBytes(input)
|
||||||
|
base := big.NewInt(58)
|
||||||
|
zero := big.NewInt(0)
|
||||||
|
mod := new(big.Int)
|
||||||
|
|
||||||
|
var result []byte
|
||||||
|
for x.Cmp(zero) > 0 {
|
||||||
|
x.DivMod(x, base, mod)
|
||||||
|
result = append(result, alphabet[mod.Int64()])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leading zeros
|
||||||
|
for _, b := range input {
|
||||||
|
if b != 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result = append(result, alphabet[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse
|
||||||
|
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
result[i], result[j] = result[j], result[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
@ -41,4 +41,9 @@ type Config struct {
|
|||||||
|
|
||||||
// WireGuard mesh configuration
|
// WireGuard mesh configuration
|
||||||
ClusterSecret string // Cluster secret for authenticating internal WireGuard peer exchange
|
ClusterSecret string // Cluster secret for authenticating internal WireGuard peer exchange
|
||||||
|
|
||||||
|
// Phantom Solana auth configuration
|
||||||
|
PhantomAuthURL string // URL of the deployed Phantom auth React app
|
||||||
|
SolanaRPCURL string // Solana RPC endpoint for NFT verification
|
||||||
|
NFTCollectionAddress string // Required NFT collection address for Phantom auth
|
||||||
}
|
}
|
||||||
|
|||||||
@ -321,6 +321,19 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
|||||||
cfg.ClientNamespace,
|
cfg.ClientNamespace,
|
||||||
gw.withInternalAuth,
|
gw.withInternalAuth,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Configure Phantom Solana auth if env vars are set
|
||||||
|
if cfg.PhantomAuthURL != "" {
|
||||||
|
var solanaVerifier *auth.SolanaNFTVerifier
|
||||||
|
if cfg.SolanaRPCURL != "" && cfg.NFTCollectionAddress != "" {
|
||||||
|
solanaVerifier = auth.NewSolanaNFTVerifier(cfg.SolanaRPCURL, cfg.NFTCollectionAddress)
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Solana NFT verifier configured",
|
||||||
|
zap.String("collection", cfg.NFTCollectionAddress))
|
||||||
|
}
|
||||||
|
gw.authHandlers.SetPhantomConfig(cfg.PhantomAuthURL, solanaVerifier)
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Phantom auth configured",
|
||||||
|
zap.String("auth_url", cfg.PhantomAuthURL))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize middleware cache (60s TTL for auth/routing lookups)
|
// Initialize middleware cache (60s TTL for auth/routing lookups)
|
||||||
|
|||||||
@ -116,10 +116,11 @@ func (h *Handlers) IssueAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SimpleAPIKeyHandler generates an API key without signature verification.
|
// SimpleAPIKeyHandler generates an API key without signature verification.
|
||||||
// This is a simplified flow for development/testing purposes.
|
// Requires an existing valid API key (convenience re-auth only, not standalone).
|
||||||
//
|
//
|
||||||
// POST /v1/auth/simple-key
|
// POST /v1/auth/simple-key
|
||||||
// Request body: SimpleAPIKeyRequest
|
// Request body: SimpleAPIKeyRequest
|
||||||
|
// Headers: X-API-Key or Authorization required
|
||||||
// Response: { "api_key", "namespace", "wallet", "created" }
|
// Response: { "api_key", "namespace", "wallet", "created" }
|
||||||
func (h *Handlers) SimpleAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) SimpleAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.authService == nil {
|
if h.authService == nil {
|
||||||
@ -131,6 +132,13 @@ func (h *Handlers) SimpleAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require existing API key — simple auth is a convenience shortcut, not standalone
|
||||||
|
existingKey, _ := r.Context().Value(CtxKeyAPIKey).(string)
|
||||||
|
if existingKey == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "simple auth requires an existing API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req SimpleAPIKeyRequest
|
var req SimpleAPIKeyRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||||
|
|||||||
@ -56,6 +56,8 @@ type Handlers struct {
|
|||||||
defaultNS string
|
defaultNS string
|
||||||
internalAuthFn func(context.Context) context.Context
|
internalAuthFn func(context.Context) context.Context
|
||||||
clusterProvisioner ClusterProvisioner // Optional: for namespace cluster provisioning
|
clusterProvisioner ClusterProvisioner // Optional: for namespace cluster provisioning
|
||||||
|
phantomAuthURL string // URL of the Phantom auth React app
|
||||||
|
solanaVerifier *authsvc.SolanaNFTVerifier // Server-side NFT ownership verifier
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandlers creates a new authentication handlers instance
|
// NewHandlers creates a new authentication handlers instance
|
||||||
@ -80,6 +82,12 @@ func (h *Handlers) SetClusterProvisioner(cp ClusterProvisioner) {
|
|||||||
h.clusterProvisioner = cp
|
h.clusterProvisioner = cp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPhantomConfig sets the Phantom auth app URL and Solana NFT verifier
|
||||||
|
func (h *Handlers) SetPhantomConfig(authURL string, verifier *authsvc.SolanaNFTVerifier) {
|
||||||
|
h.phantomAuthURL = authURL
|
||||||
|
h.solanaVerifier = verifier
|
||||||
|
}
|
||||||
|
|
||||||
// markNonceUsed marks a nonce as used in the database
|
// markNonceUsed marks a nonce as used in the database
|
||||||
func (h *Handlers) markNonceUsed(ctx context.Context, namespaceID interface{}, wallet, nonce string) {
|
func (h *Handlers) markNonceUsed(ctx context.Context, namespaceID interface{}, wallet, nonce string) {
|
||||||
if h.netClient == nil {
|
if h.netClient == nil {
|
||||||
|
|||||||
318
pkg/gateway/handlers/auth/phantom_handler.go
Normal file
318
pkg/gateway/handlers/auth/phantom_handler.go
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessionIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||||
|
namespaceRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]?$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// PhantomSessionHandler creates a new Phantom auth session.
|
||||||
|
// The CLI calls this to get a session ID and auth URL, then displays a QR code.
|
||||||
|
//
|
||||||
|
// POST /v1/auth/phantom/session
|
||||||
|
// Request body: { "namespace": "myns" }
|
||||||
|
// Response: { "session_id", "auth_url", "expires_at" }
|
||||||
|
func (h *Handlers) PhantomSessionHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.phantomAuthURL == "" {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "phantom auth not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1024)
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace := strings.TrimSpace(req.Namespace)
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = h.defaultNS
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !namespaceRegex.MatchString(namespace) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid namespace format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate session ID
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to generate session ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionID := hex.EncodeToString(buf)
|
||||||
|
expiresAt := time.Now().Add(5 * time.Minute)
|
||||||
|
|
||||||
|
// Store session in DB
|
||||||
|
ctx := r.Context()
|
||||||
|
internalCtx := h.internalAuthFn(ctx)
|
||||||
|
db := h.netClient.Database()
|
||||||
|
|
||||||
|
_, err := db.Query(internalCtx,
|
||||||
|
"INSERT INTO phantom_auth_sessions(id, namespace, status, expires_at) VALUES (?, ?, 'pending', ?)",
|
||||||
|
sessionID, namespace, expiresAt.UTC().Format("2006-01-02 15:04:05"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the auth URL that the phone will open
|
||||||
|
gatewayURL := r.Header.Get("X-Forwarded-Proto") + "://" + r.Header.Get("X-Forwarded-Host")
|
||||||
|
if gatewayURL == "://" {
|
||||||
|
// Fallback: construct from request
|
||||||
|
scheme := "https"
|
||||||
|
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
gatewayURL = scheme + "://" + r.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL := fmt.Sprintf("%s/?session=%s&gateway=%s&namespace=%s",
|
||||||
|
h.phantomAuthURL, sessionID, url.QueryEscape(gatewayURL), url.QueryEscape(namespace))
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"auth_url": authURL,
|
||||||
|
"expires_at": expiresAt.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhantomSessionStatusHandler returns the current status of a Phantom auth session.
|
||||||
|
// The CLI polls this endpoint every 2 seconds waiting for completion.
|
||||||
|
//
|
||||||
|
// GET /v1/auth/phantom/session/{id}
|
||||||
|
// Response: { "session_id", "status", "wallet", "api_key", "namespace" }
|
||||||
|
func (h *Handlers) PhantomSessionStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract session ID from URL path: /v1/auth/phantom/session/{id}
|
||||||
|
sessionID := strings.TrimPrefix(r.URL.Path, "/v1/auth/phantom/session/")
|
||||||
|
sessionID = strings.TrimSpace(sessionID)
|
||||||
|
if sessionID == "" || !sessionIDRegex.MatchString(sessionID) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid session_id format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
internalCtx := h.internalAuthFn(ctx)
|
||||||
|
db := h.netClient.Database()
|
||||||
|
|
||||||
|
res, err := db.Query(internalCtx,
|
||||||
|
"SELECT id, namespace, status, wallet, api_key, error_message, expires_at FROM phantom_auth_sessions WHERE id = ? LIMIT 1",
|
||||||
|
sessionID,
|
||||||
|
)
|
||||||
|
if err != nil || res == nil || res.Count == 0 {
|
||||||
|
writeError(w, http.StatusNotFound, "session not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row, ok := res.Rows[0].([]interface{})
|
||||||
|
if !ok || len(row) < 7 {
|
||||||
|
writeError(w, http.StatusInternalServerError, "invalid session data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := getString(row[2])
|
||||||
|
wallet := getString(row[3])
|
||||||
|
apiKey := getString(row[4])
|
||||||
|
errorMsg := getString(row[5])
|
||||||
|
expiresAtStr := getString(row[6])
|
||||||
|
namespace := getString(row[1])
|
||||||
|
|
||||||
|
// Check expiration if still pending
|
||||||
|
if status == "pending" {
|
||||||
|
if expiresAt, err := time.Parse("2006-01-02 15:04:05", expiresAtStr); err == nil {
|
||||||
|
if time.Now().UTC().After(expiresAt) {
|
||||||
|
status = "expired"
|
||||||
|
// Update in DB
|
||||||
|
_, _ = db.Query(internalCtx,
|
||||||
|
"UPDATE phantom_auth_sessions SET status = 'expired' WHERE id = ? AND status = 'pending'",
|
||||||
|
sessionID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]any{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"status": status,
|
||||||
|
"namespace": namespace,
|
||||||
|
}
|
||||||
|
if wallet != "" {
|
||||||
|
resp["wallet"] = wallet
|
||||||
|
}
|
||||||
|
if apiKey != "" {
|
||||||
|
resp["api_key"] = apiKey
|
||||||
|
}
|
||||||
|
if errorMsg != "" {
|
||||||
|
resp["error"] = errorMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhantomCompleteHandler completes Phantom authentication.
|
||||||
|
// Called by the React auth app after the user signs with Phantom.
|
||||||
|
//
|
||||||
|
// POST /v1/auth/phantom/complete
|
||||||
|
// Request body: { "session_id", "wallet", "nonce", "signature", "namespace" }
|
||||||
|
// Response: { "success": true }
|
||||||
|
func (h *Handlers) PhantomCompleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.authService == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "auth service not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SessionID == "" || req.Wallet == "" || req.Nonce == "" || req.Signature == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "session_id, wallet, nonce and signature are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sessionIDRegex.MatchString(req.SessionID) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid session_id format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
internalCtx := h.internalAuthFn(ctx)
|
||||||
|
db := h.netClient.Database()
|
||||||
|
|
||||||
|
// Validate session exists, is pending, and not expired
|
||||||
|
res, err := db.Query(internalCtx,
|
||||||
|
"SELECT status, expires_at FROM phantom_auth_sessions WHERE id = ? LIMIT 1",
|
||||||
|
req.SessionID,
|
||||||
|
)
|
||||||
|
if err != nil || res == nil || res.Count == 0 {
|
||||||
|
writeError(w, http.StatusNotFound, "session not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row, ok := res.Rows[0].([]interface{})
|
||||||
|
if !ok || len(row) < 2 {
|
||||||
|
writeError(w, http.StatusInternalServerError, "invalid session data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := getString(row[0])
|
||||||
|
expiresAtStr := getString(row[1])
|
||||||
|
|
||||||
|
if status != "pending" {
|
||||||
|
writeError(w, http.StatusConflict, "session is not pending (status: "+status+")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiresAt, err := time.Parse("2006-01-02 15:04:05", expiresAtStr); err == nil {
|
||||||
|
if time.Now().UTC().After(expiresAt) {
|
||||||
|
_, _ = db.Query(internalCtx,
|
||||||
|
"UPDATE phantom_auth_sessions SET status = 'expired' WHERE id = ?",
|
||||||
|
req.SessionID,
|
||||||
|
)
|
||||||
|
writeError(w, http.StatusGone, "session expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Ed25519 signature (Solana)
|
||||||
|
verified, err := h.authService.VerifySignature(ctx, req.Wallet, req.Nonce, req.Signature, "SOL")
|
||||||
|
if err != nil || !verified {
|
||||||
|
h.updateSessionFailed(internalCtx, db, req.SessionID, "signature verification failed")
|
||||||
|
writeError(w, http.StatusUnauthorized, "signature verification failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark nonce used
|
||||||
|
namespace := strings.TrimSpace(req.Namespace)
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = "default"
|
||||||
|
}
|
||||||
|
nsID, _ := h.resolveNamespace(ctx, namespace)
|
||||||
|
h.markNonceUsed(ctx, nsID, strings.ToLower(req.Wallet), req.Nonce)
|
||||||
|
|
||||||
|
// Verify NFT ownership (server-side)
|
||||||
|
if h.solanaVerifier != nil {
|
||||||
|
owns, err := h.solanaVerifier.VerifyNFTOwnership(ctx, req.Wallet)
|
||||||
|
if err != nil {
|
||||||
|
h.updateSessionFailed(internalCtx, db, req.SessionID, "NFT verification error: "+err.Error())
|
||||||
|
writeError(w, http.StatusInternalServerError, "NFT verification failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !owns {
|
||||||
|
h.updateSessionFailed(internalCtx, db, req.SessionID, "wallet does not own required NFT")
|
||||||
|
writeError(w, http.StatusForbidden, "wallet does not own an NFT from the required collection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue API key
|
||||||
|
apiKey, err := h.authService.GetOrCreateAPIKey(ctx, req.Wallet, namespace)
|
||||||
|
if err != nil {
|
||||||
|
h.updateSessionFailed(internalCtx, db, req.SessionID, "failed to issue API key")
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to issue API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session to completed (AND status = 'pending' prevents race condition)
|
||||||
|
_, _ = db.Query(internalCtx,
|
||||||
|
"UPDATE phantom_auth_sessions SET status = 'completed', wallet = ?, api_key = ? WHERE id = ? AND status = 'pending'",
|
||||||
|
strings.ToLower(req.Wallet), apiKey, req.SessionID,
|
||||||
|
)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSessionFailed marks a session as failed with an error message.
|
||||||
|
func (h *Handlers) updateSessionFailed(ctx context.Context, db DatabaseClient, sessionID, errMsg string) {
|
||||||
|
_, _ = db.Query(ctx, "UPDATE phantom_auth_sessions SET status = 'failed', error_message = ? WHERE id = ?", errMsg, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getString extracts a string from an interface value.
|
||||||
|
func getString(v interface{}) string {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@ -118,327 +117,3 @@ func (h *Handlers) RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginPageHandler serves the wallet authentication login page.
|
|
||||||
// This provides an interactive HTML page for wallet-based authentication
|
|
||||||
// using MetaMask or other Web3 wallet providers.
|
|
||||||
//
|
|
||||||
// GET /v1/auth/login?callback=<url>
|
|
||||||
// Query params: callback (required) - URL to redirect after successful auth
|
|
||||||
// Response: HTML page with wallet connection UI
|
|
||||||
func (h *Handlers) LoginPageHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
callbackURL := r.URL.Query().Get("callback")
|
|
||||||
if callbackURL == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "callback parameter is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get default namespace
|
|
||||||
ns := strings.TrimSpace(h.defaultNS)
|
|
||||||
if ns == "" {
|
|
||||||
ns = "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>DeBros Network - Wallet Authentication</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
|
||||||
padding: 40px;
|
|
||||||
max-width: 500px;
|
|
||||||
width: 100%%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #667eea;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.step {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.step-number {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
border-radius: 50%%;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 24px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
background: #5a67d8;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
button:disabled {
|
|
||||||
background: #cbd5e0;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
background: #fed7d7;
|
|
||||||
color: #e53e3e;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 20px 0;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.success {
|
|
||||||
background: #c6f6d5;
|
|
||||||
color: #2f855a;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 20px 0;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.loading {
|
|
||||||
display: none;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
border: 3px solid #f3f3f3;
|
|
||||||
border-top: 3px solid #667eea;
|
|
||||||
border-radius: 50%%;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
0%% { transform: rotate(0deg); }
|
|
||||||
100%% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
.namespace-info {
|
|
||||||
background: #e6fffa;
|
|
||||||
border: 1px solid #81e6d9;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 15px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.code {
|
|
||||||
font-family: 'Monaco', 'Menlo', monospace;
|
|
||||||
background: #f7fafc;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="logo">🌐 DeBros Network</div>
|
|
||||||
<p class="subtitle">Secure Wallet Authentication</p>
|
|
||||||
|
|
||||||
<div class="namespace-info">
|
|
||||||
<strong>📁 Namespace:</strong> <span class="code">%s</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step">
|
|
||||||
<div><span class="step-number">1</span><strong>Connect Your Wallet</strong></div>
|
|
||||||
<p>Click the button below to connect your Ethereum wallet (MetaMask, WalletConnect, etc.)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step">
|
|
||||||
<div><span class="step-number">2</span><strong>Sign Authentication Message</strong></div>
|
|
||||||
<p>Your wallet will prompt you to sign a message to prove your identity. This is free and secure.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step">
|
|
||||||
<div><span class="step-number">3</span><strong>Get Your API Key</strong></div>
|
|
||||||
<p>After signing, you'll receive an API key to access the DeBros Network.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="error" id="error"></div>
|
|
||||||
<div class="success" id="success"></div>
|
|
||||||
|
|
||||||
<div class="loading" id="loading">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>Processing authentication...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick="connectWallet()" id="connectBtn">🔗 Connect Wallet</button>
|
|
||||||
<button onclick="window.close()" style="background: #718096;">❌ Cancel</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const callbackURL = '%s';
|
|
||||||
const namespace = '%s';
|
|
||||||
let walletAddress = null;
|
|
||||||
|
|
||||||
async function connectWallet() {
|
|
||||||
const btn = document.getElementById('connectBtn');
|
|
||||||
const loading = document.getElementById('loading');
|
|
||||||
const error = document.getElementById('error');
|
|
||||||
const success = document.getElementById('success');
|
|
||||||
|
|
||||||
try {
|
|
||||||
btn.disabled = true;
|
|
||||||
loading.style.display = 'block';
|
|
||||||
error.style.display = 'none';
|
|
||||||
success.style.display = 'none';
|
|
||||||
|
|
||||||
// Check if MetaMask is available
|
|
||||||
if (typeof window.ethereum === 'undefined') {
|
|
||||||
throw new Error('Please install MetaMask or another Ethereum wallet');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request account access
|
|
||||||
const accounts = await window.ethereum.request({
|
|
||||||
method: 'eth_requestAccounts'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (accounts.length === 0) {
|
|
||||||
throw new Error('No wallet accounts found');
|
|
||||||
}
|
|
||||||
|
|
||||||
walletAddress = accounts[0];
|
|
||||||
console.log('Connected to wallet:', walletAddress);
|
|
||||||
|
|
||||||
// Step 1: Get challenge nonce
|
|
||||||
const challengeResponse = await fetch('/v1/auth/challenge', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
wallet: walletAddress,
|
|
||||||
purpose: 'api_key_generation',
|
|
||||||
namespace: namespace
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!challengeResponse.ok) {
|
|
||||||
const errorData = await challengeResponse.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to get challenge');
|
|
||||||
}
|
|
||||||
|
|
||||||
const challengeData = await challengeResponse.json();
|
|
||||||
const nonce = challengeData.nonce;
|
|
||||||
|
|
||||||
console.log('Received challenge nonce:', nonce);
|
|
||||||
|
|
||||||
// Step 2: Sign the nonce
|
|
||||||
const signature = await window.ethereum.request({
|
|
||||||
method: 'personal_sign',
|
|
||||||
params: [nonce, walletAddress]
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Signature obtained:', signature);
|
|
||||||
|
|
||||||
// Step 3: Get API key
|
|
||||||
const apiKeyResponse = await fetch('/v1/auth/api-key', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
wallet: walletAddress,
|
|
||||||
nonce: nonce,
|
|
||||||
signature: signature,
|
|
||||||
namespace: namespace
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!apiKeyResponse.ok) {
|
|
||||||
const errorData = await apiKeyResponse.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to get API key');
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKeyData = await apiKeyResponse.json();
|
|
||||||
console.log('API key received:', apiKeyData);
|
|
||||||
|
|
||||||
loading.style.display = 'none';
|
|
||||||
success.innerHTML = '✅ Authentication successful! Redirecting...';
|
|
||||||
success.style.display = 'block';
|
|
||||||
|
|
||||||
// Redirect to callback URL with credentials
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
api_key: apiKeyData.api_key,
|
|
||||||
namespace: apiKeyData.namespace,
|
|
||||||
wallet: apiKeyData.wallet,
|
|
||||||
plan: apiKeyData.plan || 'free'
|
|
||||||
});
|
|
||||||
|
|
||||||
const redirectURL = callbackURL + '?' + params.toString();
|
|
||||||
console.log('Redirecting to:', redirectURL);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = redirectURL;
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Authentication error:', err);
|
|
||||||
loading.style.display = 'none';
|
|
||||||
error.innerHTML = '❌ ' + err.message;
|
|
||||||
error.style.display = 'block';
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-detect if wallet is already connected
|
|
||||||
window.addEventListener('load', async () => {
|
|
||||||
if (typeof window.ethereum !== 'undefined') {
|
|
||||||
try {
|
|
||||||
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
|
|
||||||
if (accounts.length > 0) {
|
|
||||||
const btn = document.getElementById('connectBtn');
|
|
||||||
btn.innerHTML = '🔗 Continue with ' + accounts[0].slice(0, 6) + '...' + accounts[0].slice(-4);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Could not get accounts:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`, ns, callbackURL, ns)
|
|
||||||
|
|
||||||
fmt.Fprint(w, html)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -449,8 +449,13 @@ func isPublicPath(p string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phantom auth endpoints are public (session creation, status polling, completion)
|
||||||
|
if strings.HasPrefix(p, "/v1/auth/phantom/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
switch p {
|
switch p {
|
||||||
case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/login", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout", "/v1/auth/api-key", "/v1/auth/simple-key", "/v1/network/status", "/v1/network/peers", "/v1/internal/tls/check", "/v1/internal/acme/present", "/v1/internal/acme/cleanup", "/v1/internal/ping":
|
case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout", "/v1/auth/api-key", "/v1/network/status", "/v1/network/peers", "/v1/internal/tls/check", "/v1/internal/acme/present", "/v1/internal/acme/cleanup", "/v1/internal/ping":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
// Also exempt namespace status polling endpoint
|
// Also exempt namespace status polling endpoint
|
||||||
|
|||||||
@ -48,10 +48,9 @@ func (g *Gateway) Routes() http.Handler {
|
|||||||
mux.HandleFunc("/v1/auth/jwks", g.authService.JWKSHandler)
|
mux.HandleFunc("/v1/auth/jwks", g.authService.JWKSHandler)
|
||||||
mux.HandleFunc("/.well-known/jwks.json", g.authService.JWKSHandler)
|
mux.HandleFunc("/.well-known/jwks.json", g.authService.JWKSHandler)
|
||||||
if g.authHandlers != nil {
|
if g.authHandlers != nil {
|
||||||
mux.HandleFunc("/v1/auth/login", g.authHandlers.LoginPageHandler)
|
|
||||||
mux.HandleFunc("/v1/auth/challenge", g.authHandlers.ChallengeHandler)
|
mux.HandleFunc("/v1/auth/challenge", g.authHandlers.ChallengeHandler)
|
||||||
mux.HandleFunc("/v1/auth/verify", g.authHandlers.VerifyHandler)
|
mux.HandleFunc("/v1/auth/verify", g.authHandlers.VerifyHandler)
|
||||||
// New: issue JWT from API key; new: create or return API key for a wallet after verification
|
// Issue JWT from API key; create or return API key for a wallet after verification
|
||||||
mux.HandleFunc("/v1/auth/token", g.authHandlers.APIKeyToJWTHandler)
|
mux.HandleFunc("/v1/auth/token", g.authHandlers.APIKeyToJWTHandler)
|
||||||
mux.HandleFunc("/v1/auth/api-key", g.authHandlers.IssueAPIKeyHandler)
|
mux.HandleFunc("/v1/auth/api-key", g.authHandlers.IssueAPIKeyHandler)
|
||||||
mux.HandleFunc("/v1/auth/simple-key", g.authHandlers.SimpleAPIKeyHandler)
|
mux.HandleFunc("/v1/auth/simple-key", g.authHandlers.SimpleAPIKeyHandler)
|
||||||
@ -59,6 +58,10 @@ func (g *Gateway) Routes() http.Handler {
|
|||||||
mux.HandleFunc("/v1/auth/refresh", g.authHandlers.RefreshHandler)
|
mux.HandleFunc("/v1/auth/refresh", g.authHandlers.RefreshHandler)
|
||||||
mux.HandleFunc("/v1/auth/logout", g.authHandlers.LogoutHandler)
|
mux.HandleFunc("/v1/auth/logout", g.authHandlers.LogoutHandler)
|
||||||
mux.HandleFunc("/v1/auth/whoami", g.authHandlers.WhoamiHandler)
|
mux.HandleFunc("/v1/auth/whoami", g.authHandlers.WhoamiHandler)
|
||||||
|
// Phantom Solana auth (QR code + deep link)
|
||||||
|
mux.HandleFunc("/v1/auth/phantom/session", g.authHandlers.PhantomSessionHandler)
|
||||||
|
mux.HandleFunc("/v1/auth/phantom/session/", g.authHandlers.PhantomSessionStatusHandler)
|
||||||
|
mux.HandleFunc("/v1/auth/phantom/complete", g.authHandlers.PhantomCompleteHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// rqlite ORM HTTP gateway (mounts /v1/rqlite/* endpoints)
|
// rqlite ORM HTTP gateway (mounts /v1/rqlite/* endpoints)
|
||||||
|
|||||||
@ -58,6 +58,9 @@ func (n *Node) startHTTPGateway(ctx context.Context) error {
|
|||||||
BaseDomain: n.config.HTTPGateway.BaseDomain,
|
BaseDomain: n.config.HTTPGateway.BaseDomain,
|
||||||
DataDir: oramaDir,
|
DataDir: oramaDir,
|
||||||
ClusterSecret: clusterSecret,
|
ClusterSecret: clusterSecret,
|
||||||
|
PhantomAuthURL: os.Getenv("PHANTOM_AUTH_URL"),
|
||||||
|
SolanaRPCURL: os.Getenv("SOLANA_RPC_URL"),
|
||||||
|
NFTCollectionAddress: os.Getenv("NFT_COLLECTION_ADDRESS"),
|
||||||
}
|
}
|
||||||
|
|
||||||
apiGateway, err := gateway.New(gatewayLogger, gwCfg)
|
apiGateway, err := gateway.New(gatewayLogger, gwCfg)
|
||||||
|
|||||||
130
scripts/patches/fix-anyone-relay.sh
Executable file
130
scripts/patches/fix-anyone-relay.sh
Executable file
@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Patch: Fix Anyone relay after orama upgrade.
|
||||||
|
#
|
||||||
|
# After orama upgrade, the firewall reset drops the ORPort 9001 rule because
|
||||||
|
# preferences.yaml didn't have anyone_relay=true. This patch:
|
||||||
|
# 1. Opens port 9001/tcp in UFW
|
||||||
|
# 2. Re-enables debros-anyone-relay (survives reboot)
|
||||||
|
# 3. Saves anyone_relay preference so future upgrades preserve the rule
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/patches/fix-anyone-relay.sh --devnet
|
||||||
|
# scripts/patches/fix-anyone-relay.sh --testnet
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ENV=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--devnet) ENV="devnet" ;;
|
||||||
|
--testnet) ENV="testnet" ;;
|
||||||
|
-h|--help)
|
||||||
|
echo "Usage: scripts/patches/fix-anyone-relay.sh --devnet|--testnet"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "Unknown flag: $arg" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$ENV" ]]; then
|
||||||
|
echo "ERROR: specify --devnet or --testnet" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
CONF="$ROOT_DIR/scripts/remote-nodes.conf"
|
||||||
|
[[ -f "$CONF" ]] || { echo "ERROR: Missing $CONF" >&2; exit 1; }
|
||||||
|
|
||||||
|
SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o PreferredAuthentications=publickey,password)
|
||||||
|
|
||||||
|
fix_node() {
|
||||||
|
local user_host="$1"
|
||||||
|
local password="$2"
|
||||||
|
local ssh_key="$3"
|
||||||
|
|
||||||
|
# The remote script:
|
||||||
|
# 1. Check if anyone relay service exists, skip if not
|
||||||
|
# 2. Open ORPort 9001 in UFW
|
||||||
|
# 3. Enable the service (auto-start on boot)
|
||||||
|
# 4. Update preferences.yaml with anyone_relay: true
|
||||||
|
local cmd
|
||||||
|
cmd=$(cat <<'REMOTE'
|
||||||
|
set -e
|
||||||
|
PREFS="/home/debros/.orama/preferences.yaml"
|
||||||
|
|
||||||
|
# Only patch nodes that have the Anyone relay service installed
|
||||||
|
if [ ! -f /etc/systemd/system/debros-anyone-relay.service ]; then
|
||||||
|
echo "SKIP_NO_RELAY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Open ORPort 9001 in UFW
|
||||||
|
sudo ufw allow 9001/tcp >/dev/null 2>&1
|
||||||
|
|
||||||
|
# 2. Enable the service so it survives reboot
|
||||||
|
sudo systemctl enable debros-anyone-relay >/dev/null 2>&1
|
||||||
|
|
||||||
|
# 3. Restart the service if not running
|
||||||
|
if ! systemctl is-active --quiet debros-anyone-relay; then
|
||||||
|
sudo systemctl start debros-anyone-relay >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Save anyone_relay preference if missing
|
||||||
|
if [ -f "$PREFS" ]; then
|
||||||
|
if ! grep -q "anyone_relay:" "$PREFS"; then
|
||||||
|
echo "anyone_relay: true" | sudo tee -a "$PREFS" >/dev/null
|
||||||
|
echo "anyone_orport: 9001" | sudo tee -a "$PREFS" >/dev/null
|
||||||
|
elif grep -q "anyone_relay: false" "$PREFS"; then
|
||||||
|
sudo sed -i 's/anyone_relay: false/anyone_relay: true/' "$PREFS"
|
||||||
|
if ! grep -q "anyone_orport:" "$PREFS"; then
|
||||||
|
echo "anyone_orport: 9001" | sudo tee -a "$PREFS" >/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PATCH_OK"
|
||||||
|
REMOTE
|
||||||
|
)
|
||||||
|
|
||||||
|
local result
|
||||||
|
if [[ -n "$ssh_key" ]]; then
|
||||||
|
expanded_key="${ssh_key/#\~/$HOME}"
|
||||||
|
result=$(ssh -n "${SSH_OPTS[@]}" -i "$expanded_key" "$user_host" "$cmd" 2>&1)
|
||||||
|
else
|
||||||
|
result=$(sshpass -p "$password" ssh -n "${SSH_OPTS[@]}" -o PubkeyAuthentication=no "$user_host" "$cmd" 2>&1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$result" | grep -q "PATCH_OK"; then
|
||||||
|
echo " OK $user_host — UFW 9001/tcp opened, service enabled, prefs saved"
|
||||||
|
elif echo "$result" | grep -q "SKIP_NO_RELAY"; then
|
||||||
|
echo " SKIP $user_host — no Anyone relay installed"
|
||||||
|
else
|
||||||
|
echo " ERR $user_host: $result"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse ALL nodes from conf (both node and nameserver roles)
|
||||||
|
# The fix_node function skips nodes without the relay service installed
|
||||||
|
HOSTS=()
|
||||||
|
PASSES=()
|
||||||
|
KEYS=()
|
||||||
|
|
||||||
|
while IFS='|' read -r env host pass role key; do
|
||||||
|
[[ -z "$env" || "$env" == \#* ]] && continue
|
||||||
|
env="${env%%#*}"
|
||||||
|
env="$(echo "$env" | xargs)"
|
||||||
|
[[ "$env" != "$ENV" ]] && continue
|
||||||
|
HOSTS+=("$host")
|
||||||
|
PASSES+=("$pass")
|
||||||
|
KEYS+=("${key:-}")
|
||||||
|
done < "$CONF"
|
||||||
|
|
||||||
|
echo "== fix-anyone-relay ($ENV) — checking ${#HOSTS[@]} nodes =="
|
||||||
|
|
||||||
|
for i in "${!HOSTS[@]}"; do
|
||||||
|
fix_node "${HOSTS[$i]}" "${PASSES[$i]}" "${KEYS[$i]}" &
|
||||||
|
done
|
||||||
|
|
||||||
|
wait
|
||||||
|
echo "Done."
|
||||||
Loading…
x
Reference in New Issue
Block a user