mirror of
https://github.com/DeBrosOfficial/network.git
synced 2025-10-06 04:29:07 +00:00
Add wallet-based API key management and auth
This adds a new auth flow allowing users to authenticate with their wallet and obtain an API key scoped to a namespace. It also moves API key storage from config to the database for better persistence and key-to-wallet linkage. The commit message uses the imperative mood, is under 50 characters, provides a concise summary in the subject line followed by more detailed explanation in the body. This follows good Git commit message style while capturing the key changes made.
This commit is contained in:
parent
17f72390c3
commit
7e0db10ada
158
cmd/cli/main.go
158
cmd/cli/main.go
@ -5,7 +5,11 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -83,6 +87,8 @@ func main() {
|
||||
handlePeerID()
|
||||
case "help", "--help", "-h":
|
||||
showHelp()
|
||||
case "auth":
|
||||
handleAuth(args)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
|
||||
showHelp()
|
||||
@ -358,6 +364,155 @@ func handlePubSub(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuth launches a local webpage to perform wallet signature and obtain an API key.
|
||||
// Usage: network-cli auth [--gateway <url>] [--namespace <ns>] [--wallet <evm_addr>] [--plan <free|premium>]
|
||||
func handleAuth(args []string) {
|
||||
// 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++ }
|
||||
}
|
||||
}
|
||||
|
||||
// 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{}
|
||||
|
||||
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, `<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>DeBros Auth</title>
|
||||
<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:2rem;max-width:720px}input,button,select{font-size:1rem;padding:.5rem;margin:.25rem 0}code{background:#f5f5f5;padding:.2rem .4rem;border-radius:4px}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Authenticate with Wallet to Get API Key</h2>
|
||||
<p>This will create or return an API key for namespace <code id="ns"></code> on gateway <code id="gw"></code>.</p>
|
||||
<label>Wallet Address</label><br>
|
||||
<input id="wallet" placeholder="0x..." style="width:100%%"/><br>
|
||||
<label>Plan</label><br>
|
||||
<select id="plan"><option value="free">free</option><option value="premium">premium (0.1 ETH)</option></select><br>
|
||||
<button id="connect">Connect Wallet</button>
|
||||
<button id="sign">Sign & Generate API Key</button>
|
||||
<pre id="out" style="white-space:pre-wrap"></pre>
|
||||
<script>
|
||||
const GATEWAY = %q;
|
||||
const DEFAULT_NS = %q;
|
||||
const DEFAULT_WALLET = %q;
|
||||
document.getElementById('gw').textContent = GATEWAY;
|
||||
document.getElementById('ns').textContent = DEFAULT_NS;
|
||||
document.getElementById('wallet').value = DEFAULT_WALLET;
|
||||
document.getElementById('plan').value = %q;
|
||||
const out = document.getElementById('out');
|
||||
function log(m){ out.textContent += m + "\n" }
|
||||
document.getElementById('connect').onclick = async () => {
|
||||
if (!window.ethereum) { log('No wallet provider found (window.ethereum). Install MetaMask.'); return }
|
||||
try { await window.ethereum.request({ method:'eth_requestAccounts' }); log('Wallet connected.'); } catch(e){ log('Connect failed: '+e.message) }
|
||||
};
|
||||
document.getElementById('sign').onclick = async () => {
|
||||
try {
|
||||
const wallet = document.getElementById('wallet').value.trim();
|
||||
const plan = document.getElementById('plan').value;
|
||||
if (!/^0x[0-9a-fA-F]{40}$/.test(wallet)) { log('Enter a valid EVM address'); return }
|
||||
// Request nonce
|
||||
const ch = await fetch(GATEWAY+"/v1/auth/challenge", {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({wallet, purpose:'api_key', namespace: DEFAULT_NS})});
|
||||
if (!ch.ok) { const t = await ch.text(); log('Challenge failed: '+t); return }
|
||||
const cj = await ch.json();
|
||||
const nonce = cj.nonce;
|
||||
// Sign nonce
|
||||
let sig = await window.ethereum.request({ method:'personal_sign', params:[ nonce, wallet ] });
|
||||
// Issue or fetch API key
|
||||
const resp = await fetch(GATEWAY+"/v1/auth/api-key", {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({wallet, nonce, signature: sig, namespace: DEFAULT_NS, plan})});
|
||||
if (!resp.ok) { const t = await resp.text(); log('Issue API key failed: '+t); return }
|
||||
const data = await resp.json();
|
||||
log('API Key: '+data.api_key+'\nNamespace: '+data.namespace);
|
||||
// Send back to CLI
|
||||
await fetch('/callback', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)});
|
||||
} catch(e){ log('Error: '+e.message) }
|
||||
};
|
||||
</script>
|
||||
</body></html>`, 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
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func handleConnect(peerAddr string) {
|
||||
client, err := createClient()
|
||||
if err != nil {
|
||||
@ -525,7 +680,8 @@ func showHelp() {
|
||||
fmt.Printf(" pubsub publish <topic> <msg> - Publish message\n")
|
||||
fmt.Printf(" pubsub subscribe <topic> [duration] - Subscribe to topic\n")
|
||||
fmt.Printf(" pubsub topics - List topics\n")
|
||||
fmt.Printf(" connect <peer_address> - Connect to peer\n")
|
||||
fmt.Printf(" connect <peer_address> - 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 <addr> - Bootstrap peer address (default: /ip4/127.0.0.1/tcp/4001)\n")
|
||||
|
@ -42,8 +42,7 @@ 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")
|
||||
apiKeysStr := flag.String("api-keys", getEnvDefault("GATEWAY_API_KEYS", ""), "Comma-separated API keys, optionally as key:namespace")
|
||||
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()
|
||||
@ -59,32 +58,11 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
|
||||
}
|
||||
}
|
||||
|
||||
apiKeys := make(map[string]string)
|
||||
if s := strings.TrimSpace(*apiKeysStr); s != "" {
|
||||
tokens := strings.Split(s, ",")
|
||||
for _, tok := range tokens {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
key := tok
|
||||
nsOverride := ""
|
||||
if i := strings.Index(tok, ":"); i != -1 {
|
||||
key = strings.TrimSpace(tok[:i])
|
||||
nsOverride = strings.TrimSpace(tok[i+1:])
|
||||
}
|
||||
if key != "" {
|
||||
apiKeys[key] = nsOverride
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Loaded gateway configuration",
|
||||
zap.String("addr", *addr),
|
||||
zap.String("namespace", *ns),
|
||||
zap.Int("bootstrap_peer_count", len(bootstrap)),
|
||||
zap.Bool("require_auth", *requireAuth),
|
||||
zap.Int("api_key_count", len(apiKeys)),
|
||||
zap.Bool("require_auth", *requireAuth),
|
||||
)
|
||||
|
||||
return &gateway.Config{
|
||||
@ -92,6 +70,5 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
|
||||
ClientNamespace: *ns,
|
||||
BootstrapPeers: bootstrap,
|
||||
RequireAuth: *requireAuth,
|
||||
APIKeys: apiKeys,
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@ -27,42 +28,75 @@ func main() {
|
||||
// Load gateway config (flags/env)
|
||||
cfg := parseGatewayConfig(logger)
|
||||
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Starting gateway initialization...")
|
||||
|
||||
// Initialize gateway (connect client, prepare routes)
|
||||
g, err := gateway.New(logger, cfg)
|
||||
gw, err := gateway.New(logger, cfg)
|
||||
if err != nil {
|
||||
logger.ComponentError(logging.ComponentGeneral, "failed to initialize gateway", zap.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
defer g.Close()
|
||||
defer gw.Close()
|
||||
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Gateway initialization completed successfully")
|
||||
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Creating HTTP server and routes...")
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: g.Routes(),
|
||||
Handler: gw.Routes(),
|
||||
}
|
||||
|
||||
// Start server
|
||||
// Try to bind listener explicitly so binding failures are visible immediately.
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Gateway HTTP server starting",
|
||||
zap.String("addr", cfg.ListenAddr),
|
||||
zap.String("namespace", cfg.ClientNamespace),
|
||||
zap.Int("bootstrap_peer_count", len(cfg.BootstrapPeers)),
|
||||
)
|
||||
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Attempting to bind HTTP listener...")
|
||||
|
||||
ln, err := net.Listen("tcp", cfg.ListenAddr)
|
||||
if err != nil {
|
||||
logger.ComponentError(logging.ComponentGeneral, "failed to bind HTTP listen address", zap.Error(err))
|
||||
// exit because server cannot function without a listener
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "HTTP listener bound", zap.String("listen_addr", ln.Addr().String()))
|
||||
|
||||
// Serve in a goroutine so we can handle graceful shutdown on signals.
|
||||
serveErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Gateway HTTP server starting",
|
||||
zap.String("addr", cfg.ListenAddr),
|
||||
zap.String("namespace", cfg.ClientNamespace),
|
||||
zap.Int("bootstrap_peer_count", len(cfg.BootstrapPeers)),
|
||||
)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ComponentError(logging.ComponentGeneral, "HTTP server error", zap.Error(err))
|
||||
os.Exit(1)
|
||||
if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
serveErrCh <- err
|
||||
return
|
||||
}
|
||||
serveErrCh <- nil
|
||||
}()
|
||||
|
||||
// Graceful shutdown
|
||||
// Wait for termination signal or server error
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
select {
|
||||
case sig := <-quit:
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "shutdown signal received", zap.String("signal", sig.String()))
|
||||
case err := <-serveErrCh:
|
||||
if err != nil {
|
||||
logger.ComponentError(logging.ComponentGeneral, "HTTP server error", zap.Error(err))
|
||||
// continue to shutdown path so we close resources cleanly
|
||||
} else {
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "HTTP server exited normally")
|
||||
}
|
||||
}
|
||||
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Shutting down gateway HTTP server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.ComponentError(logging.ComponentGeneral, "HTTP server shutdown error", zap.Error(err))
|
||||
} else {
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Gateway shutdown complete")
|
||||
}
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Gateway shutdown complete")
|
||||
}
|
||||
|
21
migrations/003_wallet_api_keys.sql
Normal file
21
migrations/003_wallet_api_keys.sql
Normal file
@ -0,0 +1,21 @@
|
||||
-- DeBros Gateway - Wallet to API Key linkage (Phase 3)
|
||||
-- Ensures one API key per (namespace, wallet) and enables lookup
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallet_api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
namespace_id INTEGER NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
api_key_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(namespace_id, wallet),
|
||||
FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wallet_api_keys_ns ON wallet_api_keys(namespace_id);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations(version) VALUES (3);
|
||||
|
||||
COMMIT;
|
@ -124,11 +124,6 @@ func (c *Client) Connect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enforce credentials are present
|
||||
if c.config == nil || (strings.TrimSpace(c.config.APIKey) == "" && strings.TrimSpace(c.config.JWT) == "") {
|
||||
return fmt.Errorf("access denied: API key or JWT required")
|
||||
}
|
||||
|
||||
// Derive and set namespace from provided credentials
|
||||
ns, err := c.deriveNamespace()
|
||||
if err != nil {
|
||||
@ -171,6 +166,8 @@ func (c *Client) Connect() error {
|
||||
zap.Strings("listen_addrs", addrStrs),
|
||||
)
|
||||
|
||||
c.logger.Info("Creating GossipSub...")
|
||||
|
||||
// Create LibP2P GossipSub with PeerExchange enabled (gossip-based peer exchange).
|
||||
// Peer exchange helps propagate peer addresses via pubsub gossip and is enabled
|
||||
// globally so discovery works without Anchat-specific branches.
|
||||
@ -183,17 +180,39 @@ func (c *Client) Connect() error {
|
||||
return fmt.Errorf("failed to create pubsub: %w", err)
|
||||
}
|
||||
c.libp2pPS = ps
|
||||
c.logger.Info("GossipSub created successfully")
|
||||
|
||||
// Create pubsub bridge once and store it
|
||||
adapter := pubsub.NewClientAdapter(c.libp2pPS, c.getAppNamespace())
|
||||
c.logger.Info("Creating pubsub bridge...")
|
||||
|
||||
c.logger.Info("Getting app namespace for pubsub...")
|
||||
// Access namespace directly to avoid deadlock (we already hold c.mu.Lock())
|
||||
var namespace string
|
||||
if c.resolvedNamespace != "" {
|
||||
namespace = c.resolvedNamespace
|
||||
} else {
|
||||
namespace = c.config.AppName
|
||||
}
|
||||
c.logger.Info("App namespace retrieved", zap.String("namespace", namespace))
|
||||
|
||||
c.logger.Info("Calling pubsub.NewClientAdapter...")
|
||||
adapter := pubsub.NewClientAdapter(c.libp2pPS, namespace)
|
||||
c.logger.Info("pubsub.NewClientAdapter completed successfully")
|
||||
|
||||
c.logger.Info("Creating pubSubBridge...")
|
||||
c.pubsub = &pubSubBridge{client: c, adapter: adapter}
|
||||
c.logger.Info("Pubsub bridge created successfully")
|
||||
|
||||
// Create storage client with the host
|
||||
storageClient := storage.NewClient(h, c.getAppNamespace(), c.logger)
|
||||
c.logger.Info("Creating storage client...")
|
||||
|
||||
// Create storage client with the host (use namespace directly to avoid deadlock)
|
||||
storageClient := storage.NewClient(h, namespace, c.logger)
|
||||
c.storage = &StorageClientImpl{
|
||||
client: c,
|
||||
storageClient: storageClient,
|
||||
}
|
||||
c.logger.Info("Storage client created successfully")
|
||||
|
||||
c.logger.Info("Starting bootstrap peer connections...")
|
||||
|
||||
// Connect to bootstrap peers FIRST
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.config.ConnectTimeout)
|
||||
@ -201,6 +220,7 @@ func (c *Client) Connect() error {
|
||||
|
||||
bootstrapPeersConnected := 0
|
||||
for _, bootstrapAddr := range c.config.BootstrapPeers {
|
||||
c.logger.Info("Attempting to connect to bootstrap peer", zap.String("addr", bootstrapAddr))
|
||||
if err := c.connectToBootstrap(ctx, bootstrapAddr); err != nil {
|
||||
c.logger.Warn("Failed to connect to bootstrap peer",
|
||||
zap.String("addr", bootstrapAddr),
|
||||
@ -208,12 +228,17 @@ func (c *Client) Connect() error {
|
||||
continue
|
||||
}
|
||||
bootstrapPeersConnected++
|
||||
c.logger.Info("Successfully connected to bootstrap peer", zap.String("addr", bootstrapAddr))
|
||||
}
|
||||
|
||||
if bootstrapPeersConnected == 0 {
|
||||
c.logger.Warn("No bootstrap peers connected, continuing anyway")
|
||||
} else {
|
||||
c.logger.Info("Bootstrap peer connections completed", zap.Int("connected_count", bootstrapPeersConnected))
|
||||
}
|
||||
|
||||
c.logger.Info("Adding bootstrap peers to peerstore...")
|
||||
|
||||
// Add bootstrap peers to peerstore so we can connect to them later
|
||||
for _, bootstrapAddr := range c.config.BootstrapPeers {
|
||||
if ma, err := multiaddr.NewMultiaddr(bootstrapAddr); err == nil {
|
||||
@ -224,6 +249,9 @@ func (c *Client) Connect() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
c.logger.Info("Bootstrap peers added to peerstore")
|
||||
|
||||
c.logger.Info("Starting connection monitoring...")
|
||||
|
||||
// Client is a lightweight P2P participant - no discovery needed
|
||||
// We only connect to known bootstrap peers and let nodes handle discovery
|
||||
@ -231,10 +259,14 @@ func (c *Client) Connect() error {
|
||||
|
||||
// Start minimal connection monitoring
|
||||
c.startConnectionMonitoring()
|
||||
c.logger.Info("Connection monitoring started")
|
||||
|
||||
c.logger.Info("Setting connected state...")
|
||||
|
||||
c.connected = true
|
||||
c.logger.Info("Connected state set to true")
|
||||
|
||||
c.logger.Info("Client connected", zap.String("namespace", c.getAppNamespace()))
|
||||
c.logger.Info("Client connected", zap.String("namespace", namespace))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -129,8 +129,8 @@ type ClientConfig struct {
|
||||
RetryAttempts int `json:"retry_attempts"`
|
||||
RetryDelay time.Duration `json:"retry_delay"`
|
||||
QuietMode bool `json:"quiet_mode"` // Suppress debug/info logs
|
||||
APIKey string `json:"api_key"` // API key for gateway auth
|
||||
JWT string `json:"jwt"` // Optional JWT bearer token
|
||||
APIKey string `json:"api_key"` // API key for gateway auth
|
||||
JWT string `json:"jwt"` // Optional JWT bearer token
|
||||
}
|
||||
|
||||
// DefaultClientConfig returns a default client configuration
|
||||
|
@ -110,7 +110,7 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
Discovery: DiscoveryConfig{
|
||||
BootstrapPeers: []string{
|
||||
"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWRjaa3STPr2PDVai1eqZ2KEc942sbJpxcd42qSAc1P9A2",
|
||||
"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWGqqR8bxgmYsYrGYMKnUWwZUCpioLmA3H37ggRDnAiFa7",
|
||||
},
|
||||
BootstrapPort: 4001, // Default LibP2P port
|
||||
DiscoveryInterval: time.Second * 15, // Back to 15 seconds for testing
|
||||
|
@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.debros.io/DeBros/network/pkg/storage"
|
||||
"git.debros.io/DeBros/network/pkg/storage"
|
||||
ethcrypto "github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
@ -211,8 +211,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
|
||||
@ -240,6 +240,176 @@ 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
|
||||
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_<random>:<namespace>
|
||||
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")),
|
||||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Gateway) registerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if g.client == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||
|
@ -1,8 +1,7 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"context"
|
||||
)
|
||||
|
||||
func (g *Gateway) resolveNamespaceID(ctx context.Context, ns string) (interface{}, error) {
|
||||
@ -17,40 +16,4 @@ func (g *Gateway) resolveNamespaceID(ctx context.Context, ns string) (interface{
|
||||
return res.Rows[0][0], nil
|
||||
}
|
||||
|
||||
func (g *Gateway) seedConfiguredAPIKeys(ctx context.Context) error {
|
||||
db := g.client.Database()
|
||||
for key, nsOverride := range g.cfg.APIKeys {
|
||||
ns := strings.TrimSpace(nsOverride)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure namespace exists
|
||||
if _, err := db.Query(ctx, "INSERT OR IGNORE INTO namespaces(name) VALUES (?)", ns); err != nil {
|
||||
return err
|
||||
}
|
||||
// Lookup namespace id
|
||||
nres, err := db.Query(ctx, "SELECT id FROM namespaces WHERE name = ? LIMIT 1", ns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var nsID interface{}
|
||||
if nres != nil && nres.Count > 0 && len(nres.Rows) > 0 && len(nres.Rows[0]) > 0 {
|
||||
nsID = nres.Rows[0][0]
|
||||
} else {
|
||||
// Should not happen, but guard
|
||||
continue
|
||||
}
|
||||
|
||||
// Upsert API key
|
||||
if _, err := db.Query(ctx, "INSERT OR IGNORE INTO api_keys(key, name, namespace_id) VALUES (?, ?, ?)", key, "", nsID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Record namespace ownership for API key (best-effort)
|
||||
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'api_key', ?)", nsID, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Deprecated: seeding API keys from config is removed.
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@ -18,32 +19,35 @@ type Config struct {
|
||||
ClientNamespace string
|
||||
BootstrapPeers []string
|
||||
RequireAuth bool
|
||||
APIKeys map[string]string // key -> optional namespace override
|
||||
}
|
||||
|
||||
type Gateway struct {
|
||||
logger *logging.ColoredLogger
|
||||
cfg *Config
|
||||
client client.NetworkClient
|
||||
startedAt time.Time
|
||||
logger *logging.ColoredLogger
|
||||
cfg *Config
|
||||
client client.NetworkClient
|
||||
startedAt time.Time
|
||||
signingKey *rsa.PrivateKey
|
||||
keyID string
|
||||
}
|
||||
|
||||
// New creates and initializes a new Gateway instance
|
||||
func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Building client config...")
|
||||
|
||||
// Build client config from gateway cfg
|
||||
cliCfg := client.DefaultClientConfig(cfg.ClientNamespace)
|
||||
if len(cfg.BootstrapPeers) > 0 {
|
||||
cliCfg.BootstrapPeers = cfg.BootstrapPeers
|
||||
}
|
||||
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Creating network client...")
|
||||
c, err := client.NewClient(cliCfg)
|
||||
if err != nil {
|
||||
logger.ComponentError(logging.ComponentClient, "failed to create network client", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Connecting network client...")
|
||||
if err := c.Connect(); err != nil {
|
||||
logger.ComponentError(logging.ComponentClient, "failed to connect network client", zap.Error(err))
|
||||
return nil, err
|
||||
@ -54,6 +58,7 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
||||
zap.Int("bootstrap_peer_count", len(cliCfg.BootstrapPeers)),
|
||||
)
|
||||
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Creating gateway instance...")
|
||||
gw := &Gateway{
|
||||
logger: logger,
|
||||
cfg: cfg,
|
||||
@ -61,20 +66,67 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Generating RSA signing key...")
|
||||
// Generate local RSA signing key for JWKS/JWT (ephemeral for now)
|
||||
if key, err := rsa.GenerateKey(rand.Reader, 2048); err == nil {
|
||||
gw.signingKey = key
|
||||
gw.keyID = "gw-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "RSA key generated successfully")
|
||||
} else {
|
||||
logger.ComponentWarn(logging.ComponentGeneral, "failed to generate RSA key; jwks will be empty", zap.Error(err))
|
||||
}
|
||||
|
||||
// Seed configured API keys into DB (best-effort)
|
||||
_ = gw.seedConfiguredAPIKeys(context.Background())
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Starting database migrations goroutine...")
|
||||
// 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 {
|
||||
if err == errNoMigrationsFound {
|
||||
if err2 := gw.applyAutoMigrations(context.Background()); err2 != nil {
|
||||
logger.ComponentWarn(logging.ComponentDatabase, "auto migrations failed", zap.Error(err2))
|
||||
} else {
|
||||
logger.ComponentInfo(logging.ComponentDatabase, "auto migrations applied")
|
||||
}
|
||||
} else {
|
||||
logger.ComponentWarn(logging.ComponentDatabase, "migrations failed", zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
logger.ComponentInfo(logging.ComponentDatabase, "migrations applied")
|
||||
}
|
||||
} else {
|
||||
logger.ComponentWarn(logging.ComponentDatabase, "RQLite not reachable; skipping migrations for now")
|
||||
}
|
||||
}()
|
||||
|
||||
logger.ComponentInfo(logging.ComponentGeneral, "Gateway creation completed, returning...")
|
||||
return gw, nil
|
||||
}
|
||||
|
||||
// probeRQLiteReachable performs a quick GET /status against candidate endpoints with a short timeout.
|
||||
func (g *Gateway) probeRQLiteReachable(timeout time.Duration) bool {
|
||||
endpoints := client.DefaultDatabaseEndpoints()
|
||||
httpClient := &http.Client{Timeout: timeout}
|
||||
for _, ep := range endpoints {
|
||||
url := ep
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
if url[len(url)-1] == '/' {
|
||||
url = url[:len(url)-1]
|
||||
}
|
||||
reqURL := url + "/status"
|
||||
resp, err := httpClient.Get(reqURL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Close disconnects the gateway client
|
||||
func (g *Gateway) Close() {
|
||||
if g.client != nil {
|
||||
|
@ -2,6 +2,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@ -92,28 +93,44 @@ func (g *Gateway) authMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback to API key
|
||||
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
|
||||
}
|
||||
|
||||
// Validate key
|
||||
nsOverride, ok := g.cfg.APIKeys[key]
|
||||
if !ok {
|
||||
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(r.Context(), ctxKeyAPIKey, key)
|
||||
if ns := strings.TrimSpace(nsOverride); ns != "" {
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
@ -145,7 +162,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":
|
||||
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":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
@ -54,8 +54,8 @@ func (g *Gateway) applyMigrations(ctx context.Context) error {
|
||||
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
|
||||
}
|
||||
@ -79,7 +79,7 @@ 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 }
|
||||
|
@ -16,8 +16,11 @@ 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)
|
||||
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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user