package auth import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "net/http" "regexp" "strings" "time" ) var ( sessionIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`) namespaceRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]?$`) ) // PhantomSessionHandler creates a new Phantom auth session. // The CLI calls this to get a session ID and auth URL, then displays a QR code. // // POST /v1/auth/phantom/session // Request body: { "namespace": "myns" } // Response: { "session_id", "auth_url", "expires_at" } func (h *Handlers) PhantomSessionHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } var req struct { Namespace string `json:"namespace"` } r.Body = http.MaxBytesReader(w, r.Body, 1024) if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json body") return } namespace := strings.TrimSpace(req.Namespace) if namespace == "" { namespace = h.defaultNS if namespace == "" { namespace = "default" } } if !namespaceRegex.MatchString(namespace) { writeError(w, http.StatusBadRequest, "invalid namespace format") return } // Generate session ID buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { writeError(w, http.StatusInternalServerError, "failed to generate session ID") return } sessionID := hex.EncodeToString(buf) expiresAt := time.Now().Add(5 * time.Minute) // Store session in DB ctx := r.Context() internalCtx := h.internalAuthFn(ctx) db := h.netClient.Database() _, err := db.Query(internalCtx, "INSERT INTO phantom_auth_sessions(id, namespace, status, expires_at) VALUES (?, ?, 'pending', ?)", sessionID, namespace, expiresAt.UTC().Format("2006-01-02 15:04:05"), ) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create session") return } writeJSON(w, http.StatusOK, map[string]any{ "session_id": sessionID, "expires_at": expiresAt.UTC().Format(time.RFC3339), }) } // PhantomSessionStatusHandler returns the current status of a Phantom auth session. // The CLI polls this endpoint every 2 seconds waiting for completion. // // GET /v1/auth/phantom/session/{id} // Response: { "session_id", "status", "wallet", "api_key", "namespace" } func (h *Handlers) PhantomSessionStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } // Extract session ID from URL path: /v1/auth/phantom/session/{id} sessionID := strings.TrimPrefix(r.URL.Path, "/v1/auth/phantom/session/") sessionID = strings.TrimSpace(sessionID) if sessionID == "" || !sessionIDRegex.MatchString(sessionID) { writeError(w, http.StatusBadRequest, "invalid session_id format") return } ctx := r.Context() internalCtx := h.internalAuthFn(ctx) db := h.netClient.Database() res, err := db.Query(internalCtx, "SELECT id, namespace, status, wallet, api_key, error_message, expires_at FROM phantom_auth_sessions WHERE id = ? LIMIT 1", sessionID, ) if err != nil || res == nil || res.Count == 0 { writeError(w, http.StatusNotFound, "session not found") return } row, ok := res.Rows[0].([]interface{}) if !ok || len(row) < 7 { writeError(w, http.StatusInternalServerError, "invalid session data") return } status := getString(row[2]) wallet := getString(row[3]) apiKey := getString(row[4]) errorMsg := getString(row[5]) expiresAtStr := getString(row[6]) namespace := getString(row[1]) // Check expiration if still pending if status == "pending" { if expiresAt, err := time.Parse("2006-01-02 15:04:05", expiresAtStr); err == nil { if time.Now().UTC().After(expiresAt) { status = "expired" // Update in DB _, _ = db.Query(internalCtx, "UPDATE phantom_auth_sessions SET status = 'expired' WHERE id = ? AND status = 'pending'", sessionID, ) } } } resp := map[string]any{ "session_id": sessionID, "status": status, "namespace": namespace, } if wallet != "" { resp["wallet"] = wallet } if apiKey != "" { resp["api_key"] = apiKey } if errorMsg != "" { resp["error"] = errorMsg } writeJSON(w, http.StatusOK, resp) } // PhantomCompleteHandler completes Phantom authentication. // Called by the React auth app after the user signs with Phantom. // // POST /v1/auth/phantom/complete // Request body: { "session_id", "wallet", "nonce", "signature", "namespace" } // Response: { "success": true } func (h *Handlers) PhantomCompleteHandler(w http.ResponseWriter, r *http.Request) { if h.authService == nil { writeError(w, http.StatusServiceUnavailable, "auth service not initialized") return } if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } var req struct { SessionID string `json:"session_id"` Wallet string `json:"wallet"` Nonce string `json:"nonce"` Signature string `json:"signature"` Namespace string `json:"namespace"` } r.Body = http.MaxBytesReader(w, r.Body, 4096) if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json body") return } if req.SessionID == "" || req.Wallet == "" || req.Nonce == "" || req.Signature == "" { writeError(w, http.StatusBadRequest, "session_id, wallet, nonce and signature are required") return } if !sessionIDRegex.MatchString(req.SessionID) { writeError(w, http.StatusBadRequest, "invalid session_id format") return } ctx := r.Context() internalCtx := h.internalAuthFn(ctx) db := h.netClient.Database() // Validate session exists, is pending, and not expired res, err := db.Query(internalCtx, "SELECT status, expires_at FROM phantom_auth_sessions WHERE id = ? LIMIT 1", req.SessionID, ) if err != nil || res == nil || res.Count == 0 { writeError(w, http.StatusNotFound, "session not found") return } row, ok := res.Rows[0].([]interface{}) if !ok || len(row) < 2 { writeError(w, http.StatusInternalServerError, "invalid session data") return } status := getString(row[0]) expiresAtStr := getString(row[1]) if status != "pending" { writeError(w, http.StatusConflict, "session is not pending (status: "+status+")") return } if expiresAt, err := time.Parse("2006-01-02 15:04:05", expiresAtStr); err == nil { if time.Now().UTC().After(expiresAt) { _, _ = db.Query(internalCtx, "UPDATE phantom_auth_sessions SET status = 'expired' WHERE id = ?", req.SessionID, ) writeError(w, http.StatusGone, "session expired") return } } // Verify Ed25519 signature (Solana) verified, err := h.authService.VerifySignature(ctx, req.Wallet, req.Nonce, req.Signature, "SOL") if err != nil || !verified { h.updateSessionFailed(internalCtx, db, req.SessionID, "signature verification failed") writeError(w, http.StatusUnauthorized, "signature verification failed") return } // Mark nonce used namespace := strings.TrimSpace(req.Namespace) if namespace == "" { namespace = "default" } nsID, _ := h.resolveNamespace(ctx, namespace) h.markNonceUsed(ctx, nsID, strings.ToLower(req.Wallet), req.Nonce) // Verify NFT ownership (server-side) if h.solanaVerifier != nil { owns, err := h.solanaVerifier.VerifyNFTOwnership(ctx, req.Wallet) if err != nil { h.updateSessionFailed(internalCtx, db, req.SessionID, "NFT verification error: "+err.Error()) writeError(w, http.StatusInternalServerError, "NFT verification failed") return } if !owns { h.updateSessionFailed(internalCtx, db, req.SessionID, "wallet does not own required NFT") writeError(w, http.StatusForbidden, "wallet does not own an NFT from the required collection") return } } // Trigger namespace cluster provisioning if needed (for non-default namespaces) if h.clusterProvisioner != nil && namespace != "default" { _, _, needsProvisioning, checkErr := h.clusterProvisioner.CheckNamespaceCluster(ctx, namespace) if checkErr != nil { _ = checkErr // Log but don't fail auth } else if needsProvisioning { nsIDInt := 0 if id, ok := nsID.(int); ok { nsIDInt = id } else if id, ok := nsID.(int64); ok { nsIDInt = int(id) } else if id, ok := nsID.(float64); ok { nsIDInt = int(id) } _, _, provErr := h.clusterProvisioner.ProvisionNamespaceCluster(ctx, nsIDInt, namespace, req.Wallet) if provErr != nil { _ = provErr // Log but don't fail auth — provisioning is async } } } // Issue API key apiKey, err := h.authService.GetOrCreateAPIKey(ctx, req.Wallet, namespace) if err != nil { h.updateSessionFailed(internalCtx, db, req.SessionID, "failed to issue API key") writeError(w, http.StatusInternalServerError, "failed to issue API key") return } // Update session to completed (AND status = 'pending' prevents race condition) _, _ = db.Query(internalCtx, "UPDATE phantom_auth_sessions SET status = 'completed', wallet = ?, api_key = ? WHERE id = ? AND status = 'pending'", strings.ToLower(req.Wallet), apiKey, req.SessionID, ) writeJSON(w, http.StatusOK, map[string]any{ "success": true, }) } // updateSessionFailed marks a session as failed with an error message. func (h *Handlers) updateSessionFailed(ctx context.Context, db DatabaseClient, sessionID, errMsg string) { _, _ = db.Query(ctx, "UPDATE phantom_auth_sessions SET status = 'failed', error_message = ? WHERE id = ?", errMsg, sessionID) } // getString extracts a string from an interface value. func getString(v interface{}) string { if s, ok := v.(string); ok { return s } return "" }