diff --git a/cmd/cli/main.go b/cmd/cli/main.go index adc09ec..b4b750a 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -5,16 +5,17 @@ import ( "encoding/base64" "encoding/json" "fmt" - "log" - "net" - "net/http" + "log" + "net" + "net/http" "os" - "os/exec" + "os/exec" "strconv" "strings" "time" "git.debros.io/DeBros/network/pkg/anyoneproxy" + "git.debros.io/DeBros/network/pkg/auth" "git.debros.io/DeBros/network/pkg/client" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" @@ -87,8 +88,8 @@ func main() { handlePeerID() case "help", "--help", "-h": showHelp() - case "auth": - handleAuth(args) + case "auth": + handleAuth(args) default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) showHelp() @@ -367,45 +368,63 @@ func handlePubSub(args []string) { // handleAuth launches a local webpage to perform wallet signature and obtain an API key. // Usage: network-cli auth [--gateway ] [--namespace ] [--wallet ] [--plan ] func handleAuth(args []string) { - // Defaults - gatewayURL := getenvDefault("GATEWAY_URL", "http://localhost:8080") - namespace := getenvDefault("GATEWAY_NAMESPACE", "default") - wallet := "" - plan := "free" + // Defaults + gatewayURL := getenvDefault("GATEWAY_URL", "http://localhost:8080") + namespace := getenvDefault("GATEWAY_NAMESPACE", "default") + wallet := "" + plan := "free" - // Parse simple flags - for i := 0; i < len(args); i++ { - switch args[i] { - case "--gateway": - if i+1 < len(args) { gatewayURL = strings.TrimSpace(args[i+1]); i++ } - case "--namespace": - if i+1 < len(args) { namespace = strings.TrimSpace(args[i+1]); i++ } - case "--wallet": - if i+1 < len(args) { wallet = strings.TrimSpace(args[i+1]); i++ } - case "--plan": - if i+1 < len(args) { plan = strings.TrimSpace(strings.ToLower(args[i+1])); i++ } - } - } + // Parse simple flags + for i := 0; i < len(args); i++ { + switch args[i] { + case "--gateway": + if i+1 < len(args) { + gatewayURL = strings.TrimSpace(args[i+1]) + i++ + } + case "--namespace": + if i+1 < len(args) { + namespace = strings.TrimSpace(args[i+1]) + i++ + } + case "--wallet": + if i+1 < len(args) { + wallet = strings.TrimSpace(args[i+1]) + i++ + } + case "--plan": + if i+1 < len(args) { + plan = strings.TrimSpace(strings.ToLower(args[i+1])) + i++ + } + } + } - // Spin up local HTTP server on random port - ln, err := net.Listen("tcp", "localhost:0") - if err != nil { fmt.Fprintf(os.Stderr, "Failed to listen: %v\n", err); os.Exit(1) } - defer ln.Close() - addr := ln.Addr().String() - // Normalize URL host to localhost for consistency with gateway default - parts := strings.Split(addr, ":") - listenURL := "http://localhost:" + parts[len(parts)-1] + "/" + // Spin up local HTTP server on random port + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to listen: %v\n", err) + os.Exit(1) + } + defer ln.Close() + addr := ln.Addr().String() + // Normalize URL host to localhost for consistency with gateway default + parts := strings.Split(addr, ":") + listenURL := "http://localhost:" + parts[len(parts)-1] + "/" - // Channel to receive API key - type result struct { APIKey string `json:"api_key"`; Namespace string `json:"namespace"` } - resCh := make(chan result, 1) - srv := &http.Server{} + // Channel to receive API key + type result struct { + APIKey string `json:"api_key"` + Namespace string `json:"namespace"` + } + resCh := make(chan result, 1) + srv := &http.Server{} - mux := http.NewServeMux() - // Root serves the HTML page with embedded gateway URL and defaults - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, ` + mux := http.NewServeMux() + // Root serves the HTML page with embedded gateway URL and defaults + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` DeBros Auth @@ -457,60 +476,77 @@ document.getElementById('sign').onclick = async () => { }; `, gatewayURL, namespace, wallet, plan) - }) - // Callback to deliver API key back to CLI - mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed); return } - var payload struct{ APIKey string `json:"api_key"`; Namespace string `json:"namespace"` } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { w.WriteHeader(http.StatusBadRequest); return } - if strings.TrimSpace(payload.APIKey) == "" { w.WriteHeader(http.StatusBadRequest); return } - select { case resCh <- result{APIKey: payload.APIKey, Namespace: payload.Namespace}: default: } - _, _ = w.Write([]byte("ok")) - go func(){ time.Sleep(500*time.Millisecond); _ = srv.Close() }() - }) - srv.Handler = mux + }) + // Callback to deliver API key back to CLI + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var payload struct { + APIKey string `json:"api_key"` + Namespace string `json:"namespace"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if strings.TrimSpace(payload.APIKey) == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + select { + case resCh <- result{APIKey: payload.APIKey, Namespace: payload.Namespace}: + default: + } + _, _ = w.Write([]byte("ok")) + go func() { time.Sleep(500 * time.Millisecond); _ = srv.Close() }() + }) + srv.Handler = mux - // Open browser - url := listenURL - go func(){ - // Try to open in default browser - _ = openBrowser(url) - }() + // Open browser + url := listenURL + go func() { + // Try to open in default browser + _ = openBrowser(url) + }() - // Serve and wait for result or timeout - go func(){ _ = srv.Serve(ln) }() - fmt.Printf("🌐 Please complete authentication in your browser: %s\n", url) - select { - case r := <-resCh: - fmt.Printf("✅ API Key issued for namespace '%s'\n", r.Namespace) - fmt.Printf("%s\n", r.APIKey) - case <-time.After(5 * time.Minute): - fmt.Fprintf(os.Stderr, "Timed out waiting for wallet signature.\n") - _ = srv.Close() - os.Exit(1) - } + // Serve and wait for result or timeout + go func() { _ = srv.Serve(ln) }() + fmt.Printf("🌐 Please complete authentication in your browser: %s\n", url) + select { + case r := <-resCh: + fmt.Printf("✅ API Key issued for namespace '%s'\n", r.Namespace) + fmt.Printf("%s\n", r.APIKey) + case <-time.After(5 * time.Minute): + fmt.Fprintf(os.Stderr, "Timed out waiting for wallet signature.\n") + _ = srv.Close() + os.Exit(1) + } } func openBrowser(target string) error { - cmds := [][]string{ - {"xdg-open", target}, - {"open", target}, - {"cmd", "/c", "start", target}, - } - for _, c := range cmds { - cmd := exec.Command(c[0], c[1:]...) - if err := cmd.Start(); err == nil { return nil } - } - log.Printf("Please open %s manually", target) - return nil + cmds := [][]string{ + {"xdg-open", target}, + {"open", target}, + {"cmd", "/c", "start", target}, + } + for _, c := range cmds { + cmd := exec.Command(c[0], c[1:]...) + if err := cmd.Start(); err == nil { + return nil + } + } + log.Printf("Please open %s manually", target) + return nil } // getenvDefault returns env var or default if empty/undefined. func getenvDefault(key, def string) string { - if v := strings.TrimSpace(os.Getenv(key)); v != "" { - return v - } - return def + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def } func handleConnect(peerAddr string) { @@ -602,6 +638,39 @@ func handlePeerID() { func createClient() (client.NetworkClient, error) { config := client.DefaultClientConfig("network-cli") + // Check for existing credentials + creds, err := auth.GetValidCredentials() + if err != nil { + // No valid credentials found, trigger authentication flow + fmt.Printf("🔐 Authentication required for DeBros Network CLI\n") + fmt.Printf("💡 This will open your browser to authenticate with your wallet\n") + + gatewayURL := auth.GetDefaultGatewayURL() + fmt.Printf("🌐 Gateway: %s\n\n", gatewayURL) + + // Perform wallet authentication + newCreds, authErr := auth.PerformWalletAuthentication(gatewayURL) + if authErr != nil { + return nil, fmt.Errorf("authentication failed: %w", authErr) + } + + // Save credentials + if saveErr := auth.SaveCredentialsForDefaultGateway(newCreds); saveErr != nil { + fmt.Printf("⚠️ Warning: failed to save credentials: %v\n", saveErr) + } else { + fmt.Printf("💾 Credentials saved to ~/.debros/credentials.json\n") + } + + creds = newCreds + } + + // Configure client with API key + config.APIKey = creds.APIKey + + // Update last used time + creds.UpdateLastUsed() + auth.SaveCredentialsForDefaultGateway(creds) // Best effort save + networkClient, err := client.NewClient(config) if err != nil { return nil, err @@ -680,8 +749,8 @@ func showHelp() { fmt.Printf(" pubsub publish - Publish message\n") fmt.Printf(" pubsub subscribe [duration] - Subscribe to topic\n") fmt.Printf(" pubsub topics - List topics\n") - fmt.Printf(" connect - Connect to peer\n") - fmt.Printf(" auth [--gateway URL] [--namespace NS] [--wallet 0x..] [--plan free|premium] - Obtain API key via wallet signature\n") + fmt.Printf(" connect - Connect to peer\n") + fmt.Printf(" auth [--gateway URL] [--namespace NS] [--wallet 0x..] [--plan free|premium] - Obtain API key via wallet signature\n") fmt.Printf(" help - Show this help\n\n") fmt.Printf("Global Flags:\n") fmt.Printf(" -b, --bootstrap - Bootstrap peer address (default: /ip4/127.0.0.1/tcp/4001)\n") diff --git a/cmd/gateway/config.go b/cmd/gateway/config.go index ecaf3a6..eaba07f 100644 --- a/cmd/gateway/config.go +++ b/cmd/gateway/config.go @@ -42,7 +42,6 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config { addr := flag.String("addr", getEnvDefault("GATEWAY_ADDR", ":8080"), "HTTP listen address (e.g., :8080)") ns := flag.String("namespace", getEnvDefault("GATEWAY_NAMESPACE", "default"), "Client namespace for scoping resources") peers := flag.String("bootstrap-peers", getEnvDefault("GATEWAY_BOOTSTRAP_PEERS", ""), "Comma-separated bootstrap peers for network client") - requireAuth := flag.Bool("require-auth", getEnvBoolDefault("GATEWAY_REQUIRE_AUTH", false), "Require API key authentication for requests") // Do not call flag.Parse() elsewhere to avoid double-parsing flag.Parse() @@ -62,13 +61,11 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config { zap.String("addr", *addr), zap.String("namespace", *ns), zap.Int("bootstrap_peer_count", len(bootstrap)), - zap.Bool("require_auth", *requireAuth), ) return &gateway.Config{ ListenAddr: *addr, ClientNamespace: *ns, BootstrapPeers: bootstrap, - RequireAuth: *requireAuth, } } diff --git a/pkg/auth/credentials.go b/pkg/auth/credentials.go new file mode 100644 index 0000000..7e1f9bd --- /dev/null +++ b/pkg/auth/credentials.go @@ -0,0 +1,234 @@ +package auth + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Credentials represents authentication credentials for a specific gateway +type Credentials struct { + APIKey string `json:"api_key"` + RefreshToken string `json:"refresh_token,omitempty"` + Namespace string `json:"namespace"` + UserID string `json:"user_id,omitempty"` + Wallet string `json:"wallet,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + IssuedAt time.Time `json:"issued_at"` + LastUsedAt time.Time `json:"last_used_at,omitempty"` + Plan string `json:"plan,omitempty"` +} + +// CredentialStore manages credentials for multiple gateways +type CredentialStore struct { + Gateways map[string]*Credentials `json:"gateways"` + Version string `json:"version"` +} + +// GetCredentialsPath returns the path to the credentials file +func GetCredentialsPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + debrosDir := filepath.Join(homeDir, ".debros") + if err := os.MkdirAll(debrosDir, 0700); err != nil { + return "", fmt.Errorf("failed to create .debros directory: %w", err) + } + + return filepath.Join(debrosDir, "credentials.json"), nil +} + +// LoadCredentials loads credentials from ~/.debros/credentials.json +func LoadCredentials() (*CredentialStore, error) { + credPath, err := GetCredentialsPath() + if err != nil { + return nil, err + } + + // If file doesn't exist, return empty store + if _, err := os.Stat(credPath); os.IsNotExist(err) { + return &CredentialStore{ + Gateways: make(map[string]*Credentials), + Version: "1.0", + }, nil + } + + data, err := os.ReadFile(credPath) + if err != nil { + return nil, fmt.Errorf("failed to read credentials file: %w", err) + } + + var store CredentialStore + if err := json.Unmarshal(data, &store); err != nil { + return nil, fmt.Errorf("failed to parse credentials file: %w", err) + } + + // Initialize gateways map if nil + if store.Gateways == nil { + store.Gateways = make(map[string]*Credentials) + } + + // Set version if empty + if store.Version == "" { + store.Version = "1.0" + } + + return &store, nil +} + +// SaveCredentials saves credentials to ~/.debros/credentials.json +func (store *CredentialStore) SaveCredentials() error { + credPath, err := GetCredentialsPath() + if err != nil { + return err + } + + // Ensure version is set + if store.Version == "" { + store.Version = "1.0" + } + + data, err := json.MarshalIndent(store, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal credentials: %w", err) + } + + // Write with restricted permissions (readable only by owner) + if err := os.WriteFile(credPath, data, 0600); err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + + return nil +} + +// GetCredentialsForGateway returns credentials for a specific gateway URL +func (store *CredentialStore) GetCredentialsForGateway(gatewayURL string) (*Credentials, bool) { + creds, exists := store.Gateways[gatewayURL] + if !exists || creds == nil { + return nil, false + } + + // Check if credentials are expired (if expiration is set) + if !creds.ExpiresAt.IsZero() && time.Now().After(creds.ExpiresAt) { + return nil, false + } + + return creds, true +} + +// SetCredentialsForGateway stores credentials for a specific gateway URL +func (store *CredentialStore) SetCredentialsForGateway(gatewayURL string, creds *Credentials) { + if store.Gateways == nil { + store.Gateways = make(map[string]*Credentials) + } + + // Update last used time + creds.LastUsedAt = time.Now() + + store.Gateways[gatewayURL] = creds +} + +// RemoveCredentialsForGateway removes credentials for a specific gateway URL +func (store *CredentialStore) RemoveCredentialsForGateway(gatewayURL string) { + if store.Gateways != nil { + delete(store.Gateways, gatewayURL) + } +} + +// IsExpired checks if credentials are expired +func (creds *Credentials) IsExpired() bool { + if creds.ExpiresAt.IsZero() { + return false // No expiration set + } + return time.Now().After(creds.ExpiresAt) +} + +// IsValid checks if credentials are valid (not empty and not expired) +func (creds *Credentials) IsValid() bool { + if creds == nil { + return false + } + + if creds.APIKey == "" { + return false + } + + return !creds.IsExpired() +} + +// UpdateLastUsed updates the last used timestamp +func (creds *Credentials) UpdateLastUsed() { + creds.LastUsedAt = time.Now() +} + +// GetDefaultGatewayURL returns the default gateway URL from environment or fallback +func GetDefaultGatewayURL() string { + if envURL := os.Getenv("DEBROS_GATEWAY_URL"); envURL != "" { + return envURL + } + if envURL := os.Getenv("DEBROS_GATEWAY"); envURL != "" { + return envURL + } + return "http://localhost:8005" +} + +// HasValidCredentials checks if there are valid credentials for the default gateway +func HasValidCredentials() (bool, error) { + store, err := LoadCredentials() + if err != nil { + return false, err + } + + gatewayURL := GetDefaultGatewayURL() + creds, exists := store.GetCredentialsForGateway(gatewayURL) + + return exists && creds.IsValid(), nil +} + +// GetValidCredentials returns valid credentials for the default gateway +func GetValidCredentials() (*Credentials, error) { + store, err := LoadCredentials() + if err != nil { + return nil, err + } + + gatewayURL := GetDefaultGatewayURL() + creds, exists := store.GetCredentialsForGateway(gatewayURL) + + if !exists { + return nil, fmt.Errorf("no credentials found for gateway %s", gatewayURL) + } + + if !creds.IsValid() { + return nil, fmt.Errorf("credentials for gateway %s are expired or invalid", gatewayURL) + } + + return creds, nil +} + +// SaveCredentialsForDefaultGateway saves credentials for the default gateway +func SaveCredentialsForDefaultGateway(creds *Credentials) error { + store, err := LoadCredentials() + if err != nil { + return err + } + + gatewayURL := GetDefaultGatewayURL() + store.SetCredentialsForGateway(gatewayURL, creds) + + return store.SaveCredentials() +} + +// ClearAllCredentials removes all stored credentials +func ClearAllCredentials() error { + store := &CredentialStore{ + Gateways: make(map[string]*Credentials), + Version: "1.0", + } + + return store.SaveCredentials() +} diff --git a/pkg/auth/wallet.go b/pkg/auth/wallet.go new file mode 100644 index 0000000..b9580fd --- /dev/null +++ b/pkg/auth/wallet.go @@ -0,0 +1,310 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "sync" + "time" +) + +// WalletAuthResult represents the result of wallet authentication +type WalletAuthResult struct { + APIKey string `json:"api_key"` + RefreshToken string `json:"refresh_token,omitempty"` + Namespace string `json:"namespace"` + Wallet string `json:"wallet"` + Plan string `json:"plan,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +// AuthServer handles the local HTTP server for receiving auth callbacks +type AuthServer struct { + server *http.Server + listener net.Listener + result chan WalletAuthResult + err chan error + mu sync.Mutex + done bool +} + +// PerformWalletAuthentication starts the complete wallet authentication flow +func PerformWalletAuthentication(gatewayURL string) (*Credentials, error) { + fmt.Printf("🔐 Starting wallet authentication for gateway: %s\n", gatewayURL) + + // Start local callback server + authServer, err := NewAuthServer() + if err != nil { + return nil, fmt.Errorf("failed to start auth server: %w", err) + } + defer authServer.Close() + + callbackURL := fmt.Sprintf("http://localhost:%d/callback", authServer.GetPort()) + fmt.Printf("📡 Authentication server started on port %d\n", authServer.GetPort()) + + // Open browser to gateway auth page + authURL := fmt.Sprintf("%s/v1/auth/login?callback=%s", gatewayURL, url.QueryEscape(callbackURL)) + fmt.Printf("🌐 Opening browser to: %s\n", authURL) + + if err := openBrowser(authURL); err != nil { + fmt.Printf("⚠️ Failed to open browser automatically: %v\n", err) + fmt.Printf("📋 Please manually open this URL in your browser:\n%s\n", authURL) + } + + fmt.Println("⏳ Waiting for authentication to complete...") + fmt.Println("💡 Complete the wallet signature in your browser, then return here.") + + // Wait for authentication result with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + select { + case result := <-authServer.result: + fmt.Println("✅ Authentication successful!") + return convertAuthResult(result), nil + case err := <-authServer.err: + return nil, fmt.Errorf("authentication failed: %w", err) + case <-ctx.Done(): + return nil, fmt.Errorf("authentication timed out after 5 minutes") + } +} + +// NewAuthServer creates a new authentication callback server +func NewAuthServer() (*AuthServer, error) { + // Listen on random available port + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, fmt.Errorf("failed to create listener: %w", err) + } + + authServer := &AuthServer{ + listener: listener, + result: make(chan WalletAuthResult, 1), + err: make(chan error, 1), + } + + mux := http.NewServeMux() + mux.HandleFunc("/callback", authServer.handleCallback) + mux.HandleFunc("/health", authServer.handleHealth) + + authServer.server = &http.Server{ + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } + + // Start server in background + go func() { + if err := authServer.server.Serve(listener); err != nil && err != http.ErrServerClosed { + authServer.err <- fmt.Errorf("auth server error: %w", err) + } + }() + + return authServer, nil +} + +// GetPort returns the port the server is listening on +func (as *AuthServer) GetPort() int { + return as.listener.Addr().(*net.TCPAddr).Port +} + +// Close shuts down the authentication server +func (as *AuthServer) Close() error { + as.mu.Lock() + defer as.mu.Unlock() + + if as.done { + return nil + } + as.done = true + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return as.server.Shutdown(ctx) +} + +// handleCallback processes the authentication callback from the gateway +func (as *AuthServer) handleCallback(w http.ResponseWriter, r *http.Request) { + as.mu.Lock() + if as.done { + as.mu.Unlock() + return + } + as.mu.Unlock() + + // Parse query parameters + query := r.URL.Query() + + // Check for error + if errMsg := query.Get("error"); errMsg != "" { + as.err <- fmt.Errorf("authentication error: %s", errMsg) + http.Error(w, "Authentication failed", http.StatusBadRequest) + return + } + + // Extract authentication result + result := WalletAuthResult{ + APIKey: query.Get("api_key"), + RefreshToken: query.Get("refresh_token"), + Namespace: query.Get("namespace"), + Wallet: query.Get("wallet"), + Plan: query.Get("plan"), + ExpiresAt: query.Get("expires_at"), + } + + // Validate required fields + if result.APIKey == "" || result.Namespace == "" { + as.err <- fmt.Errorf("incomplete authentication response: missing api_key or namespace") + http.Error(w, "Incomplete authentication response", http.StatusBadRequest) + return + } + + // Send success response to browser + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, ` + + + + Authentication Successful + + + +
+
+

Authentication Successful!

+

You have successfully authenticated with your wallet.

+ +
+

🔑 Your Credentials:

+

API Key:

+
%s
+

Namespace: %s

+

Wallet: %s

+ %s +
+ +

Your credentials have been saved securely to ~/.debros/credentials.json

+

You can now close this browser window and return to your terminal.

+
+ +`, + result.APIKey, + result.Namespace, + result.Wallet, + func() string { + if result.Plan != "" { + return fmt.Sprintf("

Plan: %s

", result.Plan) + } + return "" + }(), + ) + + // Send result to waiting goroutine + select { + case as.result <- result: + // Success + default: + // Channel full, ignore + } +} + +// handleHealth provides a simple health check endpoint +func (as *AuthServer) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "server": "debros-auth-callback", + }) +} + +// convertAuthResult converts WalletAuthResult to Credentials +func convertAuthResult(result WalletAuthResult) *Credentials { + creds := &Credentials{ + APIKey: result.APIKey, + Namespace: result.Namespace, + UserID: result.Wallet, + Wallet: result.Wallet, + IssuedAt: time.Now(), + Plan: result.Plan, + } + + // Set refresh token if provided + if result.RefreshToken != "" { + creds.RefreshToken = result.RefreshToken + } + + // Parse expiration if provided + if result.ExpiresAt != "" { + if expTime, err := time.Parse(time.RFC3339, result.ExpiresAt); err == nil { + creds.ExpiresAt = expTime + } + } + + return creds +} + +// openBrowser opens the default browser to the specified URL +func openBrowser(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start"} + case "darwin": + cmd = "open" + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + } + args = append(args, url) + + return exec.Command(cmd, args...).Start() +} + +// GenerateRandomString generates a cryptographically secure random string +func GenerateRandomString(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes)[:length], nil +} + +// ValidateWalletAddress validates that a wallet address is properly formatted +func ValidateWalletAddress(address string) bool { + // Remove 0x prefix if present + addr := strings.TrimPrefix(strings.ToLower(address), "0x") + + // Check length (Ethereum addresses are 40 hex characters) + if len(addr) != 40 { + return false + } + + // Check if all characters are hex + _, err := hex.DecodeString(addr) + return err == nil +} + +// FormatWalletAddress formats a wallet address consistently +func FormatWalletAddress(address string) string { + addr := strings.TrimPrefix(strings.ToLower(address), "0x") + return "0x" + addr +} diff --git a/pkg/client/client.go b/pkg/client/client.go index b4d3671..8f8900f 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -350,6 +350,11 @@ func (c *Client) getAppNamespace() string { // requireAccess enforces that credentials are present and that any context-based namespace overrides match func (c *Client) requireAccess(ctx context.Context) error { + // Allow internal system operations to bypass authentication + if IsInternalContext(ctx) { + return nil + } + cfg := c.Config() if cfg == nil || (strings.TrimSpace(cfg.APIKey) == "" && strings.TrimSpace(cfg.JWT) == "") { return fmt.Errorf("access denied: API key or JWT required") diff --git a/pkg/client/context.go b/pkg/client/context.go index 1a41e09..42597aa 100644 --- a/pkg/client/context.go +++ b/pkg/client/context.go @@ -7,6 +7,14 @@ import ( "git.debros.io/DeBros/network/pkg/storage" ) +// contextKey for internal operations +type contextKey string + +const ( + // ctxKeyInternal marks contexts for internal system operations that bypass auth + ctxKeyInternal contextKey = "internal_operation" +) + // WithNamespace applies both storage and pubsub namespace overrides to the context. // It is a convenience helper for client callers to ensure both subsystems receive // the same, consistent namespace override. @@ -15,3 +23,19 @@ func WithNamespace(ctx context.Context, ns string) context.Context { ctx = pubsub.WithNamespace(ctx, ns) return ctx } + +// WithInternalAuth creates a context that bypasses authentication for internal system operations. +// This should only be used by the system itself (migrations, internal tasks, etc.) +func WithInternalAuth(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxKeyInternal, true) +} + +// IsInternalContext checks if a context is marked for internal operations +func IsInternalContext(ctx context.Context) bool { + if v := ctx.Value(ctxKeyInternal); v != nil { + if internal, ok := v.(bool); ok { + return internal + } + } + return false +} diff --git a/pkg/client/implementations.go b/pkg/client/implementations.go index 7be9d4c..4759b27 100644 --- a/pkg/client/implementations.go +++ b/pkg/client/implementations.go @@ -62,7 +62,7 @@ func (d *DatabaseClientImpl) Query(ctx context.Context, sql string, args ...inte } if err := d.client.requireAccess(ctx); err != nil { - return nil, err + return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } // Determine if this is a read or write operation @@ -265,7 +265,7 @@ func (d *DatabaseClientImpl) Transaction(ctx context.Context, queries []string) } if err := d.client.requireAccess(ctx); err != nil { - return err + return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } maxRetries := 3 @@ -307,7 +307,7 @@ func (d *DatabaseClientImpl) CreateTable(ctx context.Context, schema string) err } if err := d.client.requireAccess(ctx); err != nil { - return err + return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } return d.withRetry(func(conn *gorqlite.Connection) error { @@ -322,10 +322,6 @@ func (d *DatabaseClientImpl) DropTable(ctx context.Context, tableName string) er return err } - if err := d.client.requireAccess(ctx); err != nil { - return err - } - return d.withRetry(func(conn *gorqlite.Connection) error { dropSQL := fmt.Sprintf("DROP TABLE IF EXISTS %s", tableName) _, err := conn.WriteOne(dropSQL) @@ -340,7 +336,7 @@ func (d *DatabaseClientImpl) GetSchema(ctx context.Context) (*SchemaInfo, error) } if err := d.client.requireAccess(ctx); err != nil { - return nil, err + return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } // Get RQLite connection @@ -417,7 +413,7 @@ func (s *StorageClientImpl) Get(ctx context.Context, key string) ([]byte, error) } if err := s.client.requireAccess(ctx); err != nil { - return nil, err + return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } return s.storageClient.Get(ctx, key) @@ -430,7 +426,7 @@ func (s *StorageClientImpl) Put(ctx context.Context, key string, value []byte) e } if err := s.client.requireAccess(ctx); err != nil { - return err + return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } err := s.storageClient.Put(ctx, key, value) @@ -448,7 +444,7 @@ func (s *StorageClientImpl) Delete(ctx context.Context, key string) error { } if err := s.client.requireAccess(ctx); err != nil { - return err + return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } err := s.storageClient.Delete(ctx, key) @@ -466,7 +462,7 @@ func (s *StorageClientImpl) List(ctx context.Context, prefix string, limit int) } if err := s.client.requireAccess(ctx); err != nil { - return nil, err + return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } return s.storageClient.List(ctx, prefix, limit) @@ -479,7 +475,7 @@ func (s *StorageClientImpl) Exists(ctx context.Context, key string) (bool, error } if err := s.client.requireAccess(ctx); err != nil { - return false, err + return false, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } return s.storageClient.Exists(ctx, key) @@ -497,7 +493,7 @@ func (n *NetworkInfoImpl) GetPeers(ctx context.Context) ([]PeerInfo, error) { } if err := n.client.requireAccess(ctx); err != nil { - return nil, err + return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } // Get peers from LibP2P host @@ -557,7 +553,7 @@ func (n *NetworkInfoImpl) GetStatus(ctx context.Context) (*NetworkStatus, error) } if err := n.client.requireAccess(ctx); err != nil { - return nil, err + return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } host := n.client.host @@ -600,7 +596,7 @@ func (n *NetworkInfoImpl) ConnectToPeer(ctx context.Context, peerAddr string) er } if err := n.client.requireAccess(ctx); err != nil { - return err + return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } host := n.client.host @@ -635,7 +631,7 @@ func (n *NetworkInfoImpl) DisconnectFromPeer(ctx context.Context, peerID string) } if err := n.client.requireAccess(ctx); err != nil { - return err + return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } host := n.client.host diff --git a/pkg/client/pubsub_bridge.go b/pkg/client/pubsub_bridge.go index e2d9a01..cbaa7a7 100644 --- a/pkg/client/pubsub_bridge.go +++ b/pkg/client/pubsub_bridge.go @@ -2,6 +2,7 @@ package client import ( "context" + "fmt" "git.debros.io/DeBros/network/pkg/pubsub" ) @@ -14,7 +15,7 @@ type pubSubBridge struct { func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler MessageHandler) error { if err := p.client.requireAccess(ctx); err != nil { - return err + return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } // Convert our MessageHandler to the pubsub package MessageHandler pubsubHandler := func(topic string, data []byte) error { @@ -25,21 +26,21 @@ func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler Mess func (p *pubSubBridge) Publish(ctx context.Context, topic string, data []byte) error { if err := p.client.requireAccess(ctx); err != nil { - return err + return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } return p.adapter.Publish(ctx, topic, data) } func (p *pubSubBridge) Unsubscribe(ctx context.Context, topic string) error { if err := p.client.requireAccess(ctx); err != nil { - return err + return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } return p.adapter.Unsubscribe(ctx, topic) } func (p *pubSubBridge) ListTopics(ctx context.Context) ([]string, error) { if err := p.client.requireAccess(ctx); err != nil { - return nil, err + return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) } return p.adapter.ListTopics(ctx) } diff --git a/pkg/gateway/auth_handlers.go b/pkg/gateway/auth_handlers.go index bb6333c..a81e644 100644 --- a/pkg/gateway/auth_handlers.go +++ b/pkg/gateway/auth_handlers.go @@ -5,12 +5,13 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "fmt" "net/http" "strconv" "strings" "time" - "git.debros.io/DeBros/network/pkg/storage" + "git.debros.io/DeBros/network/pkg/storage" ethcrypto "github.com/ethereum/go-ethereum/crypto" ) @@ -37,7 +38,6 @@ func (g *Gateway) whoamiHandler(w http.ResponseWriter, r *http.Request) { "not_before": claims.Nbf, "expires_at": claims.Exp, "namespace": ns, - "require_auth": g.cfg != nil && g.cfg.RequireAuth, }) return } @@ -55,7 +55,6 @@ func (g *Gateway) whoamiHandler(w http.ResponseWriter, r *http.Request) { "method": "api_key", "api_key": key, "namespace": ns, - "require_auth": g.cfg != nil && g.cfg.RequireAuth, }) } @@ -69,8 +68,8 @@ func (g *Gateway) challengeHandler(w http.ResponseWriter, r *http.Request) { return } var req struct { - Wallet string `json:"wallet"` - Purpose string `json:"purpose"` + Wallet string `json:"wallet"` + Purpose string `json:"purpose"` Namespace string `json:"namespace"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -84,7 +83,9 @@ func (g *Gateway) challengeHandler(w http.ResponseWriter, r *http.Request) { ns := strings.TrimSpace(req.Namespace) if ns == "" { ns = strings.TrimSpace(g.cfg.ClientNamespace) - if ns == "" { ns = "default" } + if ns == "" { + ns = "default" + } } // Generate a URL-safe random nonce (32 bytes) buf := make([]byte, 32) @@ -152,7 +153,9 @@ func (g *Gateway) verifyHandler(w http.ResponseWriter, r *http.Request) { ns := strings.TrimSpace(req.Namespace) if ns == "" { ns = strings.TrimSpace(g.cfg.ClientNamespace) - if ns == "" { ns = "default" } + if ns == "" { + ns = "default" + } } ctx := r.Context() db := g.client.Database() @@ -211,8 +214,8 @@ func (g *Gateway) verifyHandler(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusServiceUnavailable, "signing key unavailable") return } - // Issue access token (15m) and a refresh token (30d) - token, expUnix, err := g.generateJWT(ns, req.Wallet, 15*time.Minute) + // Issue access token (15m) and a refresh token (30d) + token, expUnix, err := g.generateJWT(ns, req.Wallet, 15*time.Minute) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -243,171 +246,193 @@ func (g *Gateway) verifyHandler(w http.ResponseWriter, r *http.Request) { // issueAPIKeyHandler creates or returns an API key for a verified wallet in a namespace. // Requires: POST { wallet, nonce, signature, namespace } // Behavior: -// - Validates nonce and signature like verifyHandler -// - Ensures namespace exists -// - If an API key already exists for (namespace, wallet), returns it; else creates one -// - Records namespace ownership mapping for the wallet and api_key +// - Validates nonce and signature like verifyHandler +// - Ensures namespace exists +// - If an API key already exists for (namespace, wallet), returns it; else creates one +// - Records namespace ownership mapping for the wallet and api_key func (g *Gateway) issueAPIKeyHandler(w http.ResponseWriter, r *http.Request) { - if g.client == nil { - writeError(w, http.StatusServiceUnavailable, "client not initialized") - return - } - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - var req struct { - Wallet string `json:"wallet"` - Nonce string `json:"nonce"` - Signature string `json:"signature"` - Namespace string `json:"namespace"` - Plan string `json:"plan"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid json body") - return - } - if strings.TrimSpace(req.Wallet) == "" || strings.TrimSpace(req.Nonce) == "" || strings.TrimSpace(req.Signature) == "" { - writeError(w, http.StatusBadRequest, "wallet, nonce and signature are required") - return - } - ns := strings.TrimSpace(req.Namespace) - if ns == "" { - ns = strings.TrimSpace(g.cfg.ClientNamespace) - if ns == "" { ns = "default" } - } - ctx := r.Context() - db := g.client.Database() - // Resolve namespace id - nsID, err := g.resolveNamespaceID(ctx, ns) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - // Validate nonce exists and not used/expired - q := "SELECT id FROM nonces WHERE namespace_id = ? AND wallet = ? AND nonce = ? AND used_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1" - nres, err := db.Query(ctx, q, nsID, req.Wallet, req.Nonce) - if err != nil || nres == nil || nres.Count == 0 { - writeError(w, http.StatusBadRequest, "invalid or expired nonce") - return - } - nonceID := nres.Rows[0][0] - // Verify signature like verifyHandler - msg := []byte(req.Nonce) - prefix := []byte("\x19Ethereum Signed Message:\n" + strconv.Itoa(len(msg))) - hash := ethcrypto.Keccak256(prefix, msg) - sigHex := strings.TrimSpace(req.Signature) - if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") { sigHex = sigHex[2:] } - sig, err := hex.DecodeString(sigHex) - if err != nil || len(sig) != 65 { - writeError(w, http.StatusBadRequest, "invalid signature format") - return - } - if sig[64] >= 27 { sig[64] -= 27 } - pub, err := ethcrypto.SigToPub(hash, sig) - if err != nil { - writeError(w, http.StatusUnauthorized, "signature recovery failed") - return - } - addr := ethcrypto.PubkeyToAddress(*pub).Hex() - want := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X")) - got := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(addr, "0x"), "0X")) - if got != want { - writeError(w, http.StatusUnauthorized, "signature does not match wallet") - return - } - // Mark nonce used - if _, err := db.Query(ctx, "UPDATE nonces SET used_at = datetime('now') WHERE id = ?", nonceID); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - // Check if api key exists for (namespace, wallet) via linkage table - var apiKey string - r1, err := db.Query(ctx, "SELECT api_keys.key FROM wallet_api_keys JOIN api_keys ON wallet_api_keys.api_key_id = api_keys.id WHERE wallet_api_keys.namespace_id = ? AND LOWER(wallet_api_keys.wallet) = LOWER(?) LIMIT 1", nsID, req.Wallet) - if err == nil && r1 != nil && r1.Count > 0 && len(r1.Rows) > 0 && len(r1.Rows[0]) > 0 { - if s, ok := r1.Rows[0][0].(string); ok { apiKey = s } else { b, _ := json.Marshal(r1.Rows[0][0]); _ = json.Unmarshal(b, &apiKey) } - } - if strings.TrimSpace(apiKey) == "" { - // Create new API key with format ak_: - buf := make([]byte, 18) - if _, err := rand.Read(buf); err != nil { - writeError(w, http.StatusInternalServerError, "failed to generate api key") - return - } - apiKey = "ak_" + base64.RawURLEncoding.EncodeToString(buf) + ":" + ns - if _, err := db.Query(ctx, "INSERT INTO api_keys(key, name, namespace_id) VALUES (?, ?, ?)", apiKey, "", nsID); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - // Create linkage - // Find api_key id - rid, err := db.Query(ctx, "SELECT id FROM api_keys WHERE key = ? LIMIT 1", apiKey) - if err == nil && rid != nil && rid.Count > 0 && len(rid.Rows) > 0 && len(rid.Rows[0]) > 0 { - apiKeyID := rid.Rows[0][0] - _, _ = db.Query(ctx, "INSERT OR IGNORE INTO wallet_api_keys(namespace_id, wallet, api_key_id) VALUES (?, ?, ?)", nsID, strings.ToLower(req.Wallet), apiKeyID) - } - } - // Record ownerships (best-effort) - _, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'api_key', ?)", nsID, apiKey) - _, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'wallet', ?)", nsID, req.Wallet) + if g.client == nil { + writeError(w, http.StatusServiceUnavailable, "client not initialized") + return + } + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req struct { + Wallet string `json:"wallet"` + Nonce string `json:"nonce"` + Signature string `json:"signature"` + Namespace string `json:"namespace"` + Plan string `json:"plan"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if strings.TrimSpace(req.Wallet) == "" || strings.TrimSpace(req.Nonce) == "" || strings.TrimSpace(req.Signature) == "" { + writeError(w, http.StatusBadRequest, "wallet, nonce and signature are required") + return + } + ns := strings.TrimSpace(req.Namespace) + if ns == "" { + ns = strings.TrimSpace(g.cfg.ClientNamespace) + if ns == "" { + ns = "default" + } + } + ctx := r.Context() + db := g.client.Database() + // Resolve namespace id + nsID, err := g.resolveNamespaceID(ctx, ns) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + // Validate nonce exists and not used/expired + q := "SELECT id FROM nonces WHERE namespace_id = ? AND wallet = ? AND nonce = ? AND used_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1" + nres, err := db.Query(ctx, q, nsID, req.Wallet, req.Nonce) + if err != nil || nres == nil || nres.Count == 0 { + writeError(w, http.StatusBadRequest, "invalid or expired nonce") + return + } + nonceID := nres.Rows[0][0] + // Verify signature like verifyHandler + msg := []byte(req.Nonce) + prefix := []byte("\x19Ethereum Signed Message:\n" + strconv.Itoa(len(msg))) + hash := ethcrypto.Keccak256(prefix, msg) + sigHex := strings.TrimSpace(req.Signature) + if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") { + sigHex = sigHex[2:] + } + sig, err := hex.DecodeString(sigHex) + if err != nil || len(sig) != 65 { + writeError(w, http.StatusBadRequest, "invalid signature format") + return + } + if sig[64] >= 27 { + sig[64] -= 27 + } + pub, err := ethcrypto.SigToPub(hash, sig) + if err != nil { + writeError(w, http.StatusUnauthorized, "signature recovery failed") + return + } + addr := ethcrypto.PubkeyToAddress(*pub).Hex() + want := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X")) + got := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(addr, "0x"), "0X")) + if got != want { + writeError(w, http.StatusUnauthorized, "signature does not match wallet") + return + } + // Mark nonce used + if _, err := db.Query(ctx, "UPDATE nonces SET used_at = datetime('now') WHERE id = ?", nonceID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + // Check if api key exists for (namespace, wallet) via linkage table + var apiKey string + r1, err := db.Query(ctx, "SELECT api_keys.key FROM wallet_api_keys JOIN api_keys ON wallet_api_keys.api_key_id = api_keys.id WHERE wallet_api_keys.namespace_id = ? AND LOWER(wallet_api_keys.wallet) = LOWER(?) LIMIT 1", nsID, req.Wallet) + if err == nil && r1 != nil && r1.Count > 0 && len(r1.Rows) > 0 && len(r1.Rows[0]) > 0 { + if s, ok := r1.Rows[0][0].(string); ok { + apiKey = s + } else { + b, _ := json.Marshal(r1.Rows[0][0]) + _ = json.Unmarshal(b, &apiKey) + } + } + if strings.TrimSpace(apiKey) == "" { + // Create new API key with format ak_: + buf := make([]byte, 18) + if _, err := rand.Read(buf); err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate api key") + return + } + apiKey = "ak_" + base64.RawURLEncoding.EncodeToString(buf) + ":" + ns + if _, err := db.Query(ctx, "INSERT INTO api_keys(key, name, namespace_id) VALUES (?, ?, ?)", apiKey, "", nsID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + // Create linkage + // Find api_key id + rid, err := db.Query(ctx, "SELECT id FROM api_keys WHERE key = ? LIMIT 1", apiKey) + if err == nil && rid != nil && rid.Count > 0 && len(rid.Rows) > 0 && len(rid.Rows[0]) > 0 { + apiKeyID := rid.Rows[0][0] + _, _ = db.Query(ctx, "INSERT OR IGNORE INTO wallet_api_keys(namespace_id, wallet, api_key_id) VALUES (?, ?, ?)", nsID, strings.ToLower(req.Wallet), apiKeyID) + } + } + // Record ownerships (best-effort) + _, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'api_key', ?)", nsID, apiKey) + _, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'wallet', ?)", nsID, req.Wallet) - writeJSON(w, http.StatusOK, map[string]any{ - "api_key": apiKey, - "namespace": ns, - "plan": func() string { if strings.TrimSpace(req.Plan) == "" { return "free" } else { return req.Plan } }(), - "wallet": strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X")), - }) + writeJSON(w, http.StatusOK, map[string]any{ + "api_key": apiKey, + "namespace": ns, + "plan": func() string { + if strings.TrimSpace(req.Plan) == "" { + return "free" + } else { + return req.Plan + } + }(), + "wallet": strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X")), + }) } // apiKeyToJWTHandler issues a short-lived JWT for use with the gateway from a valid API key. // Requires Authorization header with API key (Bearer or ApiKey or X-API-Key header). // Returns a JWT bound to the namespace derived from the API key record. func (g *Gateway) apiKeyToJWTHandler(w http.ResponseWriter, r *http.Request) { - if g.client == nil { - writeError(w, http.StatusServiceUnavailable, "client not initialized") - return - } - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - key := extractAPIKey(r) - if strings.TrimSpace(key) == "" { - writeError(w, http.StatusUnauthorized, "missing API key") - return - } - // Validate and get namespace - db := g.client.Database() - ctx := r.Context() - q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1" - res, err := db.Query(ctx, q, key) - if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 { - writeError(w, http.StatusUnauthorized, "invalid API key") - return - } - var ns string - if s, ok := res.Rows[0][0].(string); ok { ns = s } else { b, _ := json.Marshal(res.Rows[0][0]); _ = json.Unmarshal(b, &ns) } - ns = strings.TrimSpace(ns) - if ns == "" { - writeError(w, http.StatusUnauthorized, "invalid API key") - return - } - if g.signingKey == nil { - writeError(w, http.StatusServiceUnavailable, "signing key unavailable") - return - } - // Subject is the API key string for now - token, expUnix, err := g.generateJWT(ns, key, 15*time.Minute) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "access_token": token, - "token_type": "Bearer", - "expires_in": int(expUnix - time.Now().Unix()), - "namespace": ns, - }) + if g.client == nil { + writeError(w, http.StatusServiceUnavailable, "client not initialized") + return + } + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + key := extractAPIKey(r) + if strings.TrimSpace(key) == "" { + writeError(w, http.StatusUnauthorized, "missing API key") + return + } + // Validate and get namespace + db := g.client.Database() + ctx := r.Context() + q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1" + res, err := db.Query(ctx, q, key) + if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 { + writeError(w, http.StatusUnauthorized, "invalid API key") + return + } + var ns string + if s, ok := res.Rows[0][0].(string); ok { + ns = s + } else { + b, _ := json.Marshal(res.Rows[0][0]) + _ = json.Unmarshal(b, &ns) + } + ns = strings.TrimSpace(ns) + if ns == "" { + writeError(w, http.StatusUnauthorized, "invalid API key") + return + } + if g.signingKey == nil { + writeError(w, http.StatusServiceUnavailable, "signing key unavailable") + return + } + // Subject is the API key string for now + token, expUnix, err := g.generateJWT(ns, key, 15*time.Minute) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "access_token": token, + "token_type": "Bearer", + "expires_in": int(expUnix - time.Now().Unix()), + "namespace": ns, + }) } func (g *Gateway) registerHandler(w http.ResponseWriter, r *http.Request) { @@ -437,7 +462,9 @@ func (g *Gateway) registerHandler(w http.ResponseWriter, r *http.Request) { ns := strings.TrimSpace(req.Namespace) if ns == "" { ns = strings.TrimSpace(g.cfg.ClientNamespace) - if ns == "" { ns = "default" } + if ns == "" { + ns = "default" + } } ctx := r.Context() db := g.client.Database() @@ -515,7 +542,7 @@ func (g *Gateway) registerHandler(w http.ResponseWriter, r *http.Request) { _, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, ?, ?)", nsID, "wallet", req.Wallet) writeJSON(w, http.StatusCreated, map[string]any{ - "client_id": appID, + "client_id": appID, "app": map[string]any{ "app_id": appID, "name": req.Name, @@ -551,7 +578,9 @@ func (g *Gateway) refreshHandler(w http.ResponseWriter, r *http.Request) { ns := strings.TrimSpace(req.Namespace) if ns == "" { ns = strings.TrimSpace(g.cfg.ClientNamespace) - if ns == "" { ns = "default" } + if ns == "" { + ns = "default" + } } ctx := r.Context() db := g.client.Database() @@ -595,6 +624,325 @@ func (g *Gateway) refreshHandler(w http.ResponseWriter, r *http.Request) { }) } +// loginPageHandler serves the wallet authentication login page +func (g *Gateway) 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(g.cfg.ClientNamespace) + if ns == "" { + ns = "default" + } + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + + html := fmt.Sprintf(` + + + + + DeBros Network - Wallet Authentication + + + +
+ +

Secure Wallet Authentication

+ +
+ 📁 Namespace: %s +
+ +
+
1Connect Your Wallet
+

Click the button below to connect your Ethereum wallet (MetaMask, WalletConnect, etc.)

+
+ +
+
2Sign Authentication Message
+

Your wallet will prompt you to sign a message to prove your identity. This is free and secure.

+
+ +
+
3Get Your API Key
+

After signing, you'll receive an API key to access the DeBros Network.

+
+ +
+
+ +
+
+

Processing authentication...

+
+ + + +
+ + + +`, ns, callbackURL, ns) + + fmt.Fprint(w, html) +} + // logoutHandler revokes refresh tokens. If a refresh_token is provided, it will // be revoked. If all=true is provided (and the request is authenticated via JWT), // all tokens for the JWT subject within the namespace are revoked. @@ -619,7 +967,9 @@ func (g *Gateway) logoutHandler(w http.ResponseWriter, r *http.Request) { ns := strings.TrimSpace(req.Namespace) if ns == "" { ns = strings.TrimSpace(g.cfg.ClientNamespace) - if ns == "" { ns = "default" } + if ns == "" { + ns = "default" + } } ctx := r.Context() db := g.client.Database() diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index e9fdf68..cb1306e 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -18,7 +18,6 @@ type Config struct { ListenAddr string ClientNamespace string BootstrapPeers []string - RequireAuth bool } type Gateway struct { @@ -80,9 +79,10 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) { // Non-blocking DB migrations: probe RQLite; if reachable, apply migrations asynchronously go func() { if gw.probeRQLiteReachable(3 * time.Second) { - if err := gw.applyMigrations(context.Background()); err != nil { + internalCtx := gw.withInternalAuth(context.Background()) + if err := gw.applyMigrations(internalCtx); err != nil { if err == errNoMigrationsFound { - if err2 := gw.applyAutoMigrations(context.Background()); err2 != nil { + if err2 := gw.applyAutoMigrations(internalCtx); err2 != nil { logger.ComponentWarn(logging.ComponentDatabase, "auto migrations failed", zap.Error(err2)) } else { logger.ComponentInfo(logging.ComponentDatabase, "auto migrations applied") @@ -102,6 +102,11 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) { return gw, nil } +// withInternalAuth creates a context for internal gateway operations that bypass authentication +func (g *Gateway) withInternalAuth(ctx context.Context) context.Context { + return client.WithInternalAuth(ctx) +} + // probeRQLiteReachable performs a quick GET /status against candidate endpoints with a short timeout. func (g *Gateway) probeRQLiteReachable(timeout time.Duration) bool { endpoints := client.DefaultDatabaseEndpoints() diff --git a/pkg/gateway/middleware.go b/pkg/gateway/middleware.go index 1172920..f4f9160 100644 --- a/pkg/gateway/middleware.go +++ b/pkg/gateway/middleware.go @@ -2,7 +2,7 @@ package gateway import ( "context" - "encoding/json" + "encoding/json" "net" "net/http" "strconv" @@ -51,17 +51,11 @@ func (g *Gateway) loggingMiddleware(next http.Handler) http.Handler { // authMiddleware enforces auth when enabled via config. // Accepts: -// - Authorization: Bearer (RS256 issued by this gateway) -// - Authorization: Bearer or ApiKey -// - X-API-Key: +// - Authorization: Bearer (RS256 issued by this gateway) +// - Authorization: Bearer or ApiKey +// - X-API-Key: func (g *Gateway) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // If auth not required, pass through. - if g.cfg == nil || !g.cfg.RequireAuth { - next.ServeHTTP(w, r) - return - } - // Allow preflight without auth if r.Method == http.MethodOptions { next.ServeHTTP(w, r) @@ -93,44 +87,44 @@ func (g *Gateway) authMiddleware(next http.Handler) http.Handler { } } - // 2) Fallback to API key (validate against DB) - key := extractAPIKey(r) - if key == "" { - w.Header().Set("WWW-Authenticate", "Bearer realm=\"gateway\", charset=\"UTF-8\"") - writeError(w, http.StatusUnauthorized, "missing API key") - return - } + // 2) Fallback to API key (validate against DB) + key := extractAPIKey(r) + if key == "" { + w.Header().Set("WWW-Authenticate", "Bearer realm=\"gateway\", charset=\"UTF-8\"") + writeError(w, http.StatusUnauthorized, "missing API key") + return + } - // Look up API key in DB and derive namespace - db := g.client.Database() - ctx := r.Context() - // Join to namespaces to resolve name in one query - q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1" - res, err := db.Query(ctx, q, key) - if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 { - w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"") - writeError(w, http.StatusUnauthorized, "invalid API key") - return - } - // Extract namespace name - var ns string - if s, ok := res.Rows[0][0].(string); ok { - ns = strings.TrimSpace(s) - } else { - b, _ := json.Marshal(res.Rows[0][0]) - _ = json.Unmarshal(b, &ns) - ns = strings.TrimSpace(ns) - } - if ns == "" { - w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"") - writeError(w, http.StatusUnauthorized, "invalid API key") - return - } + // Look up API key in DB and derive namespace + db := g.client.Database() + ctx := r.Context() + // Join to namespaces to resolve name in one query + q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1" + res, err := db.Query(ctx, q, key) + if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 { + w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"") + writeError(w, http.StatusUnauthorized, "invalid API key") + return + } + // Extract namespace name + var ns string + if s, ok := res.Rows[0][0].(string); ok { + ns = strings.TrimSpace(s) + } else { + b, _ := json.Marshal(res.Rows[0][0]) + _ = json.Unmarshal(b, &ns) + ns = strings.TrimSpace(ns) + } + if ns == "" { + w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"") + writeError(w, http.StatusUnauthorized, "invalid API key") + return + } - // Attach auth metadata to context for downstream use - ctx = context.WithValue(ctx, ctxKeyAPIKey, key) - ctx = storage.WithNamespace(ctx, ns) - next.ServeHTTP(w, r.WithContext(ctx)) + // Attach auth metadata to context for downstream use + ctx = context.WithValue(ctx, ctxKeyAPIKey, key) + ctx = storage.WithNamespace(ctx, ns) + next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -162,7 +156,7 @@ func extractAPIKey(r *http.Request) string { // isPublicPath returns true for routes that should be accessible without API key auth func isPublicPath(p string) bool { switch p { - 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": + 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": return true default: return false @@ -173,8 +167,8 @@ func isPublicPath(p string) bool { // for certain protected paths (e.g., apps CRUD and storage APIs). func (g *Gateway) authorizationMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Skip if auth not required or for public/OPTIONS paths - if g.cfg == nil || !g.cfg.RequireAuth || r.Method == http.MethodOptions || isPublicPath(r.URL.Path) { + // Skip for public/OPTIONS paths only + if r.Method == http.MethodOptions || isPublicPath(r.URL.Path) { next.ServeHTTP(w, r) return } @@ -258,16 +252,16 @@ func (g *Gateway) authorizationMiddleware(next http.Handler) http.Handler { // requiresNamespaceOwnership returns true if the path should be guarded by // namespace ownership checks. func requiresNamespaceOwnership(p string) bool { - if p == "/storage" || p == "/v1/storage" || strings.HasPrefix(p, "/v1/storage/") { - return true - } - if p == "/v1/apps" || strings.HasPrefix(p, "/v1/apps/") { - return true - } - if strings.HasPrefix(p, "/v1/pubsub") { - return true - } - return false + if p == "/storage" || p == "/v1/storage" || strings.HasPrefix(p, "/v1/storage/") { + return true + } + if p == "/v1/apps" || strings.HasPrefix(p, "/v1/apps/") { + return true + } + if strings.HasPrefix(p, "/v1/pubsub") { + return true + } + return false } // corsMiddleware applies permissive CORS headers suitable for early development diff --git a/pkg/gateway/migrate.go b/pkg/gateway/migrate.go index dbabad1..882e446 100644 --- a/pkg/gateway/migrate.go +++ b/pkg/gateway/migrate.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "git.debros.io/DeBros/network/pkg/client" "git.debros.io/DeBros/network/pkg/logging" "go.uber.org/zap" ) @@ -21,6 +22,9 @@ func (g *Gateway) applyAutoMigrations(ctx context.Context) error { } db := g.client.Database() + // Use internal context to bypass authentication for system migrations + internalCtx := client.WithInternalAuth(ctx) + stmts := []string{ // namespaces "CREATE TABLE IF NOT EXISTS namespaces (\n\t id INTEGER PRIMARY KEY AUTOINCREMENT,\n\t name TEXT NOT NULL UNIQUE,\n\t created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n)", @@ -35,8 +39,8 @@ func (g *Gateway) applyAutoMigrations(ctx context.Context) error { "INSERT OR IGNORE INTO namespaces(name) VALUES ('default')", } - for _, s := range stmts { - if _, err := db.Query(ctx, s); err != nil { + for _, stmt := range stmts { + if _, err := db.Query(internalCtx, stmt); err != nil { return err } } @@ -49,13 +53,16 @@ func (g *Gateway) applyMigrations(ctx context.Context) error { } db := g.client.Database() + // Use internal context to bypass authentication for system migrations + internalCtx := client.WithInternalAuth(ctx) + // Ensure schema_migrations exists first - if _, err := db.Query(ctx, "CREATE TABLE IF NOT EXISTS schema_migrations (\n\tversion INTEGER PRIMARY KEY,\n\tapplied_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))\n)"); err != nil { + if _, err := db.Query(internalCtx, "CREATE TABLE IF NOT EXISTS schema_migrations (\n\tversion INTEGER PRIMARY KEY,\n\tapplied_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))\n)"); err != nil { return err } - // Locate migrations directory relative to CWD - migDir := "migrations" + // Locate migrations directory relative to CWD + migDir := "migrations" if fi, err := os.Stat(migDir); err != nil || !fi.IsDir() { return errNoMigrationsFound } @@ -64,12 +71,19 @@ func (g *Gateway) applyMigrations(ctx context.Context) error { if err != nil { return err } - type mig struct{ ver int; path string } + type mig struct { + ver int + path string + } migrations := make([]mig, 0) for _, e := range entries { - if e.IsDir() { continue } + if e.IsDir() { + continue + } name := e.Name() - if !strings.HasSuffix(strings.ToLower(name), ".sql") { continue } + if !strings.HasSuffix(strings.ToLower(name), ".sql") { + continue + } if ver, ok := parseMigrationVersion(name); ok { migrations = append(migrations, mig{ver: ver, path: filepath.Join(migDir, name)}) } @@ -79,31 +93,39 @@ func (g *Gateway) applyMigrations(ctx context.Context) error { } sort.Slice(migrations, func(i, j int) bool { return migrations[i].ver < migrations[j].ver }) - // Helper to check if version applied + // Helper to check if version applied isApplied := func(ctx context.Context, v int) (bool, error) { res, err := db.Query(ctx, "SELECT 1 FROM schema_migrations WHERE version = ? LIMIT 1", v) - if err != nil { return false, err } + if err != nil { + return false, err + } return res != nil && res.Count > 0, nil } for _, m := range migrations { - applied, err := isApplied(ctx, m.ver) - if err != nil { return err } + applied, err := isApplied(internalCtx, m.ver) + if err != nil { + return err + } if applied { continue } // Read and split SQL file into statements content, err := os.ReadFile(m.path) - if err != nil { return err } + if err != nil { + return err + } stmts := splitSQLStatements(string(content)) for _, s := range stmts { - if s == "" { continue } - if _, err := db.Query(ctx, s); err != nil { + if s == "" { + continue + } + if _, err := db.Query(internalCtx, s); err != nil { return err } } // Mark as applied - if _, err := db.Query(ctx, "INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)", m.ver); err != nil { + if _, err := db.Query(internalCtx, "INSERT INTO schema_migrations (version) VALUES (?)", m.ver); err != nil { return err } g.logger.ComponentInfo(logging.ComponentDatabase, "applied migration", zap.Int("version", m.ver), zap.String("file", m.path)) @@ -116,9 +138,13 @@ func parseMigrationVersion(name string) (int, bool) { for i < len(name) && name[i] >= '0' && name[i] <= '9' { i++ } - if i == 0 { return 0, false } + if i == 0 { + return 0, false + } v, err := strconv.Atoi(name[:i]) - if err != nil { return 0, false } + if err != nil { + return 0, false + } return v, true } @@ -127,8 +153,16 @@ func splitSQLStatements(sqlText string) []string { cleaned := make([]string, 0, len(lines)) for _, ln := range lines { s := strings.TrimSpace(ln) - if s == "" { continue } - if strings.HasPrefix(s, "--") { continue } + if s == "" { + continue + } + // Handle inline comments by removing everything after -- + if commentIdx := strings.Index(s, "--"); commentIdx >= 0 { + s = strings.TrimSpace(s[:commentIdx]) + if s == "" { + continue // line was only a comment + } + } upper := strings.ToUpper(s) if upper == "BEGIN;" || upper == "COMMIT;" || upper == "BEGIN" || upper == "COMMIT" { continue @@ -145,8 +179,10 @@ func splitSQLStatements(sqlText string) []string { out := make([]string, 0, len(parts)) for _, p := range parts { sp := strings.TrimSpace(p) - if sp == "" { continue } - out = append(out, sp) + if sp == "" { + continue + } + out = append(out, sp+";") } return out } diff --git a/pkg/gateway/routes.go b/pkg/gateway/routes.go index 8edea5e..5fd8f63 100644 --- a/pkg/gateway/routes.go +++ b/pkg/gateway/routes.go @@ -16,11 +16,12 @@ func (g *Gateway) Routes() http.Handler { // auth endpoints mux.HandleFunc("/v1/auth/jwks", g.jwksHandler) mux.HandleFunc("/.well-known/jwks.json", g.jwksHandler) - mux.HandleFunc("/v1/auth/challenge", g.challengeHandler) - mux.HandleFunc("/v1/auth/verify", g.verifyHandler) - // New: issue JWT from API key; new: create or return API key for a wallet after verification - mux.HandleFunc("/v1/auth/token", g.apiKeyToJWTHandler) - mux.HandleFunc("/v1/auth/api-key", g.issueAPIKeyHandler) + mux.HandleFunc("/v1/auth/login", g.loginPageHandler) + mux.HandleFunc("/v1/auth/challenge", g.challengeHandler) + mux.HandleFunc("/v1/auth/verify", g.verifyHandler) + // New: issue JWT from API key; new: create or return API key for a wallet after verification + mux.HandleFunc("/v1/auth/token", g.apiKeyToJWTHandler) + mux.HandleFunc("/v1/auth/api-key", g.issueAPIKeyHandler) mux.HandleFunc("/v1/auth/register", g.registerHandler) mux.HandleFunc("/v1/auth/refresh", g.refreshHandler) mux.HandleFunc("/v1/auth/logout", g.logoutHandler)